mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-05 23:43:57 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fad99f505 | |||
| 636b2e7c3e |
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user