Over under (#771)

* feat: schedule offset
This commit is contained in:
Carlos Valente
2024-02-16 21:04:58 +01:00
committed by GitHub
parent 436a34aaee
commit 1a420a1ddd
34 changed files with 437 additions and 157 deletions
@@ -17,7 +17,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
if (typeof delayValue === 'number') {
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue, true)}>
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
@@ -27,7 +27,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue, true)}>
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
+17 -4
View File
@@ -165,10 +165,23 @@ export const setClientName = (newName: string) => socketSendJson('set-client-nam
export const useRuntimeOverview = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
clock: state.clock,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
plannedStart: state.runtime.plannedStart,
actualStart: state.runtime.actualStart,
plannedEnd: state.runtime.plannedEnd,
expectedEnd: state.runtime.expectedEnd,
});
return useRuntimeStore(featureSelector);
};
export const useRuntimePlaybackOverview = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
clock: state.clock,
numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset,
});
return useRuntimeStore(featureSelector);
+6 -1
View File
@@ -37,8 +37,13 @@ export const runtimeStorePlaceholder: RuntimeStore = {
},
},
runtime: {
numEvents: 0,
selectedEventIndex: null,
numEvents: 0,
offset: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
expectedEnd: null,
},
eventNow: null,
eventNext: null,
@@ -260,17 +260,17 @@ describe('test forgivingStringToMillis()', () => {
describe('millisToDelayString()', () => {
it('returns null for null values', () => {
expect(millisToDelayString(null)).toBeNull();
expect(millisToDelayString(null)).toBe('');
});
it('returns null 0', () => {
expect(millisToDelayString(0)).toBeNull();
expect(millisToDelayString(0)).toBe('');
});
describe('converts values in seconds', () => {
it('shows a simple string with value in seconds', () => {
expect(millisToDelayString(10000, true)).toBe('+10 sec');
expect(millisToDelayString(10000)).toBe('+10 sec');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-10000, true)).toBe('-10 sec');
expect(millisToDelayString(-10000)).toBe('-10 sec');
});
const underAMinute = [1, 500, 1000, 6000, 55000, 59999];
@@ -279,37 +279,36 @@ describe('millisToDelayString()', () => {
expect(millisToDelayString(value)?.endsWith('sec')).toBe(true);
});
});
expect(millisToDelayString(null)).toBeNull();
});
describe('converts values in minutes', () => {
it('shows a simple string with value in minutes', () => {
expect(millisToDelayString(720000, true)).toBe('+12 min');
expect(millisToDelayString(720000)).toBe('+12 min');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-720000, true)).toBe('-12 min');
expect(millisToDelayString(-720000)).toBe('-12 min');
});
it('shows a simple string with value in minutes and seconds', () => {
expect(millisToDelayString(630000, true)).toBe('+00:10:30');
expect(millisToDelayString(630000)).toBe('+00:10:30');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-630000, true)).toBe('-00:10:30');
expect(millisToDelayString(-630000)).toBe('-00:10:30');
});
const underAnHour = [60000, 360000, 720000];
underAnHour.forEach((value) => {
it(`handles ${value}`, () => {
expect(millisToDelayString(value, true)?.endsWith('min')).toBe(true);
expect(millisToDelayString(value)?.endsWith('min')).toBe(true);
});
});
});
describe('converts values with full time string', () => {
it('positive added time', () => {
expect(millisToDelayString(45015000, true)).toBe('+12:30:15');
expect(millisToDelayString(45015000)).toBe('+12:30:15');
});
it('negative added time', () => {
expect(millisToDelayString(-45015000, true)).toBe('-12:30:15');
expect(millisToDelayString(-45015000)).toBe('-12:30:15');
});
});
});
@@ -16,13 +16,13 @@ describe('nowInMillis()', () => {
describe('formatTime()', () => {
it('parses 24h strings', () => {
const ms = 13 * 60 * 60 * 1000;
const time = formatTime(ms, {format12: "hh:mm:ss", format24: "HH:mm:ss" }, (_format12, format24) => format24);
const time = formatTime(ms, { format12: 'hh:mm:ss', format24: 'HH:mm:ss' }, (_format12, format24) => format24);
expect(time).toStrictEqual('13:00:00');
});
it('parses same string in 12h strings', () => {
const ms = 13 * 60 * 60 * 1000;
const time = formatTime(ms, {format12: "hh:mm:ss a", format24: "HH:mm:ss" }, (format12, _format24) => format12);
const time = formatTime(ms, { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' }, (format12, _format24) => format12);
expect(time).toStrictEqual('01:00:00 PM');
});
@@ -34,7 +34,7 @@ describe('formatTime()', () => {
it('handles negative times', () => {
const ms = 1 * 60 * 60 * 1000;
const time = formatTime(-ms, {format12: "hh:mm a", format24: "HH:mm" }, (_format12, format24) => format24);
const time = formatTime(-ms, { format12: 'hh:mm a', format24: 'HH:mm' }, (_format12, format24) => format24);
expect(time).toStrictEqual('-01:00');
});
});
+11 -7
View File
@@ -1,3 +1,4 @@
import { MaybeNumber } from 'ontime-types';
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
/**
@@ -55,7 +56,9 @@ function checkMatchers(value: string) {
const secondsMatchValue = secondsMatch ? parse(secondsMatch[1]) : 0;
if (hoursMatchValue > 0 || minutesMatchValue > 0 || secondsMatchValue > 0) {
return hoursMatchValue * MILLIS_PER_HOUR + minutesMatchValue * MILLIS_PER_MINUTE + secondsMatchValue * MILLIS_PER_SECOND;
return (
hoursMatchValue * MILLIS_PER_HOUR + minutesMatchValue * MILLIS_PER_MINUTE + secondsMatchValue * MILLIS_PER_SECOND
);
}
return { hoursMatchValue };
}
@@ -155,21 +158,22 @@ export const forgivingStringToMillis = (value: string): number => {
return millis;
};
export function millisToDelayString(millis: number | null, small = false): undefined | string | null {
export function millisToDelayString(millis: MaybeNumber, format: 'compact' | 'expanded' = 'compact'): string {
if (millis == null || millis === 0) {
return null;
return '';
}
const isNegative = millis < 0;
const absMillis = Math.abs(millis);
const delayed = small ? '+' : 'delayed by ';
const ahead = small ? '-' : 'ahead by ';
const isCompact = format === 'compact';
const delayed = isCompact ? '+' : 'delayed by ';
const ahead = isCompact ? '-' : 'ahead by ';
if (absMillis < MILLIS_PER_MINUTE) {
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 's')} sec`;
} else if (absMillis < MILLIS_PER_HOUR && absMillis % MILLIS_PER_MINUTE === 0) {
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'm')} min`;
} else {
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'HH:mm:ss')}`;
}
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'HH:mm:ss')}`;
}
@@ -29,3 +29,7 @@ export const getAccessibleColour = (bgColour?: string): ColourCombination => {
* @param classNames - css modules objects
*/
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(' ');
export const enDash = '';
export const timerPlaceholder = '––:––:––';
@@ -5,6 +5,7 @@ import { IoSunny } from '@react-icons/all-files/io5/IoSunny';
import { IoSunnyOutline } from '@react-icons/all-files/io5/IoSunnyOutline';
import { setMessage, useMessageControl } from '../../../common/hooks/useSocket';
import { enDash } from '../../../common/utils/styleUtils';
import InputRow from './InputRow';
@@ -64,7 +65,7 @@ export default function MessageControl() {
</div>
<InputRow
label='External Message'
placeholder='-'
placeholder={enDash}
readonly
text={message.external.text || ''}
visible={message.external.visible || false}
@@ -1,7 +1,7 @@
import { MaybeNumber } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { cx } from '../../../../common/utils/styleUtils';
import { cx, timerPlaceholder } from '../../../../common/utils/styleUtils';
import style from './TimerDisplay.module.scss';
@@ -11,16 +11,17 @@ interface TimerDisplayProps {
/**
* Displays time in ms in formatted timetag
* Typically used in production views
*/
export default function TimerDisplay(props: TimerDisplayProps) {
const { time } = props;
if (time == null) {
return <div className={style.timer}>-- : -- : --</div>;
return <div className={style.timer}>{timerPlaceholder}</div>;
}
const isNegative = time < 0;
const display = millisToString(Math.abs(time), { fallback: '-- : -- : --' });
const display = millisToString(Math.abs(time), { fallback: timerPlaceholder });
const classes = cx([style.timer, isNegative ? style.finished : null]);
return <div className={classes}>{display}</div>;
@@ -1,12 +1,6 @@
$table-font-size: calc(1rem - 2px);
$table-header-font-size: calc(1rem - 3px);
@mixin ellipsis-overflow() {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cuesheetContainer {
grid-area: table;
display: flex;
@@ -10,7 +10,7 @@ interface DelayRowProps {
function DelayRow(props: DelayRowProps) {
const { duration } = props;
const delayTime = millisToDelayString(duration);
const delayTime = millisToDelayString(duration, 'expanded');
return (
<tr className={style.delayRow}>
@@ -8,7 +8,7 @@ import { Playback, ProjectData } from 'ontime-types';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import useFullscreen from '../../../common/hooks/useFullscreen';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { cx } from '../../../common/utils/styleUtils';
import { cx, enDash } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { useCuesheetSettings } from '../store/CuesheetSettings';
@@ -42,15 +42,15 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
const selected = !featureData.numEvents
? 'No events'
: `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : '-'}/${
featureData.numEvents ? featureData.numEvents : '-'
: `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : enDash}/${
featureData.numEvents ? featureData.numEvents : enDash
}`;
return (
<div className={style.header}>
<div className={style.event}>
<div className={style.title}>{project?.title || '-'}</div>
<div className={style.eventNow}>{featureData?.titleNow || '-'}</div>
<div className={style.title}>{project?.title || enDash}</div>
<div className={style.eventNow}>{featureData?.titleNow || enDash}</div>
</div>
<div className={style.playback}>
<div className={style.playbackLabel}>{selected}</div>
@@ -3,6 +3,8 @@ import { UseFormRegister } from 'react-hook-form';
import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
import { IoEyeOutline } from '@react-icons/all-files/io5/IoEyeOutline';
import { enDash } from '../../../common/utils/styleUtils';
interface FormInput {
[key: string]: string;
}
@@ -21,7 +23,7 @@ export default function ModalPinInput({ register, formName, isDisabled }: ModalP
type={isVisible ? 'text' : 'password'}
maxLength={4}
{...register(formName)}
placeholder='-'
placeholder={enDash}
isDisabled={isDisabled}
/>
<InputRightElement>
@@ -2,54 +2,26 @@
grid-area: overview;
display: flex;
align-items: center;
justify-content: start;
justify-content: space-between;
font-size: $inner-section-text-size;
gap: 2rem;
padding-left: 1rem;
padding-right: 0.5rem;
}
.titles {
flex: 1;
padding: 0 1rem;
}
.title {
font-size: 1.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@include ellipsis-overflow;
}
.description {
font-size: 1rem;
color: $label-gray;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@include ellipsis-overflow;
}
.inline {
display: flex;
align-items: center;
gap: 0.25rem;
.ahead {
color: $green-500;
}
@mixin indicator($bg-color) {
&::before {
content: '';
background-color: $bg-color;
display: inline-flex;
height: 0.75em;
width: 0.75em;
vertical-align: middle;
margin-right: 0.25rem;
}
}
.start {
@include indicator($green-500);
}
.end {
@include indicator($red-500);
.behind {
color: $ontime-delay-text;
}
+42 -37
View File
@@ -1,34 +1,39 @@
import { Tooltip } from '@chakra-ui/react';
import { MaybeNumber } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import PlaybackIcon from '../../common/components/playback-icon/PlaybackIcon';
import { useRuntimeOverview } from '../../common/hooks/useSocket';
import { useRuntimeOverview, useRuntimePlaybackOverview } from '../../common/hooks/useSocket';
import useProjectData from '../../common/hooks-query/useProjectData';
import { formatTime } from '../../common/utils/time';
import { enDash, timerPlaceholder } from '../../common/utils/styleUtils';
import styles from './Overview.module.scss';
import { TimeColumn, TimeRow } from './composite/TimeLayout';
import style from './Overview.module.scss';
/**
* Encapsulates the logic for formatting time in overview
* @param time
* @returns
*/
function formattedTime(time: MaybeNumber) {
return millisToString(time, { fallback: timerPlaceholder });
}
export default function Overview() {
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview();
return (
<div className={styles.overview}>
<div className={style.overview}>
<ErrorBoundary>
<TitlesOverview />
<div className={styles.clocks}>
<Tooltip label='Planned start'>
<div className={styles.start}>Planned start</div>
</Tooltip>
<Tooltip label='Actual start'>
<div className={styles.start}>Actual start</div>
</Tooltip>
<div className={style.column}>
<TimeRow label='Planned start' value={formattedTime(plannedStart)} className={style.start} />
<TimeRow label='Actual start' value={formattedTime(actualStart)} className={style.start} />
</div>
<RuntimeOverview />
<div className={styles.clocks}>
<Tooltip label='Planned end'>
<div className={styles.end}>Planned end</div>
</Tooltip>
<Tooltip label='Expected end'>
<div className={styles.end}>Expected end</div>
</Tooltip>
<div className={style.column}>
<TimeRow label='Planned end' value={formattedTime(plannedEnd)} className={style.end} />
<TimeRow label='Expected end' value={formattedTime(expectedEnd)} className={style.end} />
</div>
</ErrorBoundary>
</div>
@@ -39,31 +44,31 @@ function TitlesOverview() {
const { data } = useProjectData();
return (
<div className={styles.titles}>
<div className={styles.title}>{data.title}</div>
<div className={styles.description}>{data.description}</div>
<div className={style.titles}>
<div className={style.title}>{data.title}</div>
<div className={style.description}>{data.description}</div>
</div>
);
}
function RuntimeOverview() {
const { playback, clock, numEvents, selectedEventIndex } = useRuntimeOverview();
const { clock, numEvents, selectedEventIndex, offset } = useRuntimePlaybackOverview();
const current = selectedEventIndex !== null ? selectedEventIndex + 1 : '-';
const ofTotal = numEvents || '-';
const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash;
const ofTotal = numEvents || enDash;
const progressText = numEvents ? `${current} of ${ofTotal}` : '';
const display = formatTime(clock);
const isAhead = offset <= 0;
let offsetText = millisToString(Math.abs(offset), { fallback: enDash });
if (offsetText !== enDash) {
offsetText = isAhead ? `+${offsetText}` : `${enDash}${offsetText}`;
}
return (
<div className={styles.clocks}>
<div className={styles.inline}>
<PlaybackIcon state={playback} skipTooltip />
{display}
</div>
<div className={styles.inline}>
<span>{`(${current} / ${ofTotal})`}</span>
Over / Under
</div>
</div>
<>
<TimeColumn label='Progress' value={progressText} />
<TimeColumn label='Offset' value={offsetText} className={isAhead ? style.ahead : style.behind} />
<TimeColumn label='Time now' value={formattedTime(clock)} />
</>
);
}
@@ -0,0 +1,40 @@
.label {
color: $label-gray;
font-size: calc(1rem - 2px);
width: 10em; // a number large enough to force right alignment
}
.clock {
text-align: left;
font-size: 1.5rem;
letter-spacing: 0.5px;
min-width: 5em;
&::after {
content: '\200b';
}
}
.column {
display: flex;
flex-direction: column;
.label {
line-height: 0.9em;
}
}
.row {
display: flex;
align-items: center;
gap: 0.5rem;
.label {
text-align: right;
}
.clock {
font-size: 1.25rem;
}
}
@@ -0,0 +1,27 @@
import { cx } from '../../../common/utils/styleUtils';
import style from './TimeLayout.module.scss';
interface TimeLayoutProps {
label: string;
value: string;
className?: string;
}
export function TimeColumn({ label, value, className }: TimeLayoutProps) {
return (
<div className={style.column}>
<span className={style.label}>{label}</span>
<span className={cx([style.clock, className])}>{value}</span>
</div>
);
}
export function TimeRow({ label, value, className }: TimeLayoutProps) {
return (
<div className={style.row}>
<span className={style.label}>{label}</span>
<span className={cx([style.clock, className])}>{value}</span>
</div>
);
}
@@ -64,9 +64,9 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
const hasDelay = delay !== 0;
const delayLabel = hasDelay
? `Event is ${millisToDelayString(delay)}. New schedule ${millisToString(timeStart + delay)}${millisToString(
timeEnd + delay,
)}`
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${millisToString(
timeStart + delay,
)}${millisToString(timeEnd + delay)}`
: '';
return (
@@ -10,6 +10,7 @@ import ViewParamsEditor from '../../../common/components/view-params-editor/View
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { OverridableOptions } from '../../../common/models/View.types';
import { timerPlaceholder } from '../../../common/utils/styleUtils';
import { isStringBoolean } from '../../../common/utils/viewUtils';
import { useTranslation } from '../../../translation/TranslationProvider';
import { getTimerByType } from '../common/viewerUtils';
@@ -153,7 +154,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
: viewSettings.normalColor;
const stageTimer = getTimerByType(time);
let display = millisToString(stageTimer, { fallback: '-- : -- : --' });
let display = millisToString(stageTimer, { fallback: timerPlaceholder });
if (stageTimer !== null) {
if (hideTimerSeconds) {
display = removeSeconds(display);
@@ -12,6 +12,7 @@ import { getTimerOptions } from '../../../common/components/view-params-editor/c
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { isStringBoolean } from '../../../common/utils/viewUtils';
import { useTranslation } from '../../../translation/TranslationProvider';
@@ -115,7 +116,7 @@ export default function Timer(props: TimerProps) {
: viewSettings.normalColor;
const stageTimer = getTimerByType(time);
let display = millisToString(stageTimer, { fallback: '-- : -- : --' });
let display = millisToString(stageTimer, { fallback: timerPlaceholder });
if (stageTimer !== null) {
if (hideTimerSeconds) {
display = removeSeconds(display);
+6
View File
@@ -69,3 +69,9 @@ $min-tablet: 500px;
opacity: 20%;
}
}
@mixin ellipsis-overflow() {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
+3 -4
View File
@@ -42,8 +42,8 @@ import { runtimeService } from './services/runtime-service/RuntimeService.js';
import { restoreService } from './services/RestoreService.js';
import { messageService } from './services/message-service/MessageService.js';
import { populateDemo } from './modules/loadDemo.js';
import { getState, updateNumEvents } from './stores/runtimeState.js';
import { getNumEvents, setRundown } from './services/rundown-service/RundownService.js';
import { getState, updateRundownData } from './stores/runtimeState.js';
import { setRundown, getPlayableEvents } from './services/rundown-service/RundownService.js';
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
@@ -184,8 +184,7 @@ export const startServer = async () => {
setRundown(persistedRundown);
// TODO: do this on the init of the runtime service
const numEvents = getNumEvents();
updateNumEvents(numEvents);
updateRundownData(getPlayableEvents());
// load restore point if it exists
const maybeRestorePoint = await restoreService.load();
@@ -37,19 +37,6 @@ export class DataProvider {
await this.persist();
}
static getIndexOf(eventId: string) {
return data.rundown.findIndex((e) => e.id === eventId);
}
static getRundownLength() {
return data.rundown.length;
}
static async clearRundown() {
data.rundown = [];
await db.write();
}
static getSettings() {
return data.settings;
}
@@ -9,6 +9,7 @@ export type RestorePoint = {
startedAt: MaybeNumber;
addedTime: number;
pausedAt: MaybeNumber;
firstStart: MaybeNumber;
};
/**
@@ -43,6 +44,10 @@ export function isRestorePoint(obj: unknown): obj is RestorePoint {
return false;
}
if (typeof restorePoint.firstStart !== 'number' && restorePoint.pausedAt !== null) {
return false;
}
return true;
}
+1
View File
@@ -139,6 +139,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
startedAt: state.timer.startedAt,
addedTime: state.timer.addedTime,
pausedAt: state._timer.pausedAt,
firstStart: state.runtime.actualStart,
});
return result;
};
@@ -13,6 +13,7 @@ describe('isRestorePoint()', () => {
startedAt: 1,
addedTime: 2,
pausedAt: 3,
firstStart: 1,
};
expect(isRestorePoint(restorePoint)).toBe(true);
@@ -22,6 +23,7 @@ describe('isRestorePoint()', () => {
startedAt: null,
addedTime: 0,
pausedAt: null,
firstStart: 1,
};
expect(isRestorePoint(restorePoint)).toBe(true);
});
@@ -68,6 +70,7 @@ describe('RestoreService()', () => {
startedAt: 1234,
addedTime: 5678,
pausedAt: 9087,
firstStart: 1234,
};
const restoreService = new RestoreService('/path/to/restore/file');
@@ -84,6 +87,7 @@ describe('RestoreService()', () => {
startedAt: null,
addedTime: 0,
pausedAt: null,
firstStart: 1234,
};
const restoreService = new RestoreService('/path/to/restore/file');
@@ -100,6 +104,7 @@ describe('RestoreService()', () => {
startedAt: 1234,
addedTime: 1234,
pausedAt: 1234,
firstStart: 1234,
};
const restoreService = new RestoreService('/path/to/restore/file');
@@ -118,6 +123,7 @@ describe('RestoreService()', () => {
startedAt: 1234,
addedTime: 1234,
pausedAt: 1234,
firstStart: 1234,
};
const restoreService = new RestoreService('/path/to/restore/file');
@@ -5,6 +5,7 @@ import {
getCurrent,
getExpectedFinish,
getRollTimers,
getRuntimeOffset,
normaliseEndTime,
skippedOutOfEvent,
updateRoll,
@@ -1370,3 +1371,68 @@ describe('updateRoll()', () => {
expect(updateRoll(timers)).toStrictEqual(expected);
});
});
describe('getRuntimeOffset()', () => {
it('calculates the difference between schedule and actual start', () => {
const state = {
eventNow: {
id: '1',
timeStart: 100,
},
timer: {
startedAt: 150,
addedTime: 10,
current: 0,
},
_timer: {
pausedAt: null,
},
} as RuntimeState;
const offset = getRuntimeOffset(state);
expect(offset).toBe(60);
});
it('adds the overtime time of the current timer', () => {
const state = {
eventNow: {
id: '1',
timeStart: 100,
timeEnd: 140,
},
timer: {
startedAt: 100,
current: -10,
addedTime: 0,
},
_timer: {
pausedAt: null,
},
} as RuntimeState;
const offset = getRuntimeOffset(state);
expect(offset).toBe(10);
});
it('accounts for paused time', () => {
const state = {
eventNow: {
id: '1',
timeStart: 100,
timeEnd: 150,
},
clock: 150,
timer: {
startedAt: 100,
current: 25,
addedTime: 0,
},
_timer: {
pausedAt: 125,
},
} as RuntimeState;
const offset = getRuntimeOffset(state);
expect(offset).toBe(25);
});
});
@@ -15,7 +15,7 @@ import { block as blockDef, delay as delayDef } from '../../models/eventsDefinit
import { sendRefetch } from '../../adapters/websocketAux.js';
import { logger } from '../../classes/Logger.js';
import { createEvent } from '../../utils/parser.js';
import { updateNumEvents } from '../../stores/runtimeState.js';
import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../runtime-service/RuntimeService.js';
import * as cache from './rundownCache.js';
@@ -159,8 +159,7 @@ export async function swapEvents(from: string, to: string) {
* Called when we make changes to the rundown object
*/
function updateChangeNumEvents() {
const numEvents = getPlayableEvents().length;
updateNumEvents(numEvents);
updateRundownData(getPlayableEvents());
}
/**
@@ -286,6 +285,10 @@ export function findNext(currentEventId?: string): OntimeEvent | null {
return nextEvent ?? null;
}
/**
* Overrides the rundown with the given
* @param rundown
*/
export async function setRundown(rundown: OntimeRundown) {
cache.init(rundown);
notifyChanges({ timer: true });
+20
View File
@@ -289,3 +289,23 @@ export const updateRoll = (state: RuntimeState) => {
return { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished: isPrimaryFinished };
};
/**
* Calculates difference between the runtime and the schedule of an event
* @param state
* @returns
*/
export function getRuntimeOffset(state: RuntimeState): number {
if (state.eventNow === null) {
return 0;
}
const { timeStart } = state.eventNow;
const { addedTime, current, startedAt } = state.timer;
const overtime = Math.min(current, 0);
const startOffset = startedAt - timeStart;
const pausedTime = state._timer.pausedAt === null ? 0 : state.clock - state._timer.pausedAt;
return startOffset + addedTime + pausedTime + Math.abs(overtime);
}
@@ -1,9 +1,10 @@
import { OntimeEvent, Playback } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import { RuntimeState, clear, getState, load, pause, start, stop } from '../runtimeState.js';
import { RuntimeState, addTime, clear, getState, load, pause, start, stop } from '../runtimeState.js';
const mockEvent = {
type: 'event',
id: 'mock',
cue: 'mock',
timeStart: 0,
@@ -88,6 +89,7 @@ describe('mutation on runtimeState', () => {
expect(newState.timer).toMatchObject({
playback: Playback.Play,
});
expect(newState.runtime.actualStart).toBe(newState.clock);
// 3. Pause event
success = pause();
@@ -122,7 +124,7 @@ describe('mutation on runtimeState', () => {
);
expect(newState._timer.pausedAt).toBeNull();
// 4. Stop event
// 5. Stop event
success = stop();
expect(success).toBe(true);
expect(newState.eventNow).toBe(null);
@@ -133,8 +135,55 @@ describe('mutation on runtimeState', () => {
expectedFinish: null,
startedAt: null,
});
expect(newState.runtime.actualStart).toBeNull();
});
test('runtime offset', () => {
const event1 = { ...mockEvent, id: 'event1', timeStart: 0, timeEnd: 1000, duration: 1000 };
const event2 = { ...mockEvent, id: 'event2', timeStart: 1000, timeEnd: 1500, duration: 500 };
// 1. Load event
load(event1, [event1, event2]);
let newState = getState();
expect(newState.runtime.actualStart).toBeNull();
expect(newState.runtime.plannedStart).toBe(0);
expect(newState.runtime.plannedEnd).toBe(1500);
// 2. Start event
start();
newState = getState();
const firstStart = newState.clock;
expect(newState.runtime.actualStart).toBe(newState.clock);
expect(newState.runtime.offset).toBe(newState.clock - event1.timeStart);
expect(newState.runtime.expectedEnd).toBe(newState.runtime.offset + event2.timeEnd);
// 3. Next event
load(event2, [event1, event2]);
start();
newState = getState();
expect(newState.runtime.actualStart).toBe(firstStart);
// we are over-under, the difference between the schedule and the actual start
const delayBefore = newState.clock - event2.timeStart;
expect(newState.runtime.offset).toBe(delayBefore);
// finish is the difference between the runtime and the schedule
expect(newState.runtime.expectedEnd).toBe(event2.timeEnd + newState.runtime.offset);
// 4. Add time
addTime(10);
newState = getState();
expect(newState.runtime.offset).toBe(delayBefore + 10);
expect(newState.runtime.expectedEnd).toBe(event2.timeEnd + newState.runtime.offset);
// 5. Stop event
stop();
newState = getState();
expect(newState.runtime.actualStart).toBeNull();
expect(newState.runtime.offset).toBe(0);
expect(newState.runtime.expectedEnd).toBeNull();
});
test.todo('runtime offset on timers in overtime', () => {});
test.todo('roll mode', () => {});
});
});
+52 -8
View File
@@ -1,15 +1,27 @@
import { Runtime, OntimeEvent, Playback, TimerState, TimerType, MaybeNumber } from 'ontime-types';
import { calculateDuration, dayInMs } from 'ontime-utils';
import { calculateDuration, dayInMs, getFirstEvent, getLastEvent } from 'ontime-utils';
import { clock } from '../services/Clock.js';
import { RestorePoint } from '../services/RestoreService.js';
import { getPlayableEvents } from '../services/rundown-service/RundownService.js';
import { getCurrent, getExpectedFinish, getRollTimers, skippedOutOfEvent, updateRoll } from '../services/timerUtils.js';
import {
getCurrent,
getExpectedFinish,
getRollTimers,
getRuntimeOffset,
skippedOutOfEvent,
updateRoll,
} from '../services/timerUtils.js';
import { timerConfig } from '../config/config.js';
const initialRuntime: Runtime = {
selectedEventIndex: null,
numEvents: 0,
offset: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
expectedEnd: null,
};
const initialTimer: TimerState = {
@@ -64,13 +76,13 @@ export function getState(): Readonly<RuntimeState> {
}
export function clear() {
// TODO: check that entire state is reset here
runtimeState.eventNow = null;
runtimeState.publicEventNow = null;
runtimeState.eventNext = null;
runtimeState.publicEventNext = null;
runtimeState.runtime = { ...initialRuntime };
runtimeState.runtime = { ...initialRuntime, actualStart: runtimeState.runtime.actualStart };
// TODO: can we cleanup the initialisation of runtime state?
runtimeState.runtime.numEvents = fetchNumEvents();
runtimeState.timer.playback = Playback.Stop;
@@ -106,11 +118,17 @@ function fetchNumEvents(): number {
}
/**
* Utility, allows updating the number of events
* Utility, allows updating data derived from the rundown
* @param numEvents
*/
export function updateNumEvents(numEvents: number) {
runtimeState.runtime.numEvents = numEvents;
export function updateRundownData(playableRundown: OntimeEvent[]) {
runtimeState.runtime.numEvents = playableRundown.length;
const { firstEvent } = getFirstEvent(playableRundown);
const { lastEvent } = getLastEvent(playableRundown);
runtimeState.runtime.plannedStart = firstEvent?.timeStart ?? null;
runtimeState.runtime.plannedEnd = lastEvent?.timeEnd ?? null;
}
/**
@@ -119,9 +137,11 @@ export function updateNumEvents(numEvents: number) {
* @param rundown
* @param initialData
*/
export function load(event: OntimeEvent, rundown: OntimeEvent[], initialData?: Partial<TimerState>) {
export function load(event: OntimeEvent, rundown: OntimeEvent[], initialData?: Partial<TimerState & RestorePoint>) {
clear();
updateRundownData(rundown);
const eventIndex = rundown.findIndex((eventInMemory) => eventInMemory.id === event.id);
runtimeState.runtime.selectedEventIndex = eventIndex;
@@ -137,6 +157,13 @@ export function load(event: OntimeEvent, rundown: OntimeEvent[], initialData?: P
if (initialData) {
patchTimer(initialData);
const firstStart = initialData?.firstStart;
if (firstStart === null || typeof firstStart === 'number') {
runtimeState.runtime.actualStart = firstStart;
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
runtimeState.runtime.expectedEnd = runtimeState.runtime.plannedEnd + runtimeState.runtime.offset;
}
}
}
@@ -256,6 +283,15 @@ export function start(state: RuntimeState = runtimeState): boolean {
state.timer.playback = Playback.Play;
state.timer.expectedFinish = getExpectedFinish(state);
state.timer.elapsed = 0;
// update runtime delays: over - under
if (state.runtime.actualStart === null) {
state.runtime.actualStart = state.clock;
}
state.runtime.offset = getRuntimeOffset(state);
state.runtime.expectedEnd = state.runtime.plannedEnd + state.runtime.offset;
return true;
}
@@ -274,6 +310,7 @@ export function stop(state: RuntimeState = runtimeState): boolean {
if (state.timer.playback === Playback.Stop) {
return false;
}
runtimeState.runtime.actualStart = null;
clear();
return true;
}
@@ -298,6 +335,10 @@ export function addTime(amount: number) {
runtimeState.timer.finishedAt = null;
}
}
// update runtime delays: over - under
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
runtimeState.runtime.expectedEnd = runtimeState.runtime.plannedEnd + runtimeState.runtime.offset;
return true;
}
@@ -318,6 +359,9 @@ export function update(force: boolean, updateInterval: number) {
_force = true;
}
// update offset
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
// we call integrations if we update timers
if (runtimeState.timer.playback === Playback.Roll) {
const result = roll();
@@ -3,4 +3,9 @@ import { MaybeNumber } from '../../utils/utils.type.js';
export type Runtime = {
numEvents: number;
selectedEventIndex: MaybeNumber;
offset: number;
plannedStart: MaybeNumber;
actualStart: MaybeNumber;
plannedEnd: MaybeNumber;
expectedEnd: MaybeNumber;
};
@@ -1,6 +1,6 @@
import { OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
import { getNext, getNextEvent, getPrevious, getPreviousEvent, swapEventData } from './rundownUtils';
import { getLastEvent, getNext, getNextEvent, getPrevious, getPreviousEvent, swapEventData } from './rundownUtils';
describe('getNext()', () => {
it('returns the next event of type event', () => {
@@ -189,3 +189,23 @@ describe('swapEventData', () => {
});
});
});
describe('getLastEvent', () => {
it('returns the last event of type event', () => {
const testRundown = [
{ id: '1', type: SupportedEvent.Event },
{ id: '2', type: SupportedEvent.Delay },
{ id: '3', type: SupportedEvent.Event },
{ id: '4', type: SupportedEvent.Block },
];
const { lastEvent } = getLastEvent(testRundown as OntimeRundown);
expect(lastEvent?.id).toBe('3');
});
it('handles rundowns with a single event', () => {
const testRundown = [{ id: '1', type: SupportedEvent.Event }];
const { lastEvent } = getLastEvent(testRundown as OntimeRundown);
expect(lastEvent?.id).toBe('1');
});
});
@@ -74,7 +74,7 @@ export function getLastEvent(rundown: OntimeRundown): {
return { lastEvent: null, lastIndex: null };
}
for (let i = rundown.length - 1; i > 0; i--) {
for (let i = rundown.length - 1; i >= 0; i--) {
const lastEvent = rundown.at(i);
if (isOntimeEvent(lastEvent)) {
return { lastEvent, lastIndex: i };
@@ -100,7 +100,7 @@ export function getLastEventNormal(
return { lastEvent: null, lastIndex: null };
}
for (let i = order.length - 1; i > 0; i--) {
for (let i = order.length - 1; i >= 0; i--) {
const lastId = order[i];
const lastEvent = rundown[lastId];
if (isOntimeEvent(lastEvent)) {