Compare commits

..

2 Commits

Author SHA1 Message Date
Claude 1fad99f505 refactor(runtime): derive the group timer on the server
The group timer was derived in the client, which meant every view that wanted it
had to depend on the rundown query. That coupling was already awkward for the PiP
timer, whose separate react root needed its own query client, and it would have
spread to every remaining view.

Moving the derivation to the server removes the coupling. The value is still the
running event timer offset by the content scheduled around it, so the group keeps
behaving as if it were a single event containing all its children.

Because the group timer is the event timer offset by a constant, it changes exactly
when the event timer does and can share its broadcast throttling.

- add RuntimeStore.groupTimer, null unless the running group opted in
- split the group around the loaded event when the group is loaded, so the per tick
  cost is an addition rather than a walk of the rundown
- derive on the getState() projection so it cannot drift from the timer it is built on
- reduce the client to a plain selector, dropping the PiP query client workaround

Also fixes elapsed time, which was calculated from the group duration and so clamped
to zero for as long as time added to an event kept the group in credit. It is now
derived symmetrically with the remaining time, and the two always add up to the total.

Timer and PiP shared eight identical branches for choosing between the two timers.
These now go through a single resolver. A group has no warning or danger thresholds,
so it only reports as running or overtime, and feeding that phase through the existing
modifiers makes the suppression of warning and danger a consequence of what a group is
rather than something each view has to remember.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kniqs443KUNCRABwVJwT7K
2026-07-26 15:46:58 +00:00
Claude 636b2e7c3e feat(rundown): add opt-in shared group timer
Groups can now opt into a shared timer, so stage outputs show how much time
the whole block has left instead of only the running event.

The value is derived in the client from the running event timer plus the
content still scheduled after it, which makes the group behave as if it were
a single event containing all its children. Keeping it relative to the event
timer (rather than recalculating from the clock) means pause, added time,
overtime, roll and midnight rollovers are all inherited, and moving between
events inside a group cannot introduce a discontinuity.

The remainder aggregation mirrors the server side group duration calculation:
non playable entries are skipped and gaps are accounted for.

Warning and danger thresholds belong to the event and carry no meaning against
a group duration, so they are suppressed while the group timer is showing.
Overtime is kept, but only once the group itself has run out of time.

- add OntimeGroup.useGroupTimer, defaulting to false
- show the group timer in the timer view and the PiP timer
- indicate the override in the timer view, PiP, group header and timer preview
- add a switch to the group editor

The PiP window mounts its own react root, which had no query client and could
therefore not read the rundown. It is now wrapped in a provider, which also
means its view settings are no longer frozen at the moment the window opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kniqs443KUNCRABwVJwT7K
2026-07-26 06:24:15 +00:00
33 changed files with 814 additions and 426 deletions
@@ -154,6 +154,9 @@ export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer,
}));
/** Shared timer for the running group, null unless the group opted in */
export const useGroupTimer = createSelector((state: RuntimeStore) => state.groupTimer);
export const useNextFlag = createSelector((state: RuntimeStore) => ({
id: state.eventFlag?.id ?? null,
expectedStart: state.offset.expectedFlagStart,
@@ -1,11 +1,11 @@
import { TimerPhase, TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { IoArrowDown, IoArrowUp, IoBan, IoFolderOutline, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { useMessagePreview } from '../../../common/hooks/useSocket';
import { useGroupTimer, useMessagePreview } from '../../../common/hooks/useSocket';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
@@ -22,6 +22,7 @@ const secondarySourceLabels: Record<string, string> = {
export default function TimerPreview() {
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
const { data } = useViewSettings();
const isGroupTimerActive = useGroupTimer() !== null;
const main = (() => {
if (showTimerMessage) return 'Message';
@@ -105,6 +106,14 @@ export default function TimerPreview() {
>
<LuArrowDownToLine />
</Tooltip>
<Tooltip
text={isGroupTimerActive ? 'Views are showing the shared group timer' : 'Views are showing the event timer'}
render={<span />}
className={style.statusIcon}
data-active={isGroupTimerActive}
>
<IoFolderOutline />
</Tooltip>
</div>
</div>
);
@@ -5,6 +5,8 @@ import { useCallback } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import AppLink from '../../../common/components/link/app-link/AppLink';
import Switch from '../../../common/components/switch/Switch';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getOffsetState } from '../../../common/utils/offset';
@@ -95,6 +97,22 @@ export default function GroupEditor({ group }: GroupEditorProps) {
submitHandler={handleSubmit}
/>
</div>
<div>
<Tooltip
text='Timer views count the scheduled duration of the whole group instead of the running event'
render={<Editor.Label htmlFor='useGroupTimer' />}
>
Group timer
</Tooltip>
<Editor.Label className={style.switchLabel}>
<Switch
id='useGroupTimer'
checked={group.useGroupTimer}
onCheckedChange={(value) => updateEntry({ id: group.id, useGroupTimer: value })}
/>
{group.useGroupTimer ? 'On' : 'Off'}
</Editor.Label>
</div>
</div>
<div className={style.column}>
@@ -15,6 +15,7 @@ import {
import IconButton from '../../../common/components/buttons/IconButton';
import Tag from '../../../common/components/tag/Tag';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
@@ -189,6 +190,13 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
</div>
</div>
{data.useGroupTimer && (
<div className={style.metaEntry}>
<Tooltip text='Views show a shared timer for this group instead of the event timer'>
<Tag>Group timer</Tag>
</Tooltip>
</div>
)}
</div>
</div>
</div>
@@ -68,6 +68,18 @@
opacity: 0;
}
}
// signals that the displayed timer belongs to the group, not to the running event
.group-indicator {
text-align: center;
text-transform: uppercase;
letter-spacing: 0.25em;
font-weight: 600;
font-size: 2.5vw;
line-height: 1;
opacity: 0.7;
color: var(--timer-colour, $ui-white);
}
}
.secondary {
@@ -2,7 +2,7 @@ import { ViewSettings } from 'ontime-types';
import { FitText } from '../../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import { useTimerSocket } from '../../../common/hooks/useSocket';
import { useGroupTimer, useTimerSocket } from '../../../common/hooks/useSocket';
import { cx } from '../../../common/utils/styleUtils';
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
import {
@@ -10,9 +10,8 @@ import {
getIsPlaying,
getSecondaryDisplay,
getShowMessage,
getShowModifiers,
getShowProgressBar,
getTotalTime,
resolveTimerDisplay,
} from '../../timer/timer.utils';
import { getTimerColour } from '../../utils/presentation.utils';
@@ -24,23 +23,27 @@ interface PipTimerProps {
export function PipTimer({ viewSettings }: PipTimerProps) {
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
const groupTimer = useGroupTimer();
// gather modifiers
const showOverlay = getShowMessage(message.timer);
const { showFinished, showWarning, showDanger } = getShowModifiers(
timerTypeNow,
countToEndNow,
time.phase,
false,
'',
false,
);
const timerDisplay = resolveTimerDisplay({
time,
groupTimer,
event: eventNow,
timerType: timerTypeNow,
countToEnd: countToEndNow,
freezeOvertime: false,
freezeMessage: '',
hidePhase: false,
});
const { showFinished, showWarning, showDanger } = timerDisplay;
const isPlaying = getIsPlaying(time.playback);
const showProgressBar = getShowProgressBar(timerTypeNow);
// gather timer data
const totalTime = getTotalTime(time.duration, time.addedTime);
const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow);
const stageTimer = getTimerByType(false, timerTypeNow, clock, timerDisplay.source, timerTypeNow);
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
removeSeconds: false,
removeLeadingZero: false,
@@ -80,10 +83,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
<div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }}
data-phase={time.phase}
data-phase={timerDisplay.phase}
>
{display}
</div>
{timerDisplay.isGroup && <div className='group-indicator'>group</div>}
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
<FitText mode='multi' min={12} max={256}>
{secondaryContent}
@@ -94,12 +98,12 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
{showProgressBar && (
<MultiPartProgressBar
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current}
complete={totalTime}
now={timerDisplay.source.current}
complete={timerDisplay.total}
normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning}
warning={timerDisplay.warning}
warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger}
danger={timerDisplay.danger}
dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished}
/>
+12
View File
@@ -131,6 +131,18 @@
opacity: 0;
}
}
// signals that the displayed timer belongs to the group, not to the running event
.group-indicator {
text-align: center;
text-transform: uppercase;
letter-spacing: 0.25em;
font-weight: 600;
font-size: 1.5vw;
line-height: 1;
opacity: 0.7;
color: var(--timer-colour, var(--timer-color-override, $ui-white));
}
}
.secondary {
+19 -15
View File
@@ -8,7 +8,7 @@ import TitleCard from '../../common/components/title-card/TitleCard';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
import { useTimerSocket } from '../../common/hooks/useSocket';
import { useGroupTimer, useTimerSocket } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { cx } from '../../common/utils/styleUtils';
import { formatTime, getDefaultFormat } from '../../common/utils/time';
@@ -25,9 +25,8 @@ import {
getSecondaryDisplay,
getShowClock,
getShowMessage,
getShowModifiers,
getShowProgressBar,
getTotalTime,
resolveTimerDisplay,
} from './timer.utils';
import { TimerData, useTimerData } from './useTimerData';
@@ -72,20 +71,25 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
timeformat,
} = useTimerOptions();
const groupTimer = useGroupTimer();
const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes');
// gather modifiers
const viewTimerType = timerType ?? timerTypeNow;
const showOverlay = getShowMessage(message.timer);
const { showEndMessage, showFinished, showWarning, showDanger } = getShowModifiers(
timerTypeNow,
countToEndNow,
time.phase,
const timerDisplay = resolveTimerDisplay({
time,
groupTimer,
event: eventNow,
timerType: timerTypeNow,
countToEnd: countToEndNow,
freezeOvertime,
freezeMessage,
hidePhase,
);
});
const { showEndMessage, showFinished, showWarning, showDanger } = timerDisplay;
const isPlaying = getIsPlaying(time.playback);
const showClock = !hideClock && getShowClock(viewTimerType);
const showProgressBar = !hideProgress && getShowProgressBar(viewTimerType);
@@ -102,8 +106,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
);
// gather timer data
const totalTime = getTotalTime(time.duration, time.addedTime);
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, time, timerType);
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, timerDisplay.source, timerType);
const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
removeSeconds: hideTimerSeconds,
removeLeadingZero: removeLeadingZeros,
@@ -178,11 +181,12 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
className={cx(['timer', subduePaused && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }}
data-type={viewTimerType}
data-phase={time.phase}
data-phase={timerDisplay.phase}
>
{display}
</div>
)}
{timerDisplay.isGroup && !showEndMessage && <div className='group-indicator'>group</div>}
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
<FitText mode='multi' min={64} max={256}>
{secondaryContent}
@@ -193,12 +197,12 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
{showProgressBar && (
<MultiPartProgressBar
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current}
complete={totalTime}
now={timerDisplay.source.current}
complete={timerDisplay.total}
normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning}
warning={timerDisplay.warning}
warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger}
danger={timerDisplay.danger}
dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished}
/>
@@ -1,4 +1,5 @@
import {
GroupTimerState,
MaybeNumber,
MessageState,
OntimeEvent,
@@ -6,6 +7,7 @@ import {
RundownEntries,
TimerMessage,
TimerPhase,
TimerState,
TimerType,
} from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils';
@@ -114,6 +116,57 @@ export function getShowModifiers(
};
}
interface ResolveTimerDisplayOptions {
time: TimerState;
/** when present, views show the group instead of the running event */
groupTimer: GroupTimerState | null;
event: Pick<OntimeEvent, 'timeWarning' | 'timeDanger'> | null;
timerType: TimerType;
countToEnd: boolean;
freezeOvertime: boolean;
freezeMessage: string;
hidePhase: boolean;
}
/**
* Resolves which timer a view should display, and the modifiers that go with it.
*
* A group has no warning or danger thresholds of its own, so it only ever reports as
* running or overtime. Feeding that phase through `getShowModifiers` means the warning
* and danger states are suppressed as a consequence of what a group is, rather than
* every view having to remember to special case them.
*/
export function resolveTimerDisplay({
time,
groupTimer,
event,
timerType,
countToEnd,
freezeOvertime,
freezeMessage,
hidePhase,
}: ResolveTimerDisplayOptions) {
const isGroup = groupTimer !== null;
const phase = isGroup
? groupTimer.current <= 0
? TimerPhase.Overtime
: TimerPhase.Default
: time.phase;
return {
isGroup,
phase,
/** the values to render, shaped for getTimerByType */
source: isGroup ? groupTimer : time,
/** the target of a progress bar */
total: isGroup ? groupTimer.duration : getTotalTime(time.duration, time.addedTime),
// thresholds belong to the event, they carry no meaning against a group duration
warning: isGroup ? undefined : event?.timeWarning,
danger: isGroup ? undefined : event?.timeDanger,
...getShowModifiers(timerType, countToEnd, phase, freezeOvertime, freezeMessage, hidePhase),
};
}
/**
* What, if anything, should be displayed in the secondary field
*/
@@ -411,6 +411,7 @@ export function migrateRundown(
targetDuration: null,
colour: '', //leave default colour
custom: {}, // leave empty
useGroupTimer: false, // opt-in feature, off for migrated projects
// !==== RUNTIME METADATA ====! //
revision: -1,
timeStart: null,
@@ -316,6 +316,7 @@ describe('v3 to v4', () => {
targetDuration: null,
timeEnd: null,
timeStart: null,
useGroupTimer: false,
},
event2: {
id: 'event2',
@@ -392,6 +393,7 @@ describe('v3 to v4', () => {
targetDuration: null,
timeEnd: null,
timeStart: null,
useGroupTimer: false,
},
delay: {
type: SupportedEntry.Delay,
@@ -301,6 +301,23 @@ describe('parseRundown()', () => {
expect(parsedRundown.entries.group).toMatchObject({ entries: ['1', '2'] });
expect(Object.keys(parsedRundown.entries).length).toEqual(3);
});
it('normalises groups from projects made before the group timer existed', () => {
const rundown = {
id: 'test',
title: '',
order: ['group'],
flatOrder: ['group'],
entries: {
// a group as it would have been persisted by a previous version
group: { id: 'group', type: SupportedEntry.Group, title: 'legacy', entries: [] },
},
revision: 1,
} as unknown as Rundown;
const parsedRundown = parseRundown(rundown, {});
expect(parsedRundown.entries.group).toMatchObject({ id: 'group', useGroupTimer: false });
});
});
describe('sanitiseCustomFields()', () => {
@@ -15,6 +15,7 @@ import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } fr
import { parseRundown } from '../rundown.parser.js';
import {
calculateDayOffset,
createGroupPatch,
deleteById,
doesInvalidateMetadata,
getIntegerAndFraction,
@@ -610,3 +611,25 @@ describe('isLoadedPlayable()', () => {
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
});
});
describe('createGroupPatch()', () => {
it('preserves useGroupTimer when it is not part of the patch', () => {
const original = makeOntimeGroup({ id: 'group', useGroupTimer: true });
const patched = createGroupPatch(original, { title: 'Morning sessions' });
expect(patched.title).toBe('Morning sessions');
expect(patched.useGroupTimer).toBe(true);
});
it('updates useGroupTimer when it is part of the patch', () => {
const original = makeOntimeGroup({ id: 'group', useGroupTimer: false });
expect(createGroupPatch(original, { useGroupTimer: true }).useGroupTimer).toBe(true);
expect(createGroupPatch(original, { useGroupTimer: false }).useGroupTimer).toBe(false);
});
it('ignores non boolean values for useGroupTimer', () => {
const original = makeOntimeGroup({ id: 'group', useGroupTimer: true });
// @ts-expect-error -- testing a value coming from an unvalidated request
expect(createGroupPatch(original, { useGroupTimer: 'nope' }).useGroupTimer).toBe(true);
});
});
@@ -179,6 +179,8 @@ export function createGroupPatch(originalGroup: OntimeGroup, patchGroup: Partial
entries: patchGroup.entries ?? originalGroup.entries,
targetDuration: maybeTargetDuration(),
colour: makeString(patchGroup.colour, originalGroup.colour),
useGroupTimer:
typeof patchGroup.useGroupTimer === 'boolean' ? patchGroup.useGroupTimer : originalGroup.useGroupTimer,
revision: originalGroup.revision,
timeStart: originalGroup.timeStart,
timeEnd: originalGroup.timeEnd,
+1
View File
@@ -147,6 +147,7 @@ There are four entry types discriminated by \`type\`:
note: string
entries: EntryId[]
targetDuration: number | null
useGroupTimer: boolean // views show a shared timer for the whole group
custom: { [key: string]: string }
timeStart: number | null // calculated from nested entries (runtime)
timeEnd: number | null // calculated from nested entries (runtime)
+1
View File
@@ -207,6 +207,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
eventStore.init({
clock: state.clock,
timer: state.timer,
groupTimer: state.groupTimer,
message: { ...runtimeStorePlaceholder.message },
offset: state.offset,
rundown: state.rundown,
+1 -17
View File
@@ -1,5 +1,5 @@
import { Day, Duration, Instant, TimeOfDay } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs } from 'ontime-utils';
import { MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils';
/** Returns the current instant */
export function now(): Instant {
@@ -59,10 +59,6 @@ export function addDuration(instant: Instant, duration: Duration | Duration[]):
return (instant + totalDuration) as Instant;
}
export function combineValues<T extends Duration | Day>(...duration: T[]): T {
return Array.isArray(duration) ? (duration.reduce<number>((total, current) => total + current, 0) as T) : duration;
}
/**
* Calculates elapsed time on the clock from a starting time to the current time
* Handles overnight crossing (when current < start, assumes we've crossed midnight)
@@ -84,15 +80,3 @@ export function daysSinceStart(startEpoch: Instant, currentEpoch: Instant): Day
return (currentDaySerial - startDaySerial) as Day;
}
export const ZERO_DURATION = 0 as Duration;
/**
* utility function to generate Instant times
*/
export function generateInstanceFromClock(hour: number, minutes: number, second: number, ms = 0) {
return toInstant(
(hour * MILLIS_PER_HOUR + minutes * MILLIS_PER_MINUTE + second * MILLIS_PER_SECOND + ms) as TimeOfDay,
now(),
);
}
+11
View File
@@ -39,6 +39,7 @@ export const stageRundown: Rundown = {
note: '',
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -172,6 +173,7 @@ export const stageRundown: Rundown = {
note: '',
entries: ['0aaa7d'],
targetDuration: null,
useGroupTimer: false,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -219,6 +221,7 @@ export const stageRundown: Rundown = {
note: '',
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -360,6 +363,7 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
targetDuration: null,
useGroupTimer: false,
colour: '#A790F5',
custom: {},
revision: 0,
@@ -476,6 +480,7 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -588,6 +593,7 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0301'],
targetDuration: null,
useGroupTimer: false,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -634,6 +640,7 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -768,6 +775,7 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0101', 'br0102', 'br0103'],
targetDuration: null,
useGroupTimer: false,
colour: '#ED3333',
custom: {},
revision: 0,
@@ -851,6 +859,7 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0201', 'br0202', 'br0203', 'br0204'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -959,6 +968,7 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0301', 'br0302'],
targetDuration: null,
useGroupTimer: false,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -1029,6 +1039,7 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0401', 'br0402', 'br0403'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -1,13 +1,26 @@
import { Duration, EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import {
EndAction,
MaybeNumber,
OntimeEvent,
OntimeGroup,
Playback,
Rundown,
SupportedEntry,
TimeOfDay,
TimeStrategy,
TimerPhase,
TimerType,
} from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
import * as timeCore from '../../lib/time-core/timeCore.js';
import type { InternalRuntimeState, RuntimeState } from '../../stores/runtimeState.js';
import type { RuntimeState } from '../../stores/runtimeState.js';
import {
findDayOffset,
getCurrent,
getElapsed,
getExpectedFinish,
getGroupTimer,
getGroupTiming,
getRuntimeOffset,
getTimerPhase,
hasCrossedMidnight,
@@ -20,48 +33,48 @@ const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeSt
describe('getElapsed()', () => {
it('returns active elapsed time from startedAt without add-time adjustments', () => {
const state = {
_now: timeCore.generateInstanceFromClock(0, 5, 0),
clock: 5 * MILLIS_PER_MINUTE,
timer: {
addedTime: -10 * MILLIS_PER_MINUTE,
current: 15 * MILLIS_PER_MINUTE,
duration: 20 * MILLIS_PER_MINUTE,
startedAt: timeCore.generateInstanceFromClock(0, 2, 0),
startedAt: 2 * MILLIS_PER_MINUTE,
},
_timer: {
pausedAt: null,
pausedDuration: 0,
},
} as InternalRuntimeState;
} as RuntimeState;
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
});
it('subtracts accumulated pause time', () => {
const state = {
_now: timeCore.generateInstanceFromClock(0, 10, 0),
clock: 10 * MILLIS_PER_MINUTE,
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 2, 0),
startedAt: 2 * MILLIS_PER_MINUTE,
},
_timer: {
pausedAt: null,
pausedDuration: 5 * MILLIS_PER_MINUTE,
},
} as InternalRuntimeState;
} as RuntimeState;
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
});
it('uses the current pause start while paused', () => {
const state = {
_now: timeCore.generateInstanceFromClock(0, 10, 0),
clock: 10 * MILLIS_PER_MINUTE,
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 2, 0),
startedAt: 2 * MILLIS_PER_MINUTE,
},
_timer: {
pausedAt: 7 * MILLIS_PER_MINUTE,
pausedDuration: 1 * MILLIS_PER_MINUTE,
},
} as InternalRuntimeState;
} as RuntimeState;
expect(getElapsed(state)).toBe(4 * MILLIS_PER_MINUTE);
});
@@ -83,7 +96,7 @@ describe('getExpectedFinish()', () => {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(null);
});
@@ -96,13 +109,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 0,
duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
startedAt: 10,
},
_timer: {
pausedAt: null,
hasFinished: true,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(20);
});
@@ -115,13 +128,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 0,
duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
startedAt: 1,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(11);
});
@@ -134,13 +147,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 20,
duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
startedAt: 1,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(31);
@@ -154,13 +167,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: -10,
duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
startedAt: 1,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(1);
@@ -174,13 +187,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: -100,
duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
startedAt: 1,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(1);
@@ -194,13 +207,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 0,
duration: 0,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
startedAt: 1,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(1);
@@ -214,13 +227,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 0,
duration: dayInMs,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
startedAt: 10,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(10);
@@ -235,13 +248,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 10,
duration: dayInMs,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
startedAt: 10,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(40);
@@ -254,7 +267,7 @@ describe('getExpectedFinish()', () => {
},
timer: {
addedTime: 0,
startedAt: timeCore.generateInstanceFromClock(22, 0, 0),
startedAt: 79200000, // 22:00:00
},
_timer: {
pausedAt: null,
@@ -264,7 +277,7 @@ describe('getExpectedFinish()', () => {
actualStart: 79200000,
plannedEnd: 600000,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
// expected finish is not a duration but a point in time
@@ -280,7 +293,7 @@ describe('getCurrent()', () => {
timeEnd: 30,
timerType: TimerType.CountDown,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0),
clock: 0,
timer: {
addedTime: 10,
duration: 111, // <-- we take the duration value
@@ -290,7 +303,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(111);
@@ -301,17 +314,17 @@ describe('getCurrent()', () => {
timeEnd: 10,
timerType: TimerType.CountDown,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 1),
clock: 1,
timer: {
addedTime: 0,
duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0),
startedAt: 0,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(9);
@@ -322,17 +335,17 @@ describe('getCurrent()', () => {
timeEnd: 10,
timerType: TimerType.CountDown,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 1),
clock: 1,
timer: {
addedTime: 10,
duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0),
startedAt: 0,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(19);
@@ -343,17 +356,17 @@ describe('getCurrent()', () => {
timeEnd: 20,
timerType: TimerType.CountDown,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 10),
clock: 10,
timer: {
addedTime: 0,
duration: dayInMs + 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
startedAt: 10,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(dayInMs + 10);
@@ -364,17 +377,17 @@ describe('getCurrent()', () => {
timeEnd: 20,
timerType: TimerType.CountDown,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5),
clock: 5,
timer: {
addedTime: 0,
duration: dayInMs + 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
startedAt: 10,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(15);
@@ -385,17 +398,17 @@ describe('getCurrent()', () => {
timeEnd: 20,
timerType: TimerType.CountDown,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5),
clock: 5,
timer: {
addedTime: 20,
duration: dayInMs + 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
startedAt: 10,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(35);
@@ -408,7 +421,7 @@ describe('getCurrent()', () => {
timeEnd: 100,
countToEnd: true,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30),
clock: 30,
timer: {
addedTime: 0,
duration: 100,
@@ -421,7 +434,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(70);
@@ -433,11 +446,11 @@ describe('getCurrent()', () => {
timeEnd: 100,
countToEnd: true,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30),
clock: 30,
timer: {
addedTime: 0,
duration: 100,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
startedAt: 10,
},
rundown: {
plannedEnd: 100,
@@ -446,7 +459,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(70);
@@ -458,11 +471,11 @@ describe('getCurrent()', () => {
timeEnd: 100,
countToEnd: true,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30),
clock: 30,
timer: {
addedTime: 7,
duration: 100,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
startedAt: 10,
},
rundown: {
plannedEnd: 100,
@@ -471,7 +484,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(77);
@@ -484,11 +497,11 @@ describe('getCurrent()', () => {
timeEnd: 600000, // 00:10:00
countToEnd: true,
},
_now: timeCore.generateInstanceFromClock(22, 5, 0),
clock: 79500000, // 22:05:00
timer: {
addedTime: 0,
duration: Infinity, // not relevant,
startedAt: timeCore.generateInstanceFromClock(22, 0, 0),
startedAt: 79200000, // 22:00:00
},
rundown: {
actualStart: 79200000,
@@ -498,7 +511,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(dayInMs - 79500000 + 600000);
@@ -506,7 +519,7 @@ describe('getCurrent()', () => {
it('handles events that were started late', () => {
const state = {
_now: timeCore.generateInstanceFromClock(22, 46, 40), // 22:46:40 <--- starting 16 min after the scheduled end
clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
eventNow: {
timeStart: 77400000, // 21:30:00
timeEnd: 81000000, // 22:30:00
@@ -516,7 +529,7 @@ describe('getCurrent()', () => {
timer: {
addedTime: 0,
duration: Infinity, // not relevant,
startedAt: timeCore.generateInstanceFromClock(22, 0, 0),
startedAt: 79200000, // 22:00:00
},
rundown: {
actualStart: 82000000, // 22:46:40 <--- started now
@@ -526,7 +539,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(81000000 - 82000000); // <-- planned end - now
@@ -542,17 +555,17 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => {
timeEnd: 10,
timerType: TimerType.CountDown,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0),
clock: 0,
timer: {
addedTime: 0,
duration,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0),
startedAt: 0,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const expectedFinish = getExpectedFinish(state);
const current = getCurrent(state);
@@ -570,25 +583,25 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => {
timeEnd: 10,
timerType: TimerType.CountDown,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5),
clock: 5,
timer: {
addedTime: 3,
duration,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0),
startedAt: 0,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const expectedFinish = getExpectedFinish(state);
const current = getCurrent(state);
const elapsed = duration - current;
expect(expectedFinish).toBe(13);
expect(current).toBe(8);
expect(elapsed).toBe(2);
expect(current).toBe(8);
});
});
@@ -872,7 +885,7 @@ describe('getRuntimeOffset()', () => {
dayOffset: 0,
},
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150),
startedAt: 150,
addedTime: 0,
current: 0,
},
@@ -884,9 +897,9 @@ describe('getRuntimeOffset()', () => {
plannedStart: 100,
currentDay: 0,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 150),
clock: 150,
_startDayOffset: 0,
} as InternalRuntimeState;
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(50);
@@ -900,7 +913,7 @@ describe('getRuntimeOffset()', () => {
dayOffset: 0,
},
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150), // we started 50ms delayed
startedAt: 150, // we started 50ms delayed
addedTime: 10, // we compensated with 10ms
current: 10, // we are 10ms into the timer
},
@@ -913,7 +926,7 @@ describe('getRuntimeOffset()', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as InternalRuntimeState;
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(60);
@@ -928,7 +941,7 @@ describe('getRuntimeOffset()', () => {
dayOffset: 0,
},
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100), // we started ontime
startedAt: 100, // we started ontime
current: -10, // we are 10 seconds over
addedTime: 0, // we have not compensated with added time
},
@@ -941,7 +954,7 @@ describe('getRuntimeOffset()', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as InternalRuntimeState;
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(10);
@@ -955,9 +968,9 @@ describe('getRuntimeOffset()', () => {
timeEnd: 150,
dayOffset: 0,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 150),
clock: 150,
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100), // started on time
startedAt: 100, // started on time
current: 25, // are 25ms into it
addedTime: 0,
},
@@ -970,7 +983,7 @@ describe('getRuntimeOffset()', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as InternalRuntimeState;
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(25);
@@ -978,7 +991,7 @@ describe('getRuntimeOffset()', () => {
it('offset doesnt exist if we havent started', () => {
const state = {
_now: timeCore.generateInstanceFromClock(22, 48, 0), // 78480789
clock: 78480789,
eventNow: {
id: 'd6a2ce',
timeStart: 77400000,
@@ -1012,7 +1025,7 @@ describe('getRuntimeOffset()', () => {
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as InternalRuntimeState;
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(0);
@@ -1020,7 +1033,7 @@ describe('getRuntimeOffset()', () => {
it('with time-to-end, offsets dont exist if we are not in overtime', () => {
const state = {
_now: timeCore.generateInstanceFromClock(22, 13, 20),
clock: 80000000, // 22:13:20
eventNow: {
id: 'd6a2ce',
type: 'event',
@@ -1064,11 +1077,11 @@ describe('getRuntimeOffset()', () => {
expectedFinish: 81600000,
playback: Playback.Play,
secondaryTimer: null,
startedAt: timeCore.generateInstanceFromClock(21, 40, 0),
startedAt: 78000000,
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as InternalRuntimeState;
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(0);
@@ -1076,7 +1089,7 @@ describe('getRuntimeOffset()', () => {
it('with time-to-end, offset is the overtime', () => {
const state = {
_now: timeCore.generateInstanceFromClock(22, 46, 40),
clock: 82000000, // 22:46:40
eventNow: {
id: 'd6a2ce',
type: 'event',
@@ -1120,11 +1133,11 @@ describe('getRuntimeOffset()', () => {
expectedFinish: 81600000,
playback: Playback.Play,
secondaryTimer: null,
startedAt: timeCore.generateInstanceFromClock(21, 40, 0),
startedAt: 78000000,
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as InternalRuntimeState;
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
@@ -1132,7 +1145,7 @@ describe('getRuntimeOffset()', () => {
it('handles time-to-end started after the end time', () => {
const state = {
_now: timeCore.generateInstanceFromClock(22, 46, 40), // 22:46:40 <--- starting 16m 40s after the scheduled end
clock: 82000000, // 22:46:40 <--- starting 16m 40s after the scheduled end
eventNow: {
id: 'd6a2ce',
timeStart: 77400000, // 21:30:00
@@ -1165,14 +1178,14 @@ describe('getRuntimeOffset()', () => {
expectedFinish: 82000000 + 3600000, // <--- now + duration
playback: Playback.Play,
secondaryTimer: null,
startedAt: timeCore.generateInstanceFromClock(22, 46, 40),
startedAt: 82000000, // <--- started now
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as InternalRuntimeState;
} as RuntimeState;
const updateCurrent = getCurrent(state);
state.timer.current = updateCurrent as Duration;
state.timer.current = updateCurrent;
const { absolute } = getRuntimeOffset(state);
expect(millisToString(absolute)).toBe('00:16:40');
expect(absolute).toBe(82000000 - 81000000); // <-- now - planned end
@@ -1188,7 +1201,7 @@ describe('getRuntimeOffset() relative', () => {
dayOffset: 0,
},
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150),
startedAt: 150,
addedTime: 0,
current: 0,
},
@@ -1201,7 +1214,7 @@ describe('getRuntimeOffset() relative', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as InternalRuntimeState;
} as RuntimeState;
const { absolute, relative } = getRuntimeOffset(state);
expect(absolute).toBe(0);
@@ -1215,7 +1228,7 @@ describe('getRuntimeOffset() relative', () => {
dayOffset: 0,
},
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150),
startedAt: 150,
addedTime: 0,
current: 0,
},
@@ -1228,7 +1241,7 @@ describe('getRuntimeOffset() relative', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as InternalRuntimeState;
} as RuntimeState;
const { absolute, relative } = getRuntimeOffset(state);
expect(absolute).toBe(50);
@@ -1242,7 +1255,7 @@ describe('getRuntimeOffset() relative', () => {
dayOffset: 0,
},
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100),
startedAt: 100,
addedTime: 0,
current: 0,
},
@@ -1255,7 +1268,7 @@ describe('getRuntimeOffset() relative', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as InternalRuntimeState;
} as RuntimeState;
const { absolute, relative } = getRuntimeOffset(state);
expect(absolute).toBe(-50);
@@ -1277,7 +1290,7 @@ describe('getTimerPhase()', () => {
secondaryTimer: null,
startedAt: null,
},
} as InternalRuntimeState;
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.None);
@@ -1295,7 +1308,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100,
timeWarning: 200,
},
} as InternalRuntimeState;
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Overtime);
@@ -1313,7 +1326,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100,
timeWarning: 200,
},
} as InternalRuntimeState;
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Danger);
@@ -1331,7 +1344,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100,
timeWarning: 200,
},
} as InternalRuntimeState;
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Warning);
@@ -1349,7 +1362,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100,
timeWarning: 200,
},
} as InternalRuntimeState;
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Default);
@@ -1357,7 +1370,7 @@ describe('getTimerPhase()', () => {
it('#1042 identifies waiting to roll', () => {
const state = {
_now: timeCore.generateInstanceFromClock(16, 28, 11), //55691050
clock: 55691050,
eventNow: null,
eventNext: null,
rundown: {
@@ -1383,13 +1396,13 @@ describe('getTimerPhase()', () => {
startedAt: null,
},
_timer: {
forceFinish: false,
forceFinish: null,
pausedAt: null,
},
_rundown: {
totalDelay: 0,
},
} as InternalRuntimeState;
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Pending);
@@ -1397,7 +1410,7 @@ describe('getTimerPhase()', () => {
it('#1042 identifies waiting to roll', () => {
const state = {
_now: timeCore.generateInstanceFromClock(16, 28, 11), //55691050
clock: 55691050,
eventNow: null,
eventNext: null,
rundown: {
@@ -1423,13 +1436,13 @@ describe('getTimerPhase()', () => {
startedAt: null,
},
_timer: {
forceFinish: false,
forceFinish: null,
pausedAt: null,
},
_rundown: {
totalDelay: 0,
},
} as InternalRuntimeState;
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Pending);
@@ -1453,3 +1466,138 @@ describe('findDay()', () => {
expect(findDayOffset(22 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(0); // -> 1
});
});
describe('getGroupTiming()', () => {
const makeEvent = (id: string, duration: number, patch: object = {}) =>
({ id, type: SupportedEntry.Event, duration, gap: 0, skip: false, parent: 'group', ...patch }) as OntimeEvent;
const makeGroup = (entries: string[]) => ({ id: 'group', type: SupportedEntry.Group, entries }) as OntimeGroup;
const makeEntries = (...entries: OntimeEvent[]) =>
Object.fromEntries(entries.map((entry) => [entry.id, entry])) as Rundown['entries'];
it('splits the scheduled content around the loaded event', () => {
const group = makeGroup(['1', '2', '3']);
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20), makeEvent('3', 30));
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 50 });
expect(getGroupTiming(group, entries, '2')).toStrictEqual({ before: 10, after: 30 });
expect(getGroupTiming(group, entries, '3')).toStrictEqual({ before: 30, after: 0 });
});
it('accounts for the gaps between events', () => {
const group = makeGroup(['1', '2', '3']);
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20, { gap: 5 }), makeEvent('3', 30, { gap: 7 }));
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 20 + 5 + 30 + 7 });
// the gap before the loaded event has already passed, so it counts as time spent
expect(getGroupTiming(group, entries, '2')).toStrictEqual({ before: 10 + 5, after: 30 + 7 });
});
it('splits into values which add up to the group duration', () => {
const group = makeGroup(['1', '2', '3']);
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20, { gap: 5 }), makeEvent('3', 30));
const groupDuration = 10 + 20 + 5 + 30;
for (const id of ['1', '2', '3']) {
const timing = getGroupTiming(group, entries, id)!;
expect(timing.before + entries[id].duration + timing.after).toBe(groupDuration);
}
});
it('skips entries which are not playable events', () => {
const group = makeGroup(['1', '2', '3', '4']);
const entries = {
...makeEntries(makeEvent('1', 10), makeEvent('2', 20, { skip: true }), makeEvent('4', 40)),
'3': { id: '3', type: SupportedEntry.Milestone, parent: 'group' },
} as Rundown['entries'];
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 40 });
});
it('returns null when the loaded event is not part of the group', () => {
const group = makeGroup(['1', '2']);
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20));
expect(getGroupTiming(group, entries, 'elsewhere')).toBeNull();
});
});
describe('getGroupTimer()', () => {
const makeState = (patch: {
useGroupTimer?: boolean;
duration?: number;
timing?: { before: number; after: number } | null;
current?: MaybeNumber;
elapsed?: MaybeNumber;
addedTime?: number;
}) =>
({
groupNow: { duration: patch.duration ?? 100, useGroupTimer: patch.useGroupTimer ?? true },
_groupTiming: patch.timing === undefined ? { before: 0, after: 0 } : patch.timing,
timer: {
current: patch.current ?? null,
elapsed: patch.elapsed ?? null,
addedTime: patch.addedTime ?? 0,
},
}) as RuntimeState;
it('is null when there is no group running', () => {
expect(getGroupTimer({ groupNow: null, _groupTiming: null } as RuntimeState)).toBeNull();
});
it('is null when the group has not opted in', () => {
expect(getGroupTimer(makeState({ useGroupTimer: false, current: 10 }))).toBeNull();
});
it('is null when the loaded event is not part of the group', () => {
expect(getGroupTimer(makeState({ timing: null, current: 10 }))).toBeNull();
});
it('offsets the event timer by the content scheduled around it', () => {
const state = makeState({ duration: 100, timing: { before: 30, after: 50 }, current: 20, elapsed: 0 });
expect(getGroupTimer(state)).toStrictEqual({ current: 70, elapsed: 30, duration: 100 });
});
it('keeps elapsed and current adding up to the total', () => {
const state = makeState({ duration: 100, timing: { before: 30, after: 50 }, current: 12, elapsed: 8 });
const groupTimer = getGroupTimer(state)!;
expect(groupTimer.elapsed + groupTimer.current).toBe(groupTimer.duration);
});
it('grows the total with the time added to the running event', () => {
const state = makeState({
duration: 100,
timing: { before: 0, after: 0 },
current: 160,
elapsed: 0,
addedTime: 60,
});
const groupTimer = getGroupTimer(state)!;
expect(groupTimer.duration).toBe(160);
// elapsed keeps counting up rather than clamping at zero once time is added
expect(groupTimer.elapsed).toBe(0);
expect(groupTimer.elapsed + groupTimer.current).toBe(groupTimer.duration);
});
it('reports elapsed time while the group is in credit from added time', () => {
const state = makeState({
duration: 100,
timing: { before: 0, after: 0 },
current: 150,
elapsed: 10,
addedTime: 60,
});
expect(getGroupTimer(state)).toStrictEqual({ current: 150, elapsed: 10, duration: 160 });
});
it('goes negative when the group runs into overtime', () => {
const state = makeState({ duration: 100, timing: { before: 90, after: 0 }, current: -15, elapsed: 25 });
expect(getGroupTimer(state)?.current).toBe(-15);
});
});
-2
View File
@@ -5,8 +5,6 @@ import { RundownMetadata } from '../api-data/rundown/rundown.types.js';
import { getTimedIndexFromPlayableIndex } from '../api-data/rundown/rundown.utils.js';
import { normaliseEndTime } from './timerUtils.js';
// import * as timeCore from '../lib/time-core/timeCore.js';
/**
* Finds current event in a rolling rundown
*/
@@ -709,6 +709,18 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
RuntimeService.previousState.timer = { ...state.timer };
}
/**
* the group timer is the event timer offset by a constant, so it changes exactly when
* the event timer does and can share its throttling.
* entry changes are also considered since loading an event can move us between groups
*/
if (updateTimer || entryChanged) {
if (!deepEqual(RuntimeService.previousState.groupTimer, state.groupTimer)) {
batch.add('groupTimer', state.groupTimer);
RuntimeService.previousState.groupTimer = state.groupTimer ? { ...state.groupTimer } : null;
}
}
/**
* clock has changed by a second or more.
* or the timer updated so we ensure that the timer and clock ticks are in sync
@@ -760,17 +772,16 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
// save the restore state
if (hasImmediateChanges) {
const internalState = runtimeState.getInternalState();
restoreService
.save({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
startedAt: state.timer.startedAt,
addedTime: state.timer.addedTime,
pausedAt: internalState._timer.pausedAt,
pausedDuration: internalState._timer.pausedDuration,
pausedAt: state._timer.pausedAt,
pausedDuration: state._timer.pausedDuration,
firstStart: state.rundown.actualStart,
startEpoch: internalState._startEpoch,
startEpoch: state._startEpoch,
currentDay: state.rundown.currentDay,
})
.catch((_e) => {
+120 -39
View File
@@ -1,9 +1,18 @@
import { Day, Duration, Maybe, TimeOfDay, TimerPhase } from 'ontime-types';
import {
Day,
EntryId,
GroupTimerState,
MaybeNumber,
OntimeGroup,
Rundown,
TimeOfDay,
TimerPhase,
isOntimeEvent,
isPlayableEvent,
} from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import * as timeCore from '../lib/time-core/timeCore.js';
import { ZERO_DURATION } from '../lib/time-core/timeCore.js';
import type { InternalRuntimeState, RuntimeState } from '../stores/runtimeState.js';
import type { GroupTiming, RuntimeState } from '../stores/runtimeState.js';
/**
* handle events that span over midnight
@@ -22,46 +31,48 @@ export function hasCrossedMidnight(previous: TimeOfDay, current: TimeOfDay): boo
/**
* Calculates expected finish time of a running timer
* @param {InternalRuntimeState} state runtime state
* @param {RuntimeState} state runtime state
* @returns {number | null} new current time or null if nothing is running
*/
export function getExpectedFinish(state: InternalRuntimeState): Maybe<TimeOfDay> {
export function getExpectedFinish(state: RuntimeState): MaybeNumber {
const { startedAt, duration, addedTime } = state.timer;
if (state.eventNow === null) {
return null;
}
const { countToEnd, timeEnd } = state.eventNow;
const { pausedAt } = state._timer;
const { _now } = state;
const { clock } = state;
if (startedAt === null) {
return null;
}
const pausedTime = pausedAt !== null ? timeCore.toTimeOfDay(_now) - pausedAt : 0;
const pausedTime = pausedAt != null ? clock - pausedAt : 0;
if (countToEnd) {
return (timeEnd + addedTime + pausedTime) as TimeOfDay;
return timeEnd + addedTime + pausedTime;
}
// handle events that finish the day after
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if there is a timer
const expectedFinish = timeCore.toTimeOfDay(startedAt) + duration! + addedTime + pausedTime;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if ther eis a timer
const expectedFinish = startedAt + duration! + addedTime + pausedTime;
if (expectedFinish > dayInMs) {
return (expectedFinish - dayInMs) as TimeOfDay;
return expectedFinish - dayInMs;
}
// an event cannot finish before it started (user added too much negative time)
return Math.max(expectedFinish, timeCore.toTimeOfDay(startedAt)) as TimeOfDay;
return Math.max(expectedFinish, startedAt);
}
/**
* Calculates running countdown
* @param {InternalRuntimeState} state runtime state
* @param {RuntimeState} state runtime state
* @returns {number} current time for timer
*/
export function getCurrent(state: InternalRuntimeState): Duration {
export function getCurrent(state: RuntimeState): number {
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (state.eventNow === null || state.timer.duration === null) {
@@ -71,12 +82,12 @@ export function getCurrent(state: InternalRuntimeState): Duration {
const { startedAt, duration, addedTime } = state.timer;
const { countToEnd, timeStart, timeEnd } = state.eventNow;
const { pausedAt } = state._timer;
const { _now } = state;
const { clock } = state;
if (countToEnd) {
const isEventOverMidnight = timeStart > timeEnd;
const correctDay = isEventOverMidnight ? dayInMs : 0;
return (correctDay - timeCore.toTimeOfDay(_now) + timeEnd + addedTime) as Duration;
return correctDay - clock + timeEnd + addedTime;
}
if (startedAt === null) {
@@ -84,19 +95,19 @@ export function getCurrent(state: InternalRuntimeState): Duration {
}
if (pausedAt != null) {
return (timeCore.toTimeOfDay(startedAt) + duration + addedTime - pausedAt) as Duration;
return startedAt + duration + addedTime - pausedAt;
}
const hasPassedMidnight = timeCore.toTimeOfDay(startedAt) > timeCore.toTimeOfDay(_now);
const hasPassedMidnight = startedAt > clock;
const correctDay = hasPassedMidnight ? dayInMs : 0;
return (timeCore.toTimeOfDay(startedAt) + duration + addedTime - timeCore.toTimeOfDay(_now) - correctDay) as Duration;
return startedAt + duration + addedTime - clock - correctDay;
}
/**
* Calculates active time elapsed since the timer started.
*/
export function getElapsed(state: InternalRuntimeState): Maybe<Duration> {
const { _now } = state;
export function getElapsed(state: RuntimeState): MaybeNumber {
const { clock } = state;
const { startedAt } = state.timer;
const { pausedAt, pausedDuration } = state._timer;
@@ -104,11 +115,11 @@ export function getElapsed(state: InternalRuntimeState): Maybe<Duration> {
return null;
}
const referenceClock = pausedAt ?? timeCore.toTimeOfDay(_now);
const elapsedSinceStart = getTimeSinceStart(referenceClock, timeCore.toTimeOfDay(startedAt));
const referenceClock = pausedAt ?? clock;
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration;
return Math.max(0, activeElapsed) as Duration;
return Math.max(0, activeElapsed);
}
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
@@ -127,7 +138,7 @@ function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
* @returns {boolean}
*/
export function skippedOutOfEvent(state: RuntimeState, previousTime: number, skipLimit: number): boolean {
// we cant have skipped if we haven't started
// we cant have skipped if we havent started
if (state.timer.expectedFinish === null || state.timer.startedAt === null) {
return false;
}
@@ -151,12 +162,12 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
* Positive offset is over time / behind schedule
* Negative offset is under time / ahead of schedule
*/
export function getRuntimeOffset(state: InternalRuntimeState): { absolute: Duration; relative: Duration } {
const { eventNow, _now, _startDayOffset } = state;
export function getRuntimeOffset(state: RuntimeState): { absolute: number; relative: number } {
const { eventNow, clock, _startDayOffset } = state;
const { addedTime, current, startedAt } = state.timer;
// nothing to calculate if there are no loaded events or if we havent started
if (eventNow === null || startedAt === null || _startDayOffset === null) {
return { absolute: ZERO_DURATION, relative: ZERO_DURATION };
return { absolute: 0, relative: 0 };
}
const { countToEnd, timeStart, dayOffset } = eventNow;
@@ -171,23 +182,20 @@ export function getRuntimeOffset(state: InternalRuntimeState): { absolute: Durat
}
// difference between planned event start and actual event start (will be positive if we started behind)
const eventStartOffset =
timeCore.toTimeOfDay(startedAt) + _startDayOffset * dayInMs - (timeStart + dayOffset * dayInMs);
const eventStartOffset = startedAt + _startDayOffset * dayInMs - (timeStart + dayOffset * dayInMs);
// how long has the event been running over (is a negative number when in over timer so inverted before adding to offset)
const overtime = Math.abs(Math.min(current, 0));
// time the playback was paused, the different from now to when we paused is added to the offset TODO: brakes when crossing midnight
const pausedTime = state._timer.pausedAt === null ? 0 : timeCore.toTimeOfDay(_now) - state._timer.pausedAt;
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
// absolute offset is difference between schedule and playback time
// in case of count to end, the absolute offset is overtime and added time
const absolute = (
countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime
) as Duration;
const absolute = countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime;
// the relative offset is the same as the absolute but adjusted relative to the actual start time
const relative = (absolute + plannedStart - actualStart - _startDayOffset * dayInMs) as Duration;
const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs;
return { absolute, relative };
}
@@ -196,7 +204,7 @@ export function getRuntimeOffset(state: InternalRuntimeState): { absolute: Durat
* Checks running timer to see which phase it currently is in
* @param state
*/
export function getTimerPhase(state: InternalRuntimeState): TimerPhase {
export function getTimerPhase(state: RuntimeState): TimerPhase {
if (!isPlaybackActive(state.timer.playback)) {
return TimerPhase.None;
}
@@ -226,7 +234,7 @@ export function getTimerPhase(state: InternalRuntimeState): TimerPhase {
/**
* Finds the day offset relative to an event start
* used by the RuntimeState on first start to get correct offsets
* used byt the runtimeState on first start to get correct offsets
*/
export function findDayOffset(plannedStart: number, clock: number): Day {
const distance = clock - plannedStart;
@@ -234,3 +242,76 @@ export function findDayOffset(plannedStart: number, clock: number): Day {
if (distance < -12 * MILLIS_PER_HOUR) return 1 as Day;
return 0 as Day;
}
/**
* Splits a group's scheduled content around the loaded event.
*
* The result is stable for as long as the same event is loaded, so it is calculated
* when the group is loaded rather than on every update.
* The aggregation mirrors the group duration calculated in the rundown
* (see rundown.dao.ts): non playable entries are skipped and the gap is
* accounted for in every entry other than the first.
*/
export function getGroupTiming(
group: OntimeGroup,
entries: Rundown['entries'],
currentEventId: EntryId,
): GroupTiming | null {
const currentIndex = group.entries.indexOf(currentEventId);
if (currentIndex === -1) {
return null;
}
let before = 0;
let after = 0;
for (let i = 0; i < group.entries.length; i++) {
const entry = entries[group.entries[i]];
if (!isOntimeEvent(entry) || !isPlayableEvent(entry)) {
continue;
}
// the first entry of the group has no gap to account for,
// any other entry could be preceded by idle time
const gap = i > 0 ? entry.gap : 0;
if (i < currentIndex) {
before += gap + entry.duration;
} else if (i === currentIndex) {
// the loaded event contributes its own duration through the event timer,
// but the idle time before it has already passed
before += gap;
} else {
after += gap + entry.duration;
}
}
return { before, after };
}
/**
* Derives the shared group timer from the event timer.
*
* Keeping this relative to the event timer is what makes the group behave as if it were
* a single event containing all its children: pause, added time, overtime, roll and
* midnight rollovers are all inherited instead of being recalculated.
*/
export function getGroupTimer(state: RuntimeState): GroupTimerState | null {
const { groupNow, _groupTiming } = state;
if (groupNow === null || !groupNow.useGroupTimer || _groupTiming === null) {
return null;
}
const { current, elapsed, addedTime } = state.timer;
if (current === null) {
return null;
}
return {
current: current + _groupTiming.after,
elapsed: (elapsed ?? 0) + _groupTiming.before,
// mirrors the total time of an event, which grows with the time added to it
duration: groupNow.duration + addedTime,
};
}
@@ -1,12 +1,35 @@
import { Day, type Duration, type Instant, OffsetMode, Playback, TimeOfDay, TimerPhase } from 'ontime-types';
import { OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import { ZERO_DURATION } from '../../lib/time-core/timeCore.js';
import type { InternalRuntimeState, RuntimeState } from '../runtimeState.js';
import type { RuntimeState } from '../runtimeState.js';
export const runtimePlaceholder = {
const baseState: RuntimeState = {
clock: 0 as TimeOfDay,
eventNow: null,
eventNext: null,
eventFlag: null,
groupNow: null,
groupTimer: null,
_groupTiming: null,
rundown: {
selectedEventIndex: null,
numEvents: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
actualGroupStart: null,
currentDay: 0,
},
offset: {
absolute: 0,
relative: 0,
mode: OffsetMode.Absolute,
expectedRundownEnd: null,
expectedGroupEnd: null,
expectedFlagStart: null,
},
timer: {
addedTime: ZERO_DURATION,
addedTime: 0,
current: null,
duration: null,
elapsed: null,
@@ -16,43 +39,10 @@ export const runtimePlaceholder = {
secondaryTimer: null,
startedAt: null,
},
offset: {
absolute: ZERO_DURATION,
relative: ZERO_DURATION,
mode: OffsetMode.Absolute,
expectedGroupEnd: null,
expectedRundownEnd: null,
expectedFlagStart: null,
},
rundown: {
selectedEventIndex: null,
numEvents: 0,
plannedStart: 0 as TimeOfDay,
plannedEnd: 0 as TimeOfDay,
actualStart: null,
actualGroupStart: null,
currentDay: 0 as Day,
},
} as Readonly<InternalRuntimeState>;
const baseState: InternalRuntimeState = {
_now: 0 as Instant,
eventNow: null,
eventNext: null,
eventFlag: null,
groupNow: null,
rundown: {
...runtimePlaceholder.rundown,
},
offset: {
...runtimePlaceholder.offset,
},
timer: { ...runtimePlaceholder.timer },
_timer: {
forceFinish: false,
forceFinish: null,
pausedAt: null,
pausedDuration: 0 as Duration,
pausedDuration: 0,
secondaryTarget: null,
hasFinished: false,
},
@@ -6,10 +6,9 @@ import { rundownCache } from '../../api-data/rundown/rundown.dao.js';
import { initRundown } from '../../api-data/rundown/rundown.service.js';
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
import {
type InternalRuntimeState,
type RuntimeState,
addTime,
clearState,
getInternalState,
getState,
load,
loadGroupFlagAndEnd,
@@ -33,7 +32,7 @@ const mockEvent = {
} as PlayableEvent;
const mockState = {
_now: 666,
clock: 666,
eventNow: null,
eventNext: null,
rundown: {
@@ -54,7 +53,7 @@ const mockState = {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
@@ -109,34 +108,34 @@ describe('mutation on runtimeState', () => {
const { metadata, rundown } = rundownCache.get();
load(mockEvent, rundown, metadata);
let newState = getInternalState();
let newState = getState();
expect(newState.eventNow?.id).toBe(mockEvent.id);
expect(newState.eventNext?.id).toBe('event2');
expect(newState.timer.playback).toBe(Playback.Armed);
expect(getState().clock).not.toBe(666);
expect(newState.clock).not.toBe(666);
expect(newState.groupNow).toBeNull();
// 2. Start event
vi.setSystemTime('jan 1 00:02');
let success = start();
newState = getInternalState();
newState = getState();
expect(success).toBe(true);
expect(newState.timer).toMatchObject({
playback: Playback.Play,
});
expect(newState.rundown.actualStart).toBe(getState().clock);
expect(newState.rundown.actualStart).toBe(newState.clock);
// 3. Pause event
vi.setSystemTime('jan 1 00:03');
success = pause();
newState = getInternalState();
newState = getState();
expect(success).toBe(true);
expect(getState().clock).not.toBe(666);
expect(newState.clock).not.toBe(666);
expect(newState.timer).toMatchObject({
playback: Playback.Pause,
addedTime: 0,
});
expect(newState._timer.pausedAt).toEqual(getState().clock);
expect(newState._timer.pausedAt).toEqual(newState.clock);
success = pause();
expect(success).toBe(false);
@@ -144,7 +143,7 @@ describe('mutation on runtimeState', () => {
// 4. Restart event
vi.setSystemTime('jan 1 00:04');
success = start();
newState = getInternalState();
newState = getState();
expect(success).toBe(true);
expect(newState.timer).toMatchObject({
playback: Playback.Play,
@@ -164,7 +163,7 @@ describe('mutation on runtimeState', () => {
// 5. Stop event
vi.setSystemTime('jan 1 00:05');
success = stop();
newState = getInternalState();
newState = getState();
expect(success).toBe(true);
expect(newState.eventNow).toBe(null);
expect(newState.timer).toMatchObject({
@@ -826,7 +825,7 @@ describe('roll mode', () => {
test('pending event', () => {
const { rundown, metadata } = rundownCache.get();
const { eventId, didStart } = roll(rundown, metadata);
const state = getInternalState();
const state = getState();
expect(eventId).toBe('1');
expect(didStart).toBe(false);
@@ -945,7 +944,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null,
eventNow: rundown.entries[11],
rundown: { actualGroupStart: null },
} as InternalRuntimeState;
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata;
@@ -973,7 +972,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: rundown.entries[1],
eventNow: rundown.entries[22],
rundown: { actualGroupStart: null },
} as InternalRuntimeState;
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
@@ -1001,7 +1000,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: rundown.entries[1],
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as InternalRuntimeState;
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
@@ -1026,7 +1025,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null,
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as InternalRuntimeState;
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata;
@@ -1088,7 +1087,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null,
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as InternalRuntimeState;
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
@@ -1170,7 +1169,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null,
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as InternalRuntimeState;
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
+128 -154
View File
@@ -16,7 +16,9 @@ import {
TimeOfDay,
TimerPhase,
TimerState,
GroupTimerState,
isOntimeEvent,
runtimeStorePlaceholder,
} from 'ontime-types';
import {
calculateDuration,
@@ -38,23 +40,27 @@ import {
getCurrent,
getElapsed,
getExpectedFinish,
getGroupTimer,
getGroupTiming,
getRuntimeOffset,
getTimerPhase,
hasCrossedMidnight,
} from '../services/timerUtils.js';
import { timerConfig } from '../setup/config.js';
import { runtimePlaceholder } from './__mocks__/runtimeState.mocks.js';
type ExpectedMetadata = {
event: OntimeEvent;
accumulatedGap: Duration;
accumulatedGap: number;
isLinkedToLoaded: boolean;
} | null;
/** scheduled content of the running group, split around the loaded event */
export type GroupTiming = {
before: number;
after: number;
};
export type RuntimeState = {
/**
* @deprecated we want to phase out the 24h clock
*/
clock: TimeOfDay;
groupNow: OntimeGroup | null;
eventNow: PlayableEvent | null;
@@ -62,74 +68,42 @@ export type RuntimeState = {
eventFlag: PlayableEvent | null;
offset: Offset;
timer: TimerState;
/** derived from the timer, only refreshed on the getState() projection */
groupTimer: GroupTimerState | null;
rundown: RundownState;
// private properties of the timer calculations
};
export type InternalRuntimeState = Omit<RuntimeState, 'clock' | 'timer' | 'offset' | 'rundown'> & {
timer: {
addedTime: Duration;
current: Maybe<Duration>;
duration: Maybe<Duration>;
elapsed: Maybe<Duration>;
expectedFinish: Maybe<TimeOfDay>;
phase: TimerPhase;
playback: Playback;
secondaryTimer: Maybe<Duration>;
startedAt: Maybe<Instant>;
};
offset: {
absolute: Duration;
relative: Duration;
mode: OffsetMode;
expectedGroupEnd: Maybe<TimeOfDay>;
expectedRundownEnd: Maybe<TimeOfDay>;
expectedFlagStart: Maybe<TimeOfDay>;
};
rundown: {
selectedEventIndex: Maybe<number>;
numEvents: number;
plannedStart: Maybe<TimeOfDay>;
plannedEnd: Maybe<TimeOfDay>;
actualStart: Maybe<TimeOfDay>;
currentDay: Maybe<Day>;
actualGroupStart: Maybe<TimeOfDay>;
};
_now: Instant;
_timer: {
forceFinish: boolean; // whether we should declare an event as finished, will contain the finish time
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
pausedAt: Maybe<TimeOfDay>;
pausedDuration: Duration;
pausedDuration: number;
secondaryTarget: Maybe<TimeOfDay>;
hasFinished: boolean;
};
_rundown: {
totalDelay: number; // this value comes from rundown service
};
_startDayOffset: Maybe<Day>;
_startEpoch: Maybe<Instant>;
_group: ExpectedMetadata;
_groupTiming: GroupTiming | null;
_flag: ExpectedMetadata;
_end: ExpectedMetadata;
_startEpoch: Maybe<Instant>;
_startDayOffset: Maybe<Day>;
};
const runtimeState: InternalRuntimeState = {
_now: timeCore.now(),
const runtimeState: RuntimeState = {
clock: timeCore.timeOfDayNow(),
groupNow: null,
eventNow: null,
eventNext: null,
eventFlag: null,
offset: { ...runtimePlaceholder.offset },
timer: {
...runtimePlaceholder.timer,
},
rundown: {
...runtimePlaceholder.rundown,
},
offset: { ...runtimeStorePlaceholder.offset },
timer: { ...runtimeStorePlaceholder.timer },
groupTimer: null,
rundown: { ...runtimeStorePlaceholder.rundown },
_timer: {
forceFinish: false,
forceFinish: null,
pausedAt: null,
pausedDuration: 0 as Duration,
pausedDuration: 0,
secondaryTarget: null,
hasFinished: false,
},
@@ -137,38 +111,28 @@ const runtimeState: InternalRuntimeState = {
totalDelay: 0,
},
_group: null,
_groupTiming: null,
_flag: null,
_end: null,
_startEpoch: null,
_startDayOffset: null,
};
export function getInternalState(): Readonly<InternalRuntimeState> {
return runtimeState;
}
export function getState(): Readonly<RuntimeState> {
// create a shallow copy of the state
return {
...runtimeState,
clock: timeCore.toTimeOfDay(runtimeState._now),
eventNow: runtimeState.eventNow ? { ...runtimeState.eventNow } : null,
eventNext: runtimeState.eventNext ? { ...runtimeState.eventNext } : null,
eventFlag: runtimeState.eventFlag ? { ...runtimeState.eventFlag } : null,
groupNow: runtimeState.groupNow ? { ...runtimeState.groupNow } : null,
offset: { ...runtimeState.offset },
rundown: { ...runtimeState.rundown },
timer: {
addedTime: runtimeState.timer.addedTime,
current: runtimeState.timer.current,
duration: runtimeState.timer.duration,
elapsed: runtimeState.timer.elapsed,
expectedFinish: runtimeState.timer.expectedFinish,
phase: runtimeState.timer.phase,
playback: runtimeState.timer.playback,
secondaryTimer: runtimeState.timer.secondaryTimer,
startedAt: runtimeState.timer.startedAt ? timeCore.toTimeOfDay(runtimeState.timer.startedAt) : null,
},
timer: { ...runtimeState.timer },
// derived here so it can never drift from the timer values it is built on
groupTimer: getGroupTimer(runtimeState),
_timer: { ...runtimeState._timer },
_rundown: { ...runtimeState._rundown },
};
}
@@ -179,18 +143,22 @@ export function clearEventData() {
runtimeState.eventNow = null;
runtimeState.eventNext = null;
runtimeState.offset = { ...runtimePlaceholder.offset };
runtimeState.offset.absolute = 0;
runtimeState.offset.relative = 0;
runtimeState.offset.expectedFlagStart = null;
runtimeState.offset.expectedGroupEnd = null;
runtimeState.offset.expectedRundownEnd = null;
runtimeState.rundown.selectedEventIndex = null;
runtimeState.timer.playback = Playback.Stop;
runtimeState._now = timeCore.now();
runtimeState.timer = { ...runtimePlaceholder.timer };
runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
runtimeState._timer.forceFinish = false;
runtimeState._timer.forceFinish = null;
runtimeState._timer.pausedAt = null;
runtimeState._timer.pausedDuration = 0 as Duration;
runtimeState._timer.pausedDuration = 0;
runtimeState._timer.secondaryTarget = null;
runtimeState._timer.hasFinished = false;
}
@@ -204,22 +172,27 @@ export function clearState() {
runtimeState.groupNow = null;
runtimeState._group = null;
runtimeState._groupTiming = null;
runtimeState.rundown.actualStart = null;
runtimeState.rundown.selectedEventIndex = null;
runtimeState.offset = { ...runtimePlaceholder.offset };
runtimeState.offset.absolute = 0;
runtimeState.offset.relative = 0;
runtimeState.offset.expectedRundownEnd = null;
runtimeState.offset.expectedGroupEnd = null;
runtimeState.offset.expectedFlagStart = null;
runtimeState._end = null;
runtimeState.timer.playback = Playback.Stop;
runtimeState._now = timeCore.now();
runtimeState.timer = { ...runtimePlaceholder.timer };
runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
runtimeState._timer.forceFinish = false;
runtimeState._timer.forceFinish = null;
runtimeState._timer.pausedAt = null;
runtimeState._timer.pausedDuration = 0 as Duration;
runtimeState._timer.pausedDuration = 0;
runtimeState._timer.secondaryTarget = null;
runtimeState._timer.hasFinished = false;
@@ -261,9 +234,9 @@ export function updateRundownData(rundownData: {
runtimeState._rundown.totalDelay = rundownData.totalDelay;
runtimeState.rundown.numEvents = rundownData.numEvents;
runtimeState.rundown.plannedStart = rundownData.firstStart as TimeOfDay;
runtimeState.rundown.plannedStart = rundownData.firstStart;
runtimeState.rundown.plannedEnd =
rundownData.firstStart === null ? null : ((rundownData.firstStart + rundownData.totalDuration) as TimeOfDay);
rundownData.firstStart === null ? null : rundownData.firstStart + rundownData.totalDuration;
if (isPlaybackActive(runtimeState.timer.playback)) getExpectedTimes();
}
@@ -297,7 +270,7 @@ export function load(
// update state
runtimeState.timer.playback = Playback.Armed;
runtimeState.timer.duration = event.duration as Duration;
runtimeState.timer.duration = calculateDuration(event.timeStart, event.timeEnd);
runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.rundown.numEvents = metadata.timedEventOrder.length;
@@ -311,13 +284,13 @@ export function load(
(firstStart === null || typeof firstStart === 'number') &&
(startEpoch === null || typeof startEpoch === 'number')
) {
runtimeState.rundown.actualStart = firstStart as TimeOfDay;
runtimeState.rundown.actualStart = firstStart;
runtimeState._startEpoch = startEpoch;
if (firstStart !== null && runtimeState.rundown.plannedStart !== null) {
runtimeState._startDayOffset = findDayOffset(runtimeState.rundown.plannedStart, firstStart);
}
if (currentDay !== undefined) {
runtimeState.rundown.currentDay = currentDay as Day;
runtimeState.rundown.currentDay = currentDay;
}
const { absolute, relative } = getRuntimeOffset(runtimeState);
runtimeState.offset.absolute = absolute;
@@ -409,16 +382,16 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
// handle edge cases with roll
if (runtimeState.timer.playback === Playback.Roll) {
const offsetClock = timeCore.toTimeOfDay(runtimeState._now) - runtimeState.offset.absolute;
const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
// if waiting to roll, we update the targets and potentially start the timer
if (runtimeState._timer.secondaryTarget !== null) {
if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) {
// if the event is now, we queue a start
runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart as TimeOfDay;
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget - offsetClock) as Duration;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
} else {
runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart as TimeOfDay,
runtimeState.eventNow.timeStart,
offsetClock,
) as TimeOfDay;
}
@@ -435,7 +408,7 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
runtimeState.timer.startedAt = null;
runtimeState._timer.hasFinished = false;
runtimeState.timer.addedTime = 0 as Duration;
runtimeState.timer.addedTime = 0;
runtimeState._timer.pausedAt = null;
// this could be looked after by the timer
@@ -458,7 +431,7 @@ export function updateAll(rundown: Rundown, metadata: RundownMetadata) {
loadGroupFlagAndEnd(rundown, metadata, eventNowIndex);
}
export function start(state: InternalRuntimeState = runtimeState): boolean {
export function start(state: RuntimeState = runtimeState): boolean {
if (state.eventNow === null) {
return false;
}
@@ -466,19 +439,22 @@ export function start(state: InternalRuntimeState = runtimeState): boolean {
return false;
}
state._now = timeCore.now();
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
state.clock = now;
state.timer.secondaryTimer = null;
// add paused time if it exists
if (state._timer.pausedAt) {
const timeToAdd = (timeCore.toTimeOfDay(state._now) - state._timer.pausedAt) as Duration;
state.timer.addedTime = timeCore.combineValues(state.timer.addedTime, timeToAdd);
state._timer.pausedDuration = timeCore.combineValues(state._timer.pausedDuration, timeToAdd);
const timeToAdd = state.clock - state._timer.pausedAt;
state.timer.addedTime += timeToAdd;
state._timer.pausedDuration += timeToAdd;
state._timer.pausedAt = null;
}
if (state.timer.startedAt === null) {
state.timer.startedAt = state._now;
state.timer.startedAt = state.clock;
}
state.timer.playback = Playback.Play;
@@ -486,15 +462,14 @@ export function start(state: InternalRuntimeState = runtimeState): boolean {
state.timer.elapsed = getElapsed(state);
if (state.rundown.actualStart === null) {
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, timeCore.toTimeOfDay(state._now)) +
state.eventNow.dayOffset) as Day;
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
state.rundown.currentDay = state._startDayOffset;
state._startEpoch = state._now;
state.rundown.actualStart = timeCore.toTimeOfDay(state._now);
state._startEpoch = epoch;
state.rundown.actualStart = state.clock;
}
if (state.groupNow !== null && state.rundown.actualGroupStart === null) {
state.rundown.actualGroupStart = timeCore.toTimeOfDay(state._now);
state.rundown.actualGroupStart = state.clock;
}
// update timer phase
@@ -517,18 +492,18 @@ export function start(state: InternalRuntimeState = runtimeState): boolean {
return true;
}
export function pause(state: InternalRuntimeState = runtimeState): boolean {
export function pause(state: RuntimeState = runtimeState): boolean {
if (state.timer.playback !== Playback.Play) {
return false;
}
state.timer.playback = Playback.Pause;
state._now = timeCore.now();
state._timer.pausedAt = timeCore.toTimeOfDay(state._now);
state.clock = timeCore.timeOfDayNow();
state._timer.pausedAt = state.clock;
return true;
}
export function stop(state: InternalRuntimeState = runtimeState): boolean {
export function stop(state: RuntimeState = runtimeState): boolean {
if (state.timer.playback === Playback.Stop) {
return false;
}
@@ -558,7 +533,7 @@ export function addTime(amount: number) {
if (willGoNegative && !runtimeState._timer.hasFinished) {
// set finished time so side effects are triggered
runtimeState._timer.forceFinish = true;
runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
} else {
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
if (willGoPositive) {
@@ -567,8 +542,8 @@ export function addTime(amount: number) {
}
// we can update the state after handling the side effects
runtimeState.timer.addedTime = timeCore.combineValues(runtimeState.timer.addedTime, amount as Duration);
runtimeState.timer.current = timeCore.combineValues(runtimeState.timer.current, amount as Duration);
runtimeState.timer.addedTime += amount;
runtimeState.timer.current += amount;
runtimeState.timer.elapsed = getElapsed(runtimeState);
// update runtime delays: over - under
@@ -588,8 +563,10 @@ export type UpdateResult = {
export function update(): UpdateResult {
// 0. there are some things we always do
const previousClock = timeCore.toTimeOfDay(runtimeState._now);
runtimeState._now = timeCore.now(); // we update the clock on every update call
const previousClock = runtimeState.clock;
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now; // we update the clock on every update call
// 1. is playback idle?
if (!isPlaybackActive(runtimeState.timer.playback)) {
@@ -598,13 +575,13 @@ export function update(): UpdateResult {
// calculate currentDay from epoch (days elapsed since playback was started)
if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now);
runtimeState.rundown.currentDay = timeCore.combineValues(runtimeState._startDayOffset, daysSinceStart);
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
}
// 2. are we waiting to roll?
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, timeCore.toTimeOfDay(runtimeState._now));
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now);
return updateIfWaitingToRoll(clockHasCrossedMidnight);
}
@@ -658,24 +635,22 @@ export function update(): UpdateResult {
}
// account for offset
const offsetClock = timeCore.toTimeOfDay(
timeCore.addDuration(runtimeState._now, runtimeState.offset.absolute as Duration),
);
const offsetClock = runtimeState.clock + runtimeState.offset.absolute;
runtimeState.timer.phase = TimerPhase.Pending;
if (hasCrossedMidnight) {
// if we crossed midnight, we need to update the target
// this is the same logic from the roll function
runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart as TimeOfDay,
runtimeState.eventNow.timeStart,
offsetClock,
) as TimeOfDay;
}
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget! - offsetClock) as Duration;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget! - offsetClock;
return {
hasTimerFinished: false,
hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer! <= 0,
hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0,
};
}
}
@@ -692,7 +667,9 @@ export function roll(
}
// we will need to do some calculations, update the time first
runtimeState._now = timeCore.now();
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now;
// 2. if there is an event armed, we use it
if (runtimeState.timer.playback === Playback.Armed || runtimeState.timer.phase === TimerPhase.Pending) {
@@ -704,9 +681,7 @@ export function roll(
}
if (offset) {
runtimeState.offset = {
...offset,
} as InternalRuntimeState['offset'];
runtimeState.offset = { ...offset };
}
runtimeState.timer.playback = Playback.Roll;
@@ -715,16 +690,15 @@ export function roll(
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
? runtimeState.eventNow.timeEnd + dayInMs
: runtimeState.eventNow.timeEnd;
runtimeState.timer.expectedFinish = normalisedEndTime as TimeOfDay;
runtimeState.timer.expectedFinish = normalisedEndTime;
// account for offset
// const offsetClock = timeCore.addDuration(runtimeState._now, -runtimeState.offset.absolute as Duration);
const offsetClock = timeCore.toTimeOfDay(runtimeState._now) - runtimeState.offset.absolute;
const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
// state catch up
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime);
runtimeState.timer.current = runtimeState.timer.duration;
runtimeState.timer.elapsed = 0 as Duration;
runtimeState.timer.elapsed = 0;
// check if the event is ready to start or if needs to be pending
const isNow = checkIsNow(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd, offsetClock);
@@ -733,8 +707,8 @@ export function roll(
* If we are starting an event in roll mode
* we backtrace all the start times to the supposed start time of the event
*/
const plannedStart = runtimeState.eventNow.timeStart as TimeOfDay;
runtimeState.timer.startedAt = timeCore.toInstant(plannedStart, runtimeState._now);
const plannedStart = runtimeState.eventNow.timeStart;
runtimeState.timer.startedAt = plannedStart;
// reset the secondary timer to cancel any countdowns
runtimeState.timer.secondaryTimer = null;
@@ -752,20 +726,18 @@ export function roll(
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
runtimeState.eventNow.dayOffset) as Day;
// backdate _startEpoch to when the event conceptually started
const timeElapsed = timeCore.elapsedTime(timeCore.toTimeOfDay(runtimeState._now), plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(runtimeState._now, -timeElapsed as Duration);
const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
// calculate currentDay from the backdated epoch
runtimeState.rundown.currentDay = timeCore.combineValues(
runtimeState._startDayOffset,
timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now),
);
runtimeState.rundown.currentDay =
runtimeState._startDayOffset + timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
}
} else {
runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart as TimeOfDay,
runtimeState.eventNow.timeStart,
offsetClock,
) as TimeOfDay;
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget - offsetClock) as Duration;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
runtimeState.timer.phase = TimerPhase.Pending;
}
@@ -782,9 +754,9 @@ export function roll(
// account for offset but we only keep it if passed to us
if (offset) {
runtimeState.offset = { ...offset } as InternalRuntimeState['offset'];
runtimeState.offset = { ...offset };
}
const offsetClock = timeCore.toTimeOfDay(runtimeState._now) - runtimeState.offset.absolute;
const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
const { index, isPending } = loadRoll(rundown, metadata, offsetClock);
@@ -811,7 +783,7 @@ export function roll(
runtimeState.timer.phase = TimerPhase.Pending;
// we need to normalise start time in case it is the day after
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock) as TimeOfDay;
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget - offsetClock) as Duration;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
// preload timer properties
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
@@ -834,18 +806,18 @@ export function roll(
* when we start in roll mode
* we need to backtrace all times to the supposed start time of the event
*/
const plannedStart = runtimeState.eventNow.timeStart as TimeOfDay;
const plannedStart = runtimeState.eventNow.timeStart;
const endTime =
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
? runtimeState.eventNow.timeEnd + dayInMs
: runtimeState.eventNow.timeEnd;
runtimeState.timer.startedAt = timeCore.toInstant(plannedStart, runtimeState._now);
runtimeState.timer.expectedFinish = endTime as TimeOfDay;
runtimeState.timer.startedAt = plannedStart;
runtimeState.timer.expectedFinish = endTime;
// state catch up
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, endTime);
runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.timer.elapsed = 0 as Duration;
runtimeState.timer.elapsed = 0;
// update runtime
runtimeState.rundown.actualStart = plannedStart;
@@ -858,11 +830,11 @@ export function roll(
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
runtimeState.eventNow.dayOffset) as Day;
// backdate _startEpoch to when the event conceptually started
const timeElapsed = timeCore.elapsedTime(timeCore.toTimeOfDay(runtimeState._now), plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(runtimeState._now, -timeElapsed as Duration);
const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
// calculate currentDay from the backdated epoch
runtimeState.rundown.currentDay = (runtimeState._startDayOffset +
timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now)) as Day;
timeCore.daysSinceStart(runtimeState._startEpoch, epoch)) as Day;
return { eventId: runtimeState.eventNow.id, didStart: true };
}
@@ -897,11 +869,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedGroupEnd = getExpectedEnd(
lastEvent,
lastEventExpectedStart,
state.rundown.currentDay!,
) as TimeOfDay;
state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, lastEventExpectedStart, state.rundown.currentDay!);
}
}
@@ -918,7 +886,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedFlagStart = expectedStart as TimeOfDay;
state.offset.expectedFlagStart = expectedStart;
}
}
@@ -933,7 +901,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!) as TimeOfDay;
state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!);
}
}
@@ -946,6 +914,7 @@ export function loadGroupFlagAndEnd(
const previousGroup = state.groupNow?.id;
state.groupNow = null;
state._group = null;
state._groupTiming = null;
state.eventFlag = null;
state._flag = null;
state._end = null;
@@ -969,6 +938,11 @@ export function loadGroupFlagAndEnd(
state.rundown.actualGroupStart = null;
}
// the split is stable while the same event is loaded, so we only calculate it here
if (state.groupNow !== null) {
state._groupTiming = getGroupTiming(state.groupNow, rundown.entries, state.eventNow.id);
}
// if we don't have a any flags in the rundown then no need to look for it
let foundFlag = !flagsPresent;
// if we don't have a last event for the group there is no need to find its end time
@@ -1006,13 +980,13 @@ export function loadGroupFlagAndEnd(
if (!foundFlag && metadata.flags.includes(entry.id)) {
foundFlag = true;
state.eventFlag = entry as PlayableEvent; // we know it is playable as it is coming from the playableEventOrder list
state._flag = { event: entry, isLinkedToLoaded, accumulatedGap: accumulatedGap as Duration };
state._flag = { event: entry, isLinkedToLoaded, accumulatedGap };
}
}
if (!foundGroupEnd && entry.id === lastEventInGroup?.id) {
foundGroupEnd = true;
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap: accumulatedGap as Duration };
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
}
}
}
@@ -1020,7 +994,7 @@ export function loadGroupFlagAndEnd(
const lastID = playableEventOrder.at(-1);
const lastEvent = lastID ? (entries[lastID] as OntimeEvent) : null;
if (lastEvent) {
state._end = { event: lastEvent, isLinkedToLoaded, accumulatedGap: accumulatedGap as Duration };
state._end = { event: lastEvent, isLinkedToLoaded, accumulatedGap };
}
}
@@ -46,6 +46,8 @@ export type OntimeGroup = OntimeBaseEvent & {
targetDuration: MaybeNumber;
colour: string;
custom: EntryCustomFields;
/** whether views should display a shared timer for the group instead of the event timer */
useGroupTimer: boolean;
// !==== RUNTIME METADATA ====! //
revision: number;
timeStart: MaybeNumber; // calculated at runtime
@@ -17,6 +17,7 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
secondaryTimer: null, // change on every update
startedAt: null, // change can only be initiated by user
},
groupTimer: null, // derived from the timer, null unless the running group opted in
message: {
timer: {
text: '',
@@ -3,12 +3,14 @@ import type { SimpleTimerState } from './AuxTimer.type.js';
import type { MessageState } from './MessageControl.type.js';
import type { Offset } from './Offset.type.js';
import type { RundownState } from './RundownState.type.js';
import type { TimerState } from './TimerState.type.js';
import type { GroupTimerState, TimerState } from './TimerState.type.js';
export type RuntimeStore = {
// timer data
clock: number;
timer: TimerState;
/** shared timer for the running group, null unless the group opted in */
groupTimer: GroupTimerState | null;
// messages service
message: MessageState;
@@ -11,6 +11,22 @@ export enum TimerPhase {
Pending = 'pending',
}
/**
* Timer for the running group, present when the group opted into a shared timer.
*
* The group is treated as a single event containing all its children: the values are
* derived from the running event timer plus the content scheduled around it, so pause,
* added time, overtime and roll are inherited from the event timer rather than recalculated.
*/
export type GroupTimerState = {
/** Time remaining in the group */
current: number;
/** Time elapsed since the group started */
elapsed: number;
/** Total time in the group, includes time added to the running event */
duration: number;
};
/**
* Gathers the current running timer state
*/
+1 -1
View File
@@ -119,7 +119,7 @@ export type { Offset } from './definitions/runtime/Offset.type.js';
export { OffsetMode } from './definitions/runtime/Offset.type.js';
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js';
export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
export { type GroupTimerState, type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
// ---> Extra Timer
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
@@ -54,6 +54,7 @@ export const group: Omit<OntimeGroup, 'id'> = {
targetDuration: null,
colour: '',
custom: {},
useGroupTimer: false,
// !==== RUNTIME METADATA ====! //
revision: 0, // calculated at runtime
timeStart: null, // calculated at runtime
@@ -69,6 +69,7 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
targetDuration: patch.targetDuration ?? null,
colour: makeString(patch.colour, ''),
custom: patch.custom ?? {},
useGroupTimer: typeof patch.useGroupTimer === 'boolean' ? patch.useGroupTimer : false,
revision: 0,
timeStart: null,
timeEnd: null,
@@ -1,4 +1,4 @@
import type { Duration, OntimeEvent } from 'ontime-types';
import type { OntimeEvent } from 'ontime-types';
import { TimeStrategy } from 'ontime-types';
import { dayInMs } from '../date-utils/conversionUtils.js';
@@ -103,17 +103,16 @@ export function validateTimes(
/**
* @description calculates event duration considering midnight
* @deprecated - duration is the more useful value from the event data, prefer using that directly
* @param {number} timeStart
* @param {number} timeEnd
* @returns {number}
*/
export function calculateDuration(timeStart: number, timeEnd: number): Duration {
export function calculateDuration(timeStart: number, timeEnd: number): number {
// Durations must be positive
if (timeEnd < timeStart) {
return (timeEnd + dayInMs - timeStart) as Duration;
return timeEnd + dayInMs - timeStart;
}
return (timeEnd - timeStart) as Duration;
return timeEnd - timeStart;
}
export function calculateEnd(timeStart: number, duration: number): number {