Compare commits

..

2 Commits

Author SHA1 Message Date
Carlos Valente b5075e8d18 bump version to 3.5.0-beta.1 2024-07-22 22:37:49 +02:00
Carlos Valente 2105a2af2a feat: timeline view 2024-07-22 22:31:00 +02:00
97 changed files with 2044 additions and 3215 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.5.0",
"version": "3.5.0-beta.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.5.0",
"version": "3.5.0-beta.1",
"private": true,
"type": "module",
"dependencies": {
@@ -22,9 +22,9 @@
"color": "^4.2.3",
"csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0",
"react": "^18.3.1",
"react": "^18.2.0",
"react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
"react-dom": "^18.2.0",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.12",
@@ -38,7 +38,7 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
onClose();
};
const host = window.location.origin;
const host = `${window.location.origin}/`;
const canSubmit = path !== currentPath && path !== '';
return (
@@ -1,12 +1,12 @@
import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress';
import { clamp } from '../../utils/math';
import './MultiPartProgressBar.scss';
interface MultiPartProgressBar {
now: MaybeNumber;
complete: MaybeNumber;
complete: number;
normalColor: string;
warning?: MaybeNumber;
warningColor: string;
@@ -31,9 +31,10 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '',
} = props;
const percentRemaining = 100 - getProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const percentRemaining = complete === 0 ? 0 : 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
const warningWidth = warning ? clamp((warning / complete) * 100 - dangerWidth, 0, 100) : 0;
return (
<div
@@ -1,23 +1,22 @@
import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress';
import { clamp } from '../../utils/math';
import './ProgressBar.scss';
interface ProgressBarProps {
current: MaybeNumber;
duration: MaybeNumber;
now?: number;
complete?: number;
hidden?: boolean;
className?: string;
}
export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, hidden, className = '' } = props;
const progress = getProgress(current, duration);
const { now = 0, complete = 100, hidden, className = '' } = props;
const percentComplete = clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
return (
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} />
<div className='progress-bar__indicator' style={{ width: `${percentComplete}%` }} />
</div>
);
}
@@ -12,7 +12,9 @@ export const useClientPath = () => {
// notify of client path changes
useEffect(() => {
socketSendJson('set-client-path', pathname + search);
//remove leading '/' from path
const fullPath = (pathname.startsWith('/') ? pathname.slice(1) : pathname) + search;
socketSendJson('set-client-path', fullPath);
}, [pathname, search]);
// navigate to new path when received from server
+1 -13
View File
@@ -50,7 +50,6 @@ export const usePlaybackControl = () => {
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
timerPhase: state.timer.phase,
});
return useRuntimeStore(featureSelector);
@@ -116,7 +115,6 @@ export const setAuxTimer = {
export const useCuesheet = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
currentBlockId: state.currentBlock.block?.id ?? null,
selectedEventId: state.eventNow?.id ?? null,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
@@ -151,6 +149,7 @@ export const useClock = () => {
/** Used by the progress bar components */
export const useProgressData = () => {
const featureSelector = (state: RuntimeStore) => ({
addedTime: state.timer.addedTime,
current: state.timer.current,
duration: state.timer.duration,
timeWarning: state.eventNow?.timeWarning ?? null,
@@ -180,17 +179,6 @@ export const useRuntimePlaybackOverview = () => {
numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset,
currentBlock: state.currentBlock,
});
return useRuntimeStore(featureSelector);
};
export const useTimelineOverview = () => {
const featureSelector = (state: RuntimeStore) => ({
plannedStart: state.runtime.plannedStart,
plannedEnd: state.runtime.plannedEnd,
});
return useRuntimeStore(featureSelector);
-4
View File
@@ -38,10 +38,6 @@ export const runtimeStorePlaceholder: RuntimeStore = {
actualStart: null,
expectedEnd: null,
},
currentBlock: {
block: null,
startedAt: null,
},
eventNow: null,
eventNext: null,
publicEventNow: null,
@@ -1,23 +0,0 @@
import { MaybeNumber } from 'ontime-types';
import { clamp } from './math';
/**
* Returns completion percentage of a progress bar
* This code assumes the current time and duration have addedTime already applied
*/
export function getProgress(current: MaybeNumber, duration: MaybeNumber) {
if (current === null || duration === null) {
return 0;
}
if (current <= 0) {
return 100;
}
if (current >= duration) {
return 0;
}
return clamp(((duration - current) / duration) * 100, 0, 100);
}
+1 -7
View File
@@ -36,8 +36,7 @@ export const connectSocket = () => {
}
socketSendJson('set-client-type', 'ontime');
socketSendJson('set-client-path', location.pathname + location.search);
socketSendJson('set-client-path', location.pathname);
};
websocket.onclose = () => {
@@ -151,11 +150,6 @@ export const connectSocket = () => {
updateDevTools({ eventNow: payload });
break;
}
case 'ontime-currentBlock': {
patchRuntime('currentBlock', payload);
updateDevTools({ currentBlock: payload });
break;
}
case 'ontime-publicEventNow': {
patchRuntime('publicEventNow', payload);
updateDevTools({ publicEventNow: payload });
+3 -10
View File
@@ -6,7 +6,7 @@ import { APP_SETTINGS } from '../api/constants';
import { ontimeQueryClient } from '../queryClient';
/**
* Returns current time in milliseconds from midnight
* Returns current time in milliseconds
* @returns {number}
*/
export function nowInMillis(): number {
@@ -101,7 +101,7 @@ export const formatTime = (
* @param duration
* @returns
*/
export function formatDuration(duration: number, hideSeconds = true): string {
export function formatDuration(duration: number): string {
// durations should never be negative, we handle it here to flag if there is an issue in future
if (duration <= 0) {
return '0h 0m';
@@ -111,17 +111,10 @@ export function formatDuration(duration: number, hideSeconds = true): string {
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
let result = '';
if (hours > 0) {
result += `${hours}h`;
result += `${hours}h `;
}
if (minutes > 0) {
result += `${minutes}m`;
}
if (!hideSeconds) {
const seconds = Math.floor((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
if (seconds > 0) {
result += `${seconds}s`;
}
}
return result;
}
@@ -148,7 +148,6 @@ export default function GeneralPanelForm() {
<option value='en'>English</option>
<option value='fr'>French</option>
<option value='de'>German</option>
<option value='hu'>Hungarian</option>
<option value='it'>Italian</option>
<option value='no'>Norwegian</option>
<option value='pt'>Portuguese</option>
@@ -78,7 +78,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
const isLoading = Boolean(loading);
const canSubmitSpreadsheet = isSpreadsheet && !isLoading;
const canSubmitGSheet = !isLoading && !stepData.worksheet.error;
const canSubmitGSheet = !isLoading;
const canSubmit = !hasErrors && isValid && (canSubmitSpreadsheet || canSubmitGSheet);
return (
@@ -21,7 +21,6 @@ export default function PlaybackControl() {
playback={data.playback}
numEvents={data.numEvents}
selectedEventIndex={data.selectedEventIndex}
timerPhase={data.timerPhase}
/>
<AuxTimer />
</div>
@@ -6,7 +6,7 @@ import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward'
import { IoReload } from '@react-icons/all-files/io5/IoReload';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { Playback, TimerPhase } from 'ontime-types';
import { Playback } from 'ontime-types';
import { validatePlayback } from 'ontime-utils';
import { setPlayback } from '../../../../common/hooks/useSocket';
@@ -19,11 +19,10 @@ interface PlaybackButtonsProps {
playback: Playback;
numEvents: number;
selectedEventIndex: number | null;
timerPhase: TimerPhase;
}
export default function PlaybackButtons(props: PlaybackButtonsProps) {
const { playback, numEvents, selectedEventIndex, timerPhase } = props;
const { playback, numEvents, selectedEventIndex } = props;
const isRolling = playback === Playback.Roll;
const isPlaying = playback === Playback.Play;
@@ -38,7 +37,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
const disableNext = isRolling || noEvents || isLast;
const disablePrev = isRolling || noEvents || isFirst;
const playbackCan = validatePlayback(playback, timerPhase);
const playbackCan = validatePlayback(playback);
const disableStart = !playbackCan.start;
const disablePause = !playbackCan.pause;
const disableRoll = !playbackCan.roll || noEvents;
@@ -21,11 +21,11 @@ interface CuesheetProps {
columns: ColumnDef<OntimeRundownEntry>[];
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => void;
selectedId: string | null;
currentBlockId: string | null;
}
export default function Cuesheet({ data, columns, handleUpdate, selectedId, currentBlockId }: CuesheetProps) {
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
const {
columnVisibility,
columnOrder,
@@ -114,16 +114,11 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId, curr
}
if (isOntimeBlock(row.original)) {
if (isPast && !showPrevious && key !== currentBlockId) {
return null;
}
return <BlockRow key={key} title={row.original.title} />;
}
if (isOntimeDelay(row.original)) {
if (isPast && !showPrevious) {
return null;
}
const delayVal = row.original.duration;
if (!showDelayBlock || delayVal === 0) {
return null;
}
@@ -133,6 +128,9 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId, curr
if (isOntimeEvent(row.original)) {
eventIndex++;
const isSelected = key === selectedId;
if (isSelected) {
isPast = false;
}
if (isPast && !showPrevious) {
return null;
@@ -107,7 +107,6 @@ export default function CuesheetWrapper() {
columns={columns}
handleUpdate={handleUpdate}
selectedId={featureData.selectedEventId}
currentBlockId={featureData.currentBlockId}
/>
</div>
);
@@ -6,17 +6,18 @@ import styles from './CuesheetProgress.module.scss';
export default function CuesheetProgress() {
const { data } = useViewSettings();
const { current, duration, timeWarning, timeDanger } = useProgressData();
const { addedTime, current, duration, timeWarning, timeDanger } = useProgressData();
const totalTime = (duration ?? 0) + (addedTime ?? 0);
return (
<MultiPartProgressBar
now={current}
complete={duration}
normalColor={data.normalColor}
complete={totalTime}
normalColor={data!.normalColor}
warning={timeWarning}
warningColor={data.warningColor}
warningColor={data!.warningColor}
danger={timeDanger}
dangerColor={data.dangerColor}
dangerColor={data!.dangerColor}
className={styles.progressOverride}
ignoreCssOverride
/>
@@ -170,12 +170,9 @@ export default function Operator() {
const mainField = main ? getPropertyValue(entry, main) ?? '' : entry.title;
const secondaryField = getPropertyValue(entry, secondary) ?? '';
const subscribedData = subscriptions
? subscriptions.flatMap((id) => {
if (!customFields[id]) {
return [];
}
? subscriptions.map((id) => {
const { label, colour } = customFields[id];
return [{ id, label, colour, value: entry.custom[id] }];
return { id, label, colour, value: entry.custom[id] };
})
: null;
@@ -45,7 +45,7 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to hide events that have passed',
description: 'Whether to events that have passed',
type: 'boolean',
defaultValue: false,
},
@@ -11,12 +11,13 @@ interface StatusBarProgressProps {
export default function StatusBarProgress(props: StatusBarProgressProps) {
const { viewSettings } = props;
const { current, duration, timeWarning, timeDanger } = useProgressData();
const { addedTime, current, duration, timeWarning, timeDanger } = useProgressData();
const totalTime = (duration ?? 0) + (addedTime ?? 0);
return (
<MultiPartProgressBar
now={current}
complete={duration}
complete={totalTime}
normalColor={viewSettings.normalColor}
warning={timeWarning}
warningColor={viewSettings.warningColor}
@@ -33,7 +33,6 @@ function _EditorOverview({ children }: { children: React.ReactNode }) {
<TimeRow label='Actual start' value={formatedTime(actualStart)} className={style.start} />
</div>
<ProgressOverview />
<CurrentBlockOverview />
<RuntimeOverview />
<div>
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} />
@@ -95,14 +94,6 @@ function TitlesOverview() {
);
}
function CurrentBlockOverview() {
const { currentBlock, clock } = useRuntimePlaybackOverview();
const timeInBlock = formatedTime(currentBlock.startedAt === null ? null : clock - currentBlock.startedAt);
return <TimeColumn label='Time in block' value={timeInBlock} className={style.clock} />;
}
function TimerOverview() {
const { current } = useTimer();
+26 -78
View File
@@ -2,24 +2,8 @@ import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react'
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useHotkeys } from '@mantine/hooks';
import {
isOntimeBlock,
isOntimeEvent,
isPlayableEvent,
PlayableEvent,
Playback,
RundownCached,
SupportedEvent,
} from 'ontime-types';
import {
getFirstNormal,
getLastNormal,
getNextBlockNormal,
getNextNormal,
getPreviousBlockNormal,
getPreviousNormal,
isNewLatest,
} from 'ontime-utils';
import { isOntimeEvent, MaybeNumber, Playback, RundownCached, SupportedEvent } from 'ontime-types';
import { getFirstNormal, getLastNormal, getNextNormal, getPreviousNormal } from 'ontime-utils';
import { useEventAction } from '../../common/hooks/useEventAction';
import useFollowComponent from '../../common/hooks/useFollowComponent';
@@ -114,61 +98,28 @@ export default function Rundown({ data }: RundownProps) {
[rundown, order, addEvent],
);
const selectBlock = useCallback(
(cursor: string | null, direction: 'up' | 'down') => {
if (order.length < 1) {
return;
}
let newCursor = cursor;
if (cursor === null) {
// there is no cursor, we select the first or last depending on direction
const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order);
if (isOntimeBlock(selected)) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
return;
}
newCursor = selected?.id ?? null;
}
if (newCursor === null) {
return;
}
// otherwise we select the next or previous
const selected =
direction === 'up'
? getPreviousBlockNormal(rundown, order, newCursor)
: getNextBlockNormal(rundown, order, newCursor);
if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
}
},
[order, rundown, setSelectedEvents],
);
const selectEntry = useCallback(
(cursor: string | null, direction: 'up' | 'down') => {
if (order.length < 1) {
return;
}
let newCursor: string | null;
let newIndex: number | null;
if (cursor === null) {
// there is no cursor, we select the first or last depending on direction if it exists
const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order);
if (selected !== null) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
}
return;
newCursor =
(direction === 'up' ? getLastNormal(rundown, order)?.id : getFirstNormal(rundown, order)?.id) ?? null;
newIndex = direction === 'up' ? order.length : 0;
} else {
// otherwise we select the next or previous
const selected =
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
newCursor = selected.entry?.id ?? null;
newIndex = selected.index;
}
// otherwise we select the next or previous
const selected =
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
if (newCursor && newIndex !== null) {
setSelectedEvents({ id: newCursor, selectMode: 'click', index: newIndex });
}
},
[order, rundown, setSelectedEvents],
@@ -194,10 +145,6 @@ export default function Rundown({ data }: RundownProps) {
useHotkeys([
['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true }],
['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true }],
['alt + shift + ArrowDown', () => selectBlock(cursor, 'down'), { preventDefault: true }],
['alt + shift + ArrowUp', () => selectBlock(cursor, 'up'), { preventDefault: true }],
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }],
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }],
@@ -256,9 +203,11 @@ export default function Rundown({ data }: RundownProps) {
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
}
let lastEntry: PlayableEvent | undefined; // used by indicators
let thisEntry: PlayableEvent | undefined;
let previousStart: MaybeNumber = null;
let previousEnd: MaybeNumber = null;
let previousEventId: string | undefined;
let thisStart: MaybeNumber = null;
let thisEnd: MaybeNumber = null;
let thisId = previousEventId;
let eventIndex = 0;
@@ -286,14 +235,13 @@ export default function Rundown({ data }: RundownProps) {
if (isOntimeEvent(event)) {
// event indexes are 1 based in frontend
eventIndex++;
previousStart = thisStart;
previousEnd = thisEnd;
previousEventId = thisId;
lastEntry = thisEntry;
if (isPlayableEvent(event)) {
// populate previous entry
if (isNewLatest(event.timeStart, event.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
thisEntry = event;
}
if (!event.skip) {
thisStart = event.timeStart;
thisEnd = event.timeEnd;
thisId = eventId;
}
}
@@ -320,8 +268,8 @@ export default function Rundown({ data }: RundownProps) {
loaded={isLoaded}
hasCursor={hasCursor}
isNext={isNext}
previousStart={lastEntry?.timeStart}
previousEnd={lastEntry?.timeEnd}
previousStart={previousStart}
previousEnd={previousEnd}
previousEventId={previousEventId}
playback={isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import { MaybeNumber, OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../common/hooks/useEventAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
@@ -32,8 +32,8 @@ interface RundownEntryProps {
eventIndex: number;
hasCursor: boolean;
isNext: boolean;
previousStart?: number;
previousEnd?: number;
previousStart: MaybeNumber;
previousEnd: MaybeNumber;
previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing
isRolling: boolean; // we need to know even if not related to this event
@@ -8,7 +8,7 @@ import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { EndAction, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
@@ -36,8 +36,8 @@ interface EventBlockProps {
title: string;
note: string;
delay: number;
previousStart?: number;
previousEnd?: number;
previousStart: MaybeNumber;
previousEnd: MaybeNumber;
colour: string;
isPast: boolean;
isNext: boolean;
@@ -1,13 +1,5 @@
import {
calculateDuration,
checkIsNextDay,
dayInMs,
getTimeFromPrevious,
millisToString,
removeTrailingZero,
} from 'ontime-utils';
import { formatDuration } from '../../../common/utils/time';
import { MaybeNumber } from 'ontime-types';
import { checkIsNextDay, dayInMs, millisToString, removeLeadingZero, removeTrailingZero } from 'ontime-utils';
export function formatDelay(timeStart: number, delay: number): string | undefined {
if (!delay) return;
@@ -18,24 +10,31 @@ export function formatDelay(timeStart: number, delay: number): string | undefine
return `New start ${timeTag}`;
}
export function formatOverlap(timeStart: number, previousStart?: number, previousEnd?: number): string | undefined {
const noPreviousElement = previousEnd === undefined || previousStart === undefined;
export function formatOverlap(
previousStart: MaybeNumber,
previousEnd: MaybeNumber,
timeStart: number,
): string | undefined {
const noPreviousElement = previousEnd === null || previousStart === null;
if (noPreviousElement) return;
const normalisedDuration = calculateDuration(previousStart, previousEnd);
const timeFromPrevious = getTimeFromPrevious(timeStart, previousStart, previousEnd, normalisedDuration);
if (timeFromPrevious === 0) return;
const overlap = previousEnd - timeStart;
if (overlap === 0) return;
if (checkIsNextDay(previousStart, timeStart, normalisedDuration)) {
const previousCrossMidnight = previousStart > previousEnd;
const normalisedPreviousEnd = previousCrossMidnight ? previousEnd + dayInMs : previousEnd;
const previousCrossMidnight = previousStart > previousEnd;
const isNextDay = previousCrossMidnight
? checkIsNextDay(previousEnd, timeStart) || previousEnd == 0 // exception for when previousEnd is precisely midnight
: checkIsNextDay(previousStart, timeStart);
const gap = dayInMs - normalisedPreviousEnd + timeStart;
const correctedPreviousEnd = previousCrossMidnight ? previousEnd + dayInMs : previousEnd;
if (isNextDay) {
const gap = dayInMs - correctedPreviousEnd + timeStart;
if (gap === 0) return;
const gapString = formatDuration(Math.abs(gap), false);
const gapString = removeLeadingZero(millisToString(Math.abs(gap)));
return `Gap ${gapString} (next day)`;
}
const overlapString = formatDuration(Math.abs(timeFromPrevious), false);
return `${timeFromPrevious < 0 ? 'Overlap' : 'Gap'} ${overlapString}`;
const overlapString = removeLeadingZero(millisToString(Math.abs(overlap)));
return `${overlap > 0 ? 'Overlap' : 'Gap'} ${overlapString}`;
}
@@ -1,18 +1,20 @@
import { MaybeNumber } from 'ontime-types';
import { formatDelay, formatOverlap } from './EventBlock.utils';
import style from './RundownIndicators.module.scss';
interface RundownIndicatorProps {
timeStart: number;
previousStart?: number;
previousEnd?: number;
previousStart: MaybeNumber;
previousEnd: MaybeNumber;
delay: number;
}
export default function RundownIndicators(props: RundownIndicatorProps) {
const { timeStart, previousStart, previousEnd, delay } = props;
const hasOverlap = formatOverlap(timeStart, previousStart, previousEnd);
const hasOverlap = formatOverlap(previousStart, previousEnd, timeStart);
const hasDelay = formatDelay(timeStart, delay);
return (
@@ -16,63 +16,47 @@ describe('formatOverlap()', () => {
const previousStart = 0;
const previousEnd = 60000; // 1 min
const timeStart = 30000; // 30 sec
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toEqual('Overlap 30s');
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toEqual('Overlap 0:30');
});
it('bug #949 recognises an overlap between two times', () => {
const previousStart = 46800000; // 13:00:00
const previousEnd = 48600000; // 13:30:00
const timeStart = 48300000; // 13:25:00
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toEqual('Overlap 5m');
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toEqual('Overlap 5:00');
});
it('handles events the day after, without overlap', () => {
const previousStart = 11 * MILLIS_PER_HOUR;
const previousEnd = 12 * MILLIS_PER_HOUR;
const timeStart = 6 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 18h (next day)');
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toBe('Gap 18:00:00 (next day)');
});
it('handles events the day after, with gap', () => {
const previousStart = 17 * MILLIS_PER_HOUR;
const previousEnd = 23 * MILLIS_PER_HOUR;
const timeStart = 9 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 10h (next day)');
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toBe('Gap 10:00:00 (next day)');
});
it('handles events the day after, with previous ending at midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR; // 23:00:00
const previousEnd = 0; // 00:00:00
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00:00
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 1h (next day)');
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toBe('Gap 01:00:00 (next day)');
});
it('handles sequential events the day after, with previous ending over midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 1 * MILLIS_PER_HOUR;
const timeStart = 1 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBeUndefined();
});
it('handles events the day after, with previous ending over midnight with overlap', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 2 * MILLIS_PER_HOUR;
const timeStart = 1 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Overlap 1h');
});
it('handles events the day after, with previous ending over midnight with gap', () => {
it('handles events the day after, with previous ending over midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 1 * MILLIS_PER_HOUR;
const timeStart = 2 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 1h');
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toBe('Gap 01:00:00');
});
});
@@ -1,12 +1,29 @@
import { MaybeNumber } from 'ontime-types';
import { useTimer } from '../../../../common/hooks/useSocket';
import { getProgress } from '../../../../common/utils/getProgress';
import { clamp } from '../../../../common/utils/math';
import style from './EventBlockProgressBar.module.scss';
export function getPercentComplete(remaining: MaybeNumber, total: MaybeNumber): number {
if (remaining === null || total === null) {
return 0;
}
if (remaining <= 0) {
return 100;
}
if (remaining === total) {
return 0;
}
return clamp(100 - (remaining * 100) / total, 0, 100);
}
export default function EventBlockProgressBar() {
const timer = useTimer();
const progress = getProgress(timer.current, timer.duration);
return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
const progress = `${getPercentComplete(timer.current, timer.duration)}%`;
return <div className={style.progressBar} style={{ width: progress }} />;
}
@@ -0,0 +1,27 @@
import { dayInMs } from 'ontime-utils';
import { getPercentComplete } from '../EventBlockProgressBar';
describe('getPercentComplete()', () => {
describe('calculates progress in normal cases', () => {
const testScenarios = [
{ current: 0, duration: 0, expect: 100 },
{ current: 0, duration: 100, expect: 100 },
{ current: 0, duration: dayInMs, expect: 100 },
{ current: 10, duration: 100, expect: 90 },
{ current: 50, duration: 100, expect: 50 },
{ current: 100, duration: 100, expect: 0 },
];
testScenarios.forEach((testCase) => {
it(`handles ${testCase.current} / ${testCase.duration}`, () => {
const progress = getPercentComplete(testCase.current, testCase.duration);
expect(progress).toBe(testCase.expect);
});
});
});
it('is 0 if we dont have a current or duration', () => {
const progress = getPercentComplete(null, null);
expect(progress).toBe(0);
});
});
@@ -28,7 +28,6 @@
}
td:nth-child(even) {
text-align: right;
white-space: nowrap;
}
}
}
@@ -24,18 +24,6 @@ function EventEditorEmpty() {
<Kbd></Kbd>
</td>
</tr>
<tr>
<td>Select block</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>/</AuxKey>
<Kbd></Kbd>
</td>
</tr>
<tr>
<td>Deselect entry</td>
<td>
@@ -39,7 +39,7 @@ type WithDataProps = {
publicSelectedId: string | null;
runtime: Runtime;
selectedId: string | null;
settings: Settings | undefined; // TODO: what is the case for this being undefined?
settings: Settings | undefined;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
};
@@ -94,6 +94,7 @@ export default function Backstage(props: BackstageProps) {
let stageTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
stageTimer = removeLeadingZero(stageTimer);
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const backstageOptions = getBackstageOptions(defaultFormat, customFields);
@@ -110,8 +111,8 @@ export default function Backstage(props: BackstageProps) {
<ProgressBar
className='progress-container'
current={time.current}
duration={time.duration}
now={time.current ?? undefined}
complete={totalTime}
hidden={!showProgress}
/>
@@ -14,6 +14,18 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
});
return [
{ section: 'View behaviour' },
{
id: 'trigger',
title: 'Animation Trigger',
description: '',
type: 'option',
values: {
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'manual',
},
{ section: 'Data sources' },
{
id: 'top-src',
@@ -2,17 +2,16 @@
$timeline-entry-height: 20px;
$lane-height: 120px;
$timeline-height: 1rem;
.timeline {
flex: 1;
font-weight: 600;
color: $ui-white;
background-color: $ui-black;
}
.timelineEvents {
position: relative;
top: 0.5rem;
height: 100%;
}
@@ -20,39 +19,9 @@ $timeline-height: 1rem;
display: flex;
flex-direction: column;
position: absolute;
border-left: 1px solid $ui-black;
border-inline: 1px solid $ui-black;
// avoiding content being larger than the view
height: calc(100% - 3rem);
// decorate timeline element
&::before {
content: '';
position: absolute;
box-sizing: content-box;
top: -$timeline-height;
left: 0;
right: 0;
height: $timeline-height;
background-color: $white-40;
}
}
.smallArea {
.content {
gap: 0rem;
writing-mode: vertical-rl;
}
.timeOverview {
opacity: 0;
}
}
.hide {
// hide text elements
& > div {
display: none;
}
}
.content {
@@ -67,15 +36,7 @@ $timeline-height: 1rem;
background-color: var(--lighter, $viewer-card-bg-color);
border-bottom: 2px solid $ui-black;
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
&[data-status='done'] {
opacity: $opacity-disabled;
}
&[data-status='live'] {
box-shadow: 0 0.25rem 0 0 $active-red;
}
box-shadow: 0 0.25rem 0 0 var(--color, $ui-white);
}
.delay {
@@ -1,9 +1,9 @@
import { memo } from 'react';
import { useViewportSize } from '@mantine/hooks';
import { isOntimeEvent, MaybeNumber, OntimeEvent } from 'ontime-types';
import { checkIsNextDay, dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import { isOntimeEvent, MaybeNumber } from 'ontime-types';
import { dayInMs, getFirstEventNormal, getLastEventNormal, MILLIS_PER_HOUR } from 'ontime-utils';
import { useTimelineOverview } from '../../../common/hooks/useSocket';
import useRundown from '../../../common/hooks-query/useRundown';
import TimelineMarkers from './timeline-markers/TimelineMarkers';
import ProgressBar from './timeline-progress-bar/TimelineProgressBar';
@@ -12,38 +12,61 @@ import { ProgressStatus, TimelineEntry } from './TimelineEntry';
import style from './Timeline.module.scss';
function useTimeline() {
const { data } = useRundown();
if (data.revision === -1) {
return null;
}
const { firstEvent } = getFirstEventNormal(data.rundown, data.order);
const { lastEvent } = getLastEventNormal(data.rundown, data.order);
const firstStart = firstEvent?.timeStart ?? 0;
const lastEnd = lastEvent?.timeEnd ?? 0;
const normalisedLastEnd = lastEnd < firstStart ? lastEnd + dayInMs : lastEnd;
// timeline is padded to nearest hours (floor and ceil)
const startHour = getStartHour(firstStart) * MILLIS_PER_HOUR;
const endHour = getEndHour(normalisedLastEnd) * MILLIS_PER_HOUR;
const accumulatedDelay = lastEvent?.delay ?? 0;
return {
rundown: data.rundown,
order: data.order,
startHour,
endHour,
accumulatedDelay,
};
}
interface TimelineProps {
selectedEventId: string | null;
rundown: OntimeEvent[];
}
export default memo(Timeline);
function Timeline(props: TimelineProps) {
const { selectedEventId, rundown } = props;
const { selectedEventId } = props;
const { width: screenWidth } = useViewportSize();
const { plannedStart, plannedEnd } = useTimelineOverview();
const timelineData = useTimeline();
if (plannedStart === null || plannedEnd === null) {
if (timelineData === null) {
return null;
}
const { lastEvent } = getLastEvent(rundown);
const startHour = getStartHour(plannedStart);
const endHour = getEndHour(plannedEnd + (lastEvent?.delay ?? 0));
const { rundown, order, startHour, endHour, accumulatedDelay } = timelineData;
let hasTimelinePassedMidnight = false;
let previousEventStartTime: MaybeNumber = null;
// we use selectedEventId as a signifier on whether the timeline is live
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
let eventStatus: ProgressStatus = 'done';
return (
<div className={style.timeline}>
<TimelineMarkers startHour={startHour} endHour={endHour} />
<ProgressBar startHour={startHour} endHour={endHour} />
<TimelineMarkers />
<ProgressBar startHour={startHour} endHour={endHour + accumulatedDelay} />
<div className={style.timelineEvents}>
{rundown.map((event) => {
{order.map((eventId) => {
// for now we dont render delays and blocks
const event = rundown[eventId];
if (!isOntimeEvent(event)) {
return null;
}
@@ -52,25 +75,20 @@ function Timeline(props: TimelineProps) {
if (eventStatus === 'live') {
eventStatus = 'future';
}
if (event.id === selectedEventId) {
if (eventId === selectedEventId) {
eventStatus = 'live';
}
// we need to offset the start to account for midnight
if (!hasTimelinePassedMidnight) {
// we need to offset the start to account for midnight
hasTimelinePassedMidnight = previousEventStartTime !== null && event.timeStart < previousEventStartTime;
}
// TODO: timeline must accumulate normalised time over days
const isNextDay =
previousEventStartTime !== null
? checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
: false;
const normalisedStart = hasTimelinePassedMidnight || isNextDay ? event.timeStart + dayInMs : event.timeStart;
const normalisedStart = hasTimelinePassedMidnight ? event.timeStart + dayInMs : event.timeStart;
previousEventStartTime = normalisedStart;
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
startHour * MILLIS_PER_HOUR,
endHour * MILLIS_PER_HOUR,
startHour,
endHour + accumulatedDelay,
normalisedStart + (event.delay ?? 0),
event.duration,
screenWidth,
@@ -78,13 +96,13 @@ function Timeline(props: TimelineProps) {
return (
<TimelineEntry
key={event.id}
key={eventId}
colour={event.colour}
delay={event.delay ?? 0}
duration={event.duration}
left={elementLeftPosition}
status={eventStatus}
start={normalisedStart} // dataset solves issues related to crossing midnight
start={event.timeStart}
title={event.title}
width={elementWidth}
/>
@@ -1,5 +1,5 @@
import { useTimelineStatus } from '../../../common/hooks/useSocket';
import { alpha, cx } from '../../../common/utils/styleUtils';
import { alpha } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
@@ -34,13 +34,10 @@ export function TimelineEntry(props: TimelineEntryProps) {
const hasDelay = delay > 0;
const lighterColour = alpha(colour, 0.7);
const columnClasses = cx([style.column, width < 40 && style.smallArea]);
const contentClasses = cx([style.content, width < 20 && style.hide]);
const showTitle = width > 25;
return (
<div
className={columnClasses}
className={style.column}
style={{
'--color': colour,
'--lighter': lighterColour ?? '',
@@ -49,7 +46,7 @@ export function TimelineEntry(props: TimelineEntryProps) {
}}
>
<div
className={contentClasses}
className={style.content}
data-status={status}
style={{
'--color': colour,
@@ -57,15 +54,11 @@ export function TimelineEntry(props: TimelineEntryProps) {
>
<div className={hasDelay ? style.cross : undefined}>{formattedStartTime}</div>
{hasDelay && <div className={style.delay}>{formatTime(delayedStart, formatOptions)}</div>}
{showTitle && <div>{title}</div>}
<div>{title}</div>
</div>
<div className={style.timeOverview} data-status={status}>
{status !== 'done' && (
<>
<div className={style.duration}>{formattedDuration}</div>
<TimelineEntryStatus status={status} start={delayedStart} />
</>
)}
<div className={style.duration}>{formattedDuration}</div>
<TimelineEntryStatus status={status} start={delayedStart} />
</div>
</div>
);
@@ -82,12 +75,13 @@ function TimelineEntryStatus(props: TimelineEntryStatusProps) {
const { clock, offset } = useTimelineStatus();
const { getLocalizedString } = useTranslation();
// start times need to be normalised in a rundown that crosses midnight
let statusText = getStatusLabel(start - clock + offset, status);
if (statusText === 'live') {
statusText = getLocalizedString('timeline.live');
} else if (statusText === 'pending') {
statusText = getLocalizedString('timeline.due');
} else if (statusText === 'done') {
statusText = getLocalizedString('timeline.done');
}
return <div className={style.status}>{statusText}</div>;
@@ -0,0 +1,24 @@
.timeline {
width: 100vw;
height: 100vh;
background-color: $ui-black;
color: $ui-white;
display: flex;
flex-direction: column;
gap: 2rem;
}
.title {
padding-inline: 2rem;
font-size: 3.5rem;
}
.sections {
padding-inline: 2rem;
display: grid;
grid-template-columns: 1fr 1fr;
row-gap: 1rem;
column-gap: 3rem;
}
@@ -1,101 +0,0 @@
@use '../../../theme/viewerDefs' as *;
.timeline {
width: 100vw;
height: 100vh;
padding-top: 0.5rem;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
display: flex;
flex-direction: column;
gap: 2rem;
.project-header {
padding-inline: 2rem;
font-size: clamp(32px, 4.5vw, 64px);
font-weight: 600;
display: flex;
justify-content: space-between;
}
.clock-container {
.label {
font-size: clamp(16px, 1.5vw, 24px);
font-weight: 600;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.time {
font-size: clamp(32px, 3.5vw, 50px);
font-weight: 600;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
}
}
.title-grid {
display: grid;
grid-template-columns: 2fr 3fr;
row-gap: 1rem;
column-gap: 2rem;
grid-template-areas:
'now next'
'now following';
padding-inline: 2rem;
}
.section {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: 0.5rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
border-radius: $element-border-radius;
}
.section--now {
grid-area: now;
}
.section-title {
line-height: 1em;
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.section-title__label {
text-transform: uppercase;
}
.section-title__status {
color: $green-500;
}
.section-content {
min-height: 2em;
line-height: 1em;
font-size: 3rem;
text-transform: uppercase;
font-weight: 600;
}
.section-content--now {
color: $red-500;
}
.section-content--next {
color: $green-500;
}
.section-content--subdue {
opacity: $opacity-disabled;
}
}
@@ -1,21 +1,17 @@
import { useMemo } from 'react';
import { MaybeString, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
import { MaybeString, OntimeEvent, ProjectData, Settings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import Section from './timeline-section/TimelineSection';
import Timeline from './Timeline';
import { getTimelineOptions } from './timeline.options';
import { getFormattedTimeToStart, getScopedRundown, getUpcomingEvents } from './timeline.utils';
import { getFormattedTimeToStart, getUpcomingEvents } from './timeline.utils';
import './TimelinePage.scss';
import style from './TimelinePage.module.scss';
interface TimelinePageProps {
backstageEvents: OntimeEvent[];
@@ -23,7 +19,6 @@ interface TimelinePageProps {
selectedId: MaybeString;
settings: Settings | undefined;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
}
/**
@@ -32,61 +27,36 @@ interface TimelinePageProps {
* There is little point splitting or memoising top level elements
*/
export default function TimelinePage(props: TimelinePageProps) {
const { backstageEvents, general, selectedId, settings, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { backstageEvents, general, selectedId, settings, time } = props;
const { getLocalizedString } = useTranslation();
const clock = formatTime(time.clock);
// holds copy of the rundown with only relevant events
const scopedRundown = useMemo(() => {
return getScopedRundown(backstageEvents, selectedId);
}, [backstageEvents, selectedId]);
const { now, next, followedBy } = useMemo(() => {
return getUpcomingEvents(scopedRundown, selectedId);
}, [scopedRundown, selectedId]);
useWindowTitle('Timeline');
if (!shouldRender) {
return null;
}
return getUpcomingEvents(backstageEvents, selectedId);
}, [backstageEvents, selectedId]);
// populate options
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const progressOptions = getTimelineOptions(defaultFormat);
const titleNow = now?.title ?? '-';
const dueText = getLocalizedString('timeline.due').toUpperCase();
const nextText = next !== null ? next.title : '-';
const followedByText = followedBy !== null ? followedBy.title : '-';
const nextStatus = next !== null ? getFormattedTimeToStart(next, time.clock, dueText) : undefined;
const followedByStatus = followedBy !== null ? getFormattedTimeToStart(followedBy, time.clock, dueText) : undefined;
const dueText = getLocalizedString('timeline.due');
const nextText = next !== null ? `${next.title} · ${getFormattedTimeToStart(next, time.clock, dueText)}` : '-';
const followedByText =
followedBy !== null ? `${followedBy.title} · ${getFormattedTimeToStart(followedBy, time.clock, dueText)}` : '-';
return (
<div className='timeline'>
<div className={style.timeline}>
<ViewParamsEditor viewOptions={progressOptions} />
<div className='project-header'>
{general.title}
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
<SuperscriptTime time={clock} className='time' />
</div>
</div>
<div className='title-grid'>
<div className={style.title}>{general.title}</div>
<div className={style.sections}>
<Section title={getLocalizedString('common.time_now')} content={clock} category='now' />
<Section title={getLocalizedString('common.next')} content={nextText} category='next' />
<Section title={getLocalizedString('timeline.live')} content={titleNow} category='now' />
<Section title={getLocalizedString('common.next')} status={nextStatus} content={nextText} category='next' />
<Section
title={getLocalizedString('timeline.followedby')}
status={followedByStatus}
content={followedByText}
category='next'
/>
<Section title={getLocalizedString('timeline.followedby')} content={followedByText} category='next' />
</div>
<Timeline selectedEventId={selectedId} rundown={scopedRundown} />
<Timeline selectedEventId={selectedId} />
</div>
);
}
@@ -1,21 +1,21 @@
import { makeTimelineSections } from '../timeline.utils';
import useRundown from '../../../../common/hooks-query/useRundown';
import { getTimelineSections } from '../timeline.utils';
import style from './TimelineMarkers.module.scss';
interface TimelineMarkersProps {
startHour: number;
endHour: number;
}
export default function TimelineMarkers() {
const { data } = useRundown();
export default function TimelineMarkers(props: TimelineMarkersProps) {
const { startHour, endHour } = props;
if (!data || data.revision === -1) {
return null;
}
const elements = makeTimelineSections(startHour, endHour);
const elements = getTimelineSections(data.rundown, data.order);
return (
<div className={style.markers}>
{elements.map((tag, index) => {
return <span key={`${index}-${tag}`}>{tag}</span>;
{elements.map((tag) => {
return <span key={tag}>{tag}</span>;
})}
</div>
);
@@ -1,19 +1,13 @@
.progressBar {
width: 100%;
height: 1rem;
position: relative;
height: 0.5rem;
transition-duration: 0.3s;
transition-property: left;
background-color: $gray-1000;
}
.progress {
height: 100%;
position: absolute;
left: 0;
top: 0;
z-index: 2;
background-color: $active-red;
transition-duration: 0.3s;
transition-property: width;
.progress {
height: 100%;
background-color: $active-red;
}
}
@@ -1,5 +1,3 @@
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { useClock } from '../../../../common/hooks/useSocket';
import { getRelativePositionX } from '../timeline.utils';
@@ -12,10 +10,9 @@ interface ProgressBarProps {
export default function ProgressBar(props: ProgressBarProps) {
const { startHour, endHour } = props;
// TODO: how to account for days?
const { clock } = useClock();
const width = getRelativePositionX(startHour * MILLIS_PER_HOUR, endHour * MILLIS_PER_HOUR, clock);
const width = getRelativePositionX(startHour, endHour, clock);
return (
<div className={style.progressBar}>
@@ -0,0 +1,25 @@
.sectionTitle {
line-height: 1.2em;
font-size: 1.5rem;
text-transform: uppercase;
}
.sectionContent {
min-height: 2em;
line-height: 1em;
font-size: 3rem;
text-transform: uppercase;
font-weight: 600;
&.now {
color: $red-500;
}
&.next {
color: $green-500;
}
&.subdue {
opacity: $opacity-disabled;
}
}
@@ -1,28 +1,22 @@
import { memo } from 'react';
import { MaybeString } from 'ontime-types';
import { cx } from '../../../../common/utils/styleUtils';
import style from './TimelineSection.module.scss';
interface SectionProps {
category: 'now' | 'next';
content: MaybeString;
title: string;
status?: string;
}
export default memo(Section);
export default function Section(props: SectionProps) {
const { category, content, title } = props;
export function Section(props: SectionProps) {
const { category, content, title, status } = props;
const sectionClasses = cx(['section', category === 'now' && 'section--now']);
const contentClasses = cx(['section-content', content ? `section-content--${category}` : 'section-content--subdue']);
const contentClasses = cx([style.sectionContent, content != null ? style[category] : style.subdue]);
return (
<div className={sectionClasses}>
<div className='section-title'>
<span className='section-title__label'>{title}</span>
{status && <span className='section-title__status'>{status}</span>}
</div>
<div>
<div className={style.sectionTitle}>{title}</div>
<div className={contentClasses}>{content ?? '-'}</div>
</div>
);
@@ -2,21 +2,5 @@ import { getTimeOption } from '../../../common/components/view-params-editor/con
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
return [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide Past Events',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideBackstage',
title: 'Hide Private Events',
description: 'Whether to hide non-public events',
type: 'boolean',
defaultValue: false,
},
];
return [getTimeOption(timeFormat)];
};
@@ -1,8 +1,10 @@
import { isOntimeEvent, MaybeString, OntimeEvent } from 'ontime-types';
import { isOntimeEvent, MaybeString, NormalisedRundown, OntimeEvent } from 'ontime-types';
import {
dayInMs,
getEventWithId,
getFirstEvent,
getFirstEventNormal,
getLastEventNormal,
getNextEvent,
MILLIS_PER_HOUR,
millisToString,
@@ -11,7 +13,6 @@ import {
import { clamp } from '../../../common/utils/math';
import { formatDuration } from '../../../common/utils/time';
import { isStringBoolean } from '../common/viewUtils';
import type { ProgressStatus } from './TimelineEntry';
@@ -72,6 +73,26 @@ export function makeTimelineSections(firstHour: number, lastHour: number) {
return timelineSections;
}
/**
* Extracts the timeline sections from a rundown
*/
export function getTimelineSections(rundown: NormalisedRundown, order: string[]): string[] {
if (order.length === 0) {
return [];
}
const { firstEvent } = getFirstEventNormal(rundown, order);
const { lastEvent } = getLastEventNormal(rundown, order);
const firstStart = firstEvent?.timeStart ?? 0;
const lastEnd = lastEvent?.timeEnd ?? 0;
const normalisedLastEnd = lastEnd < firstStart ? lastEnd + dayInMs : lastEnd;
const startHour = getStartHour(firstStart);
const endHour = getEndHour(normalisedLastEnd);
const elements = makeTimelineSections(startHour, endHour);
return elements;
}
/**
* Returns a formatted label for a progress status
*/
@@ -87,31 +108,6 @@ export function getStatusLabel(timeToStart: number, status: ProgressStatus): str
return formatDuration(timeToStart);
}
export function getScopedRundown(rundown: OntimeEvent[], selectedEventId: MaybeString): OntimeEvent[] {
if (rundown.length === 0) {
return [];
}
const params = new URL(document.location.href).searchParams;
const hideBackstage = isStringBoolean(params.get('hideBackstage'));
const hidePast = isStringBoolean(params.get('hidePast'));
let scopedRundown = [...rundown];
if (hidePast && selectedEventId) {
const currentIndex = rundown.findIndex((event) => event.id === selectedEventId);
if (currentIndex >= 0) {
scopedRundown = scopedRundown.slice(currentIndex);
}
}
if (hideBackstage) {
scopedRundown = scopedRundown.filter((event) => event.isPublic);
}
return scopedRundown;
}
type UpcomingEvents = {
now: OntimeEvent | null;
next: OntimeEvent | null;
@@ -7,7 +7,6 @@ $white-9: rgba(255, 255, 255, 0.09);
$white-10: rgba(255, 255, 255, 0.10);
$white-13: rgba(255, 255, 255, 0.13);
$white-20: rgba(255, 255, 255, 0.20);
$white-40: rgba(255, 255, 255, 0.40);
$white-60: rgba(255, 255, 255, 0.60);
$white-90: rgba(255, 255, 255, 0.90);
@@ -6,7 +6,6 @@ import { langDe } from './languages/de';
import { langEn } from './languages/en';
import { langEs } from './languages/es';
import { langFr } from './languages/fr';
import { langHu } from './languages/hu';
import { langIt } from './languages/it';
import { langNo } from './languages/no';
import { langPl } from './languages/pl';
@@ -17,7 +16,6 @@ const translationsList = {
en: langEn,
es: langEs,
fr: langFr,
hu: langHu,
it: langIt,
de: langDe,
no: langNo,
@@ -1,26 +0,0 @@
import { TranslationObject } from './en';
export const langHu: TranslationObject = {
'common.expected_finish': 'Várható befejezés',
'common.minutes': 'perc',
'common.now': 'Most',
'common.next': 'Következő',
'common.public_message': 'Nyilvános közlemény',
'common.scheduled_start': 'Ütemezett kezdés',
'common.scheduled_end': 'Ütemezett befejezés',
'common.projected_start': 'Várható kezdés',
'common.projected_end': 'Várható befejezés',
'common.stage_timer': 'Színpadi időzítő',
'common.started_at': 'Kezdődött',
'common.time_now': 'Jelenlegi idő',
'countdown.ended': 'Esemény véget ért',
'countdown.running': 'Esemény folyamatban',
'countdown.select_event': 'Válassza ki a követendő eseményt',
'countdown.to_start': 'Idő kezdésig',
'countdown.waiting': 'Várakozás az esemény kezdetére',
'countdown.overtime': 'csúszik',
'timeline.live': 'élő',
'timeline.done': 'kész',
'timeline.due': 'esedékes',
'timeline.followedby': 'Követi',
};
+1 -1
View File
@@ -3,7 +3,7 @@ export const navigatorConstants = [
{ url: '/clock', label: 'Clock' },
{ url: '/minimal', label: 'Minimal Timer' },
{ url: '/backstage', label: 'Backstage' },
{ url: '/timeline', label: 'Timeline (beta)' },
{ url: '/timeline', label: 'Timeline' },
{ url: '/public', label: 'Public' },
{ url: '/lower', label: 'Lower Thirds' },
{ url: '/studio', label: 'Studio Clock' },
+1 -4
View File
@@ -1,7 +1,6 @@
const { app, BrowserWindow, Menu, globalShortcut, Tray, dialog, ipcMain, shell, Notification } = require('electron');
const path = require('path');
const electronConfig = require('./electron.config');
const { version } = require('./package.json');
const { getApplicationMenu } = require('./src/menu/applicationMenu.js');
const env = process.env.NODE_ENV || 'production';
@@ -188,9 +187,7 @@ app.whenReady().then(() => {
? electronConfig.reactAppUrl.production(port)
: electronConfig.reactAppUrl.development(port);
const template = getApplicationMenu(isMac, askToQuit, clientUrl, `v${version}`, (path) => {
win.loadURL(`${clientUrl}/${path}`);
});
const template = getApplicationMenu(isMac, askToQuit, clientUrl);
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.5.0",
"version": "3.5.0-beta.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+9 -24
View File
@@ -4,7 +4,7 @@ const { shell } = require('electron');
* @param {boolean} isMac - Whether the target platform is mac
* @param {function} askToQuit - function for quitting process
*/
function getApplicationMenu(isMac, askToQuit, urlBase, version, redirectWindow) {
function getApplicationMenu(isMac, askToQuit, urlBase) {
return [
...(isMac
? [
@@ -59,19 +59,6 @@ function getApplicationMenu(isMac, askToQuit, urlBase, version, redirectWindow)
{
label: 'Ontime Views (opens in browser)',
submenu: [
{
label: 'Public',
click: async () => {
await shell.openExternal(`${urlBase}/public`);
},
},
{
label: 'Lower Thirds',
click: async () => {
await shell.openExternal(`${urlBase}/lower`);
},
},
{ type: 'separator' },
{
label: 'Timer',
accelerator: 'CmdOrCtrl+V',
@@ -98,9 +85,15 @@ function getApplicationMenu(isMac, askToQuit, urlBase, version, redirectWindow)
},
},
{
label: 'Timeline (beta)',
label: 'Public',
click: async () => {
await shell.openExternal(`${urlBase}/timeline`);
await shell.openExternal(`${urlBase}/public`);
},
},
{
label: 'Lower Thirds',
click: async () => {
await shell.openExternal(`${urlBase}/lower`);
},
},
{
@@ -157,14 +150,6 @@ function getApplicationMenu(isMac, askToQuit, urlBase, version, redirectWindow)
{
role: 'help',
submenu: [
{
label: 'About',
click: () => redirectWindow('editor?settings=about'),
},
{
label: version,
click: () => redirectWindow('editor?settings=about'),
},
{
label: 'See on github',
click: async () => {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.5.0",
"version": "3.5.0-beta.1",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -1,11 +1,4 @@
import {
ErrorResponse,
MessageResponse,
OntimeRundown,
OntimeRundownEntry,
RundownCached,
RundownPaginated,
} from 'ontime-types';
import { ErrorResponse, MessageResponse, OntimeRundownEntry, RundownCached, RundownPaginated } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { Request, Response } from 'express';
@@ -29,11 +22,6 @@ import {
getRundown,
} from '../../services/rundown-service/rundownUtils.js';
export async function rundownGetAll(_req: Request, res: Response<OntimeRundown>) {
const rundown = getRundown();
res.json(rundown);
}
export async function rundownGetNormalised(_req: Request, res: Response<RundownCached>) {
const cachedRundown = getNormalisedRundown();
res.json(cachedRundown);
@@ -6,7 +6,6 @@ import {
rundownBatchPut,
rundownDelete,
rundownFrozenPost,
rundownGetAll,
rundownGetById,
rundownGetNormalised,
rundownGetPaginated,
@@ -30,8 +29,7 @@ import { preventIfFrozen } from './rundown.middleware.js';
export const router = express.Router();
router.get('/', rundownGetAll); // not used in Ontime frontend
router.get('/paginated', rundownGetPaginatedQueryParams, rundownGetPaginated); // not used in Ontime frontend
router.get('/', rundownGetPaginatedQueryParams, rundownGetPaginated); // not used in Ontime frontend
router.get('/normalised', rundownGetNormalised);
router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend
@@ -54,15 +54,18 @@ const actionHandlers: Record<string, ActionHandler> = {
if (typeof property !== 'string' || value === undefined) {
throw new Error('Invalid property or value');
}
// parseProperty is async because of the data lock
const newObjectProperty = parseProperty(property, value);
const key = Object.keys(newObjectProperty)[0] as keyof OntimeEvent;
shouldThrottle = shouldThrottle || willCauseRegeneration(key);
if (patchEvent.custom && newObjectProperty.custom) {
Object.assign(patchEvent.custom, newObjectProperty.custom);
} else {
Object.assign(patchEvent, newObjectProperty);
}
parseProperty(property, value).then((newObjectProperty) => {
const key = Object.keys(newObjectProperty)[0] as keyof OntimeEvent;
shouldThrottle = willCauseRegeneration(key) || shouldThrottle;
if (patchEvent.custom && newObjectProperty.custom) {
Object.assign(patchEvent.custom, newObjectProperty.custom);
} else {
Object.assign(patchEvent, newObjectProperty);
}
});
});
if (shouldThrottle) {
@@ -42,7 +42,7 @@ const propertyConversion = {
timeEnd: (value: unknown) => clampDuration(coerceNumber(value)),
};
export function parseProperty(property: string, value: unknown) {
export async function parseProperty(property: string, value: unknown) {
if (property.startsWith('custom:')) {
const customKey = property.split(':')[1].toLocaleLowerCase(); // all custom fields keys are lowercase
const customFields = getDataProvider().getCustomFields();
+1 -11
View File
@@ -19,7 +19,7 @@ import {
resolvePublicDirectoy,
} from './setup/index.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
import { consoleSuccess, consoleHighlight } from './utils/console.js';
// Import Routers
import { appRouter } from './api-data/index.js';
@@ -179,10 +179,6 @@ export const startServer = async (
message: messageService.getState(),
runtime: state.runtime,
eventNow: state.eventNow,
currentBlock: {
block: null,
startedAt: null,
},
publicEventNow: state.publicEventNow,
eventNext: state.eventNext,
publicEventNext: state.publicEventNext,
@@ -285,18 +281,12 @@ export const shutdown = async (exitCode = 0) => {
process.on('exit', (code) => consoleHighlight(`Ontime shutdown with code: ${code}`));
process.on('unhandledRejection', async (error) => {
if (!isProduction && error instanceof Error && error.stack) {
consoleError(error.stack);
}
generateCrashReport(error);
logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`);
await shutdown(1);
});
process.on('uncaughtException', async (error) => {
if (!isProduction && error instanceof Error && error.stack) {
consoleError(error.stack);
}
generateCrashReport(error);
logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`);
await shutdown(1);
+1 -6
View File
@@ -11,7 +11,6 @@ export type RestorePoint = {
addedTime: number;
pausedAt: MaybeNumber;
firstStart: MaybeNumber;
blockStartAt: MaybeNumber;
};
/**
@@ -46,11 +45,7 @@ export function isRestorePoint(obj: unknown): obj is RestorePoint {
return false;
}
if (typeof restorePoint.firstStart !== 'number' && restorePoint.firstStart !== null) {
return false;
}
if (typeof restorePoint.blockStartAt !== 'number' && restorePoint.blockStartAt !== null) {
if (typeof restorePoint.firstStart !== 'number' && restorePoint.pausedAt !== null) {
return false;
}
@@ -1,3 +1,5 @@
import { OntimeEvent } from 'ontime-types';
import * as runtimeState from '../stores/runtimeState.js';
import type { UpdateResult } from '../stores/runtimeState.js';
import { timerConfig } from '../config/config.js';
@@ -7,7 +9,7 @@ type UpdateCallbackFn = (updateResult: UpdateResult) => void;
/**
* Service manages Ontime's main timer
*/
export class EventTimer {
export class TimerService {
private readonly _interval: NodeJS.Timeout;
/** how often we recalculate */
static _refreshInterval: number;
@@ -24,14 +26,15 @@ export class EventTimer {
* @param {function} [timerConfig.onUpdateCallback] how often we update the socket
*/
constructor(timerConfig: { refresh: number; updateInterval: number }) {
EventTimer._refreshInterval = timerConfig.refresh;
TimerService._refreshInterval = timerConfig.refresh;
this._interval = setInterval(() => {
this.update();
}, EventTimer._refreshInterval);
}, TimerService._refreshInterval);
}
/**
* Allows setting a callback for when the timer updates
* @param callback
*/
setOnUpdateCallback(callback: (updateResult: UpdateResult) => void) {
this.onUpdateCallback = callback;
@@ -70,6 +73,7 @@ export class EventTimer {
/**
* Adds time to running timer by given amount
* @param {number} amount
*/
addTime(amount: number): boolean {
if (!runtimeState.addTime(amount)) {
@@ -98,6 +102,14 @@ export class EventTimer {
this.onUpdateCallback?.(updateResult);
}
/**
* Loads roll information into timer service
* @param {OntimeEvent[]} rundown -- list of events to run
*/
roll(rundown: OntimeEvent[]) {
runtimeState.roll(rundown);
}
shutdown() {
clearInterval(this._interval);
clearTimeout(this.endCallback);
@@ -14,7 +14,6 @@ describe('isRestorePoint()', () => {
addedTime: 2,
pausedAt: 3,
firstStart: 1,
blockStartAt: 10,
};
expect(isRestorePoint(restorePoint)).toBe(true);
@@ -25,7 +24,6 @@ describe('isRestorePoint()', () => {
addedTime: 0,
pausedAt: null,
firstStart: 1,
blockStartAt: null,
};
expect(isRestorePoint(restorePoint)).toBe(true);
});
@@ -38,7 +36,6 @@ describe('isRestorePoint()', () => {
startedAt: null,
addedTime: 0,
pausedAt: null,
blockStartAt: 10,
};
expect(isRestorePoint(restorePoint)).toBe(false);
});
@@ -48,7 +45,6 @@ describe('isRestorePoint()', () => {
startedAt: null,
addedTime: 0,
pausedAt: null,
blockStartAt: 10,
};
expect(isRestorePoint(restorePoint)).toBe(false);
});
@@ -59,7 +55,6 @@ describe('isRestorePoint()', () => {
startedAt: 'testing',
addedTime: 0,
pausedAt: null,
blockStartAt: 10,
};
expect(isRestorePoint(restorePoint)).toBe(false);
});
@@ -76,7 +71,6 @@ describe('RestoreService()', () => {
addedTime: 5678,
pausedAt: 9087,
firstStart: 1234,
blockStartAt: 1652,
};
const restoreService = new RestoreService('/path/to/restore/file');
@@ -94,7 +88,6 @@ describe('RestoreService()', () => {
addedTime: 0,
pausedAt: null,
firstStart: 1234,
blockStartAt: null,
};
const restoreService = new RestoreService('/path/to/restore/file');
@@ -112,7 +105,6 @@ describe('RestoreService()', () => {
addedTime: 1234,
pausedAt: 1234,
firstStart: 1234,
blockStartAt: 10,
};
const restoreService = new RestoreService('/path/to/restore/file');
@@ -132,7 +124,6 @@ describe('RestoreService()', () => {
addedTime: 1234,
pausedAt: 1234,
firstStart: 1234,
blockStartAt: null,
};
const restoreService = new RestoreService('/path/to/restore/file');
@@ -1,448 +0,0 @@
import { OntimeEvent, SupportedEvent } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { loadRoll } from '../rollUtils.js';
const baseEvent = {
type: SupportedEvent.Event,
skip: false,
};
function makeOntimeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
return {
...baseEvent,
...patch,
} as OntimeEvent;
}
function prepareTimedEvents(events: Partial<OntimeEvent>[]): OntimeEvent[] {
return events.map(makeOntimeEvent);
}
describe('loadRoll()', () => {
const eventlist = [
{
id: '1',
timeStart: 5,
timeEnd: 10,
isPublic: false,
},
{
id: '2',
timeStart: 10,
timeEnd: 20,
isPublic: false,
},
{
id: '3',
timeStart: 20,
timeEnd: 30,
isPublic: false,
},
{
id: '4',
timeStart: 30,
timeEnd: 40,
isPublic: false,
},
{
id: '5',
timeStart: 40,
timeEnd: 50,
isPublic: true,
},
{
id: '6',
timeStart: 50,
timeEnd: 60,
isPublic: false,
},
{
id: '7',
timeStart: 60,
timeEnd: 70,
isPublic: true,
},
{
id: '8',
timeStart: 70,
timeEnd: 80,
isPublic: false,
},
];
const timedEvents = prepareTimedEvents(eventlist);
it('should roll to the day after if timer is at 100', () => {
const now = 100;
const expected = {
event: timedEvents[0],
index: 0,
isPending: true,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should be waiting to start if timer is at 0', () => {
const now = 0;
const expected = {
event: timedEvents[0],
index: 0,
isPending: true,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the first event if timer is at 5', () => {
const now = 5;
const expected = {
event: timedEvents[0],
index: 0,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the second event if timer is at 15', () => {
const now = 15;
const expected = {
event: timedEvents[1],
index: 1,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the third event if timer is at 10', () => {
const now = 20;
const expected = {
event: timedEvents[2],
index: 2,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the fifth event if timer is at 49', () => {
const now = 49;
const expected = {
event: timedEvents[4],
index: 4,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the seventh event if timer is at 63', () => {
const now = 63;
const expected = {
event: timedEvents[6],
index: 6,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the eight event if timer is at 75', () => {
const now = 75;
const expected = {
event: timedEvents[7],
index: 7,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
});
describe('loadRoll() handle edge cases with midnight', () => {
it('should find an event that crosses midnight', () => {
const now = 23 * MILLIS_PER_HOUR;
const eventlist = [
{
id: '0',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 10 * MILLIS_PER_HOUR,
isPublic: true,
},
{
id: '1',
timeStart: 20 * MILLIS_PER_HOUR,
timeEnd: 22 * MILLIS_PER_HOUR,
isPublic: true,
},
{
id: '2',
timeStart: 22 * MILLIS_PER_HOUR,
timeEnd: 1 * MILLIS_PER_HOUR,
isPublic: true,
},
{
id: '3',
timeStart: 1 * MILLIS_PER_HOUR,
timeEnd: 1 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE,
isPublic: true,
},
{
id: '4',
timeStart: 1 * MILLIS_PER_HOUR,
timeEnd: 2 * MILLIS_PER_HOUR,
isPublic: true,
},
];
const timedEvents = prepareTimedEvents(eventlist);
const expected = {
event: timedEvents[2],
index: 2,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should not skip to the second day', () => {
/**
* NOTE: this is a potentially contentious decision
*
* The idea here is that it makes no sense for us to jump to the second / third day on activating roll
* if the user wants to skip a portion of the rundown, they can manually jump to the event and activate roll
*
* On our side, this simplifies logic and makes behaviour more predictable
*/
const now = 8 * MILLIS_PER_HOUR;
const eventlist = [
{
id: '0',
timeStart: 21 * MILLIS_PER_HOUR,
timeEnd: 22 * MILLIS_PER_HOUR,
},
{
id: '1',
timeStart: 22 * MILLIS_PER_HOUR,
timeEnd: 3 * MILLIS_PER_HOUR,
},
{
id: '2',
timeStart: 3 * MILLIS_PER_HOUR,
timeEnd: 10 * MILLIS_PER_HOUR,
},
];
const timedEvents = prepareTimedEvents(eventlist);
const expected = {
event: timedEvents[0],
index: 0,
isPending: true,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
});
describe('loadRoll() handle edge cases with before and after start', () => {
it('should prepare first event, if we are not yet in the rundown start', () => {
const now = 7 * MILLIS_PER_HOUR;
const singleEventList = [
makeOntimeEvent({
id: '1',
timeStart: 10 * MILLIS_PER_HOUR,
timeEnd: 11 * MILLIS_PER_HOUR,
isPublic: true,
}),
];
const expected = {
event: singleEventList[0],
index: 0,
isPending: true,
};
const state = loadRoll(singleEventList, now);
expect(state).toStrictEqual(expected);
});
it('should prepare first event, if we are over the rundown end', () => {
const now = 18 * MILLIS_PER_HOUR;
const singleEventList = [
makeOntimeEvent({
id: '1',
timeStart: 10 * MILLIS_PER_HOUR,
timeEnd: 11 * MILLIS_PER_HOUR,
isPublic: true,
}),
];
const expected = {
event: singleEventList[0],
index: 0,
isPending: true,
};
const state = loadRoll(singleEventList, now);
expect(state).toStrictEqual(expected);
});
it('should account for a rundown that goes through midnight', () => {
const now = 1 * MILLIS_PER_HOUR;
const singleEventList = [
makeOntimeEvent({
id: '1',
timeStart: 10 * MILLIS_PER_HOUR,
timeEnd: 2 * MILLIS_PER_HOUR,
isPublic: true,
}),
];
const expected = {
event: singleEventList[0],
index: 0,
};
const state = loadRoll(singleEventList, now);
expect(state.isPending).toBeUndefined();
expect(state).toStrictEqual(expected);
});
it('loads upcoming event while waiting to roll', () => {
const now = 6000; // 00:01
const singleEventList = [
makeOntimeEvent({
id: '1',
timeStart: 72000000, // 20:00
timeEnd: 72010000, // 20:10
isPublic: true,
}),
];
const expected = {
event: singleEventList[0],
index: 0,
isPending: true,
};
const state = loadRoll(singleEventList, now);
expect(state).toStrictEqual(expected);
});
});
describe('loadRoll() test that roll behaviour with overlapping times', () => {
const eventlist = [
{
id: '1',
timeStart: 10,
timeEnd: 10,
isPublic: false,
},
{
id: '2',
timeStart: 10,
timeEnd: 20,
isPublic: true,
},
{
id: '3',
timeStart: 10,
timeEnd: 30,
isPublic: false,
},
];
const timedEvents = prepareTimedEvents(eventlist);
it('if timer is at 0', () => {
const now = 0;
const expected = {
event: timedEvents[0],
index: 0,
isPending: true,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 10, it ignores events with 0 duration', () => {
const now = 10;
const expected = {
event: timedEvents[1],
index: 1,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 15', () => {
const now = 15;
const expected = {
event: timedEvents[1],
index: 1,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 20', () => {
const now = 20;
const expected = {
event: timedEvents[2],
index: 2,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 25', () => {
const now = 25;
const expected = {
event: timedEvents[2],
index: 2,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
});
// issue #58
describe('loadRoll() test that roll behaviour multi day event edge cases', () => {
it('should recognise a playing event where its schedule spans over midnight', () => {
const now = 66600000; // 19:30
const eventlist = [
makeOntimeEvent({
id: '1',
timeStart: 66000000, // 19:20
timeEnd: 54600000, // 16:10
isPublic: false,
}),
];
const expected = {
event: eventlist[0],
index: 0,
};
const state = loadRoll(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if the start time is the day after end time, and both are later than now', () => {
const now = 66840000; // 19:34
const eventlist = [
makeOntimeEvent({
id: '1',
timeStart: 67200000, // 19:40
timeEnd: 66900000, // 19:35
isPublic: false,
}),
];
const expected = {
event: eventlist[0],
index: 0,
};
const state = loadRoll(eventlist, now);
expect(state).toStrictEqual(expected);
});
});
@@ -1,9 +1,10 @@
import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils';
import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { EndAction, OntimeEvent, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import {
getCurrent,
getExpectedFinish,
getRollTimers,
getRuntimeOffset,
getTimerPhase,
getTotalDuration,
@@ -695,6 +696,520 @@ describe('skippedOutOfEvent()', () => {
});
});
describe('getRollTimers()', () => {
const eventlist: Partial<OntimeEvent>[] = [
{
id: '1',
timeStart: 5,
timeEnd: 10,
isPublic: false,
},
{
id: '2',
timeStart: 10,
timeEnd: 20,
isPublic: false,
},
{
id: '3',
timeStart: 20,
timeEnd: 30,
isPublic: false,
},
{
id: '4',
timeStart: 30,
timeEnd: 40,
isPublic: false,
},
{
id: '5',
timeStart: 40,
timeEnd: 50,
isPublic: true,
},
{
id: '6',
timeStart: 50,
timeEnd: 60,
isPublic: false,
},
{
id: '7',
timeStart: 60,
timeEnd: 70,
isPublic: true,
},
{
id: '8',
timeStart: 70,
timeEnd: 80,
isPublic: false,
},
];
it('if timer is at 0', () => {
const now = 0;
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 4,
timeToNext: 5,
nextEvent: eventlist[0],
nextPublicEvent: eventlist[4],
currentEvent: null,
currentPublicEvent: null,
};
const state = getRollTimers(eventlist as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 5', () => {
const now = 5;
const expected = {
nowIndex: 0,
nowId: eventlist[0].id,
publicIndex: null,
nextIndex: 1,
publicNextIndex: 4,
timeToNext: 5,
nextEvent: eventlist[1],
nextPublicEvent: eventlist[4],
currentEvent: eventlist[0],
currentPublicEvent: null,
};
const state = getRollTimers(eventlist as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 15', () => {
const now = 15;
const expected = {
nowIndex: 1,
nowId: eventlist[1].id,
publicIndex: null,
nextIndex: 2,
publicNextIndex: 4,
timeToNext: 5,
nextEvent: eventlist[2],
nextPublicEvent: eventlist[4],
currentEvent: eventlist[1],
currentPublicEvent: null,
};
const state = getRollTimers(eventlist as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 20', () => {
const now = 20;
const expected = {
nowIndex: 2,
nowId: eventlist[2].id,
publicIndex: null,
nextIndex: 3,
publicNextIndex: 4,
timeToNext: 10,
nextEvent: eventlist[3],
nextPublicEvent: eventlist[4],
currentEvent: eventlist[2],
currentPublicEvent: null,
};
const state = getRollTimers(eventlist as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 49', () => {
const now = 49;
const expected = {
nowIndex: 4,
nowId: eventlist[4].id,
publicIndex: 4,
nextIndex: 5,
publicNextIndex: 6,
timeToNext: 1,
nextEvent: eventlist[5],
nextPublicEvent: eventlist[6],
currentEvent: eventlist[4],
currentPublicEvent: eventlist[4],
};
const state = getRollTimers(eventlist as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 63', () => {
const now = 63;
const expected = {
nowIndex: 6,
nowId: eventlist[6].id,
publicIndex: 6,
nextIndex: 7,
publicNextIndex: null,
timeToNext: 7,
nextEvent: eventlist[7],
nextPublicEvent: null,
currentEvent: eventlist[6],
currentPublicEvent: eventlist[6],
};
const state = getRollTimers(eventlist as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 75', () => {
const now = 75;
const expected = {
nowIndex: 7,
nowId: eventlist[7].id,
publicIndex: 6,
nextIndex: null,
publicNextIndex: null,
timeToNext: null,
nextEvent: null,
nextPublicEvent: null,
currentEvent: eventlist[7],
currentPublicEvent: eventlist[6],
};
const state = getRollTimers(eventlist as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 100 we roll to day after', () => {
const now = 100;
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 4,
timeToNext: dayInMs - now + eventlist[0].timeStart!,
nextEvent: eventlist[0],
nextPublicEvent: eventlist[4],
currentEvent: null,
currentPublicEvent: null,
};
const state = getRollTimers(eventlist as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('handles rolls to next day with real values', () => {
const singleEventList: Partial<OntimeEvent>[] = [
{
id: '1',
timeStart: 36000000, // 10:00
timeEnd: 39600000, // 11:00
isPublic: true,
},
];
const now = 64800000; // 18:00
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 0,
timeToNext: dayInMs - now + singleEventList[0].timeStart!,
nextEvent: singleEventList[0],
nextPublicEvent: singleEventList[0],
currentEvent: null,
currentPublicEvent: null,
};
const state = getRollTimers(singleEventList as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('handles rolls to next day with real values', () => {
const singleEventList: Partial<OntimeEvent>[] = [
{
id: '1',
timeStart: 36000000, // 10:00
timeEnd: 3600000, // 01:00
isPublic: true,
},
];
const now = 60000; // 00:01
const expected = {
nowIndex: 0,
nowId: singleEventList[0].id,
publicIndex: 0,
nextIndex: null,
publicNextIndex: null,
timeToNext: null,
nextEvent: null,
nextPublicEvent: null,
currentEvent: singleEventList[0],
currentPublicEvent: singleEventList[0],
};
const state = getRollTimers(singleEventList as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('handles rolls to next day with real values', () => {
const singleEventList: Partial<OntimeEvent>[] = [
{
id: '1',
timeStart: 36000000, // 10:00
timeEnd: 3600000, // 01:00
isPublic: true,
},
];
const now = 60000; // 00:01
const expected = {
nowIndex: 0,
nowId: singleEventList[0].id,
publicIndex: 0,
nextIndex: null,
publicNextIndex: null,
timeToNext: null,
nextEvent: null,
nextPublicEvent: null,
currentEvent: singleEventList[0],
currentPublicEvent: singleEventList[0],
};
const state = getRollTimers(singleEventList as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('loads upcoming event while waiting to roll', () => {
const singleEventList: Partial<OntimeEvent>[] = [
{
id: '1',
timeStart: 72000000, // 20:00
timeEnd: 72010000, // 20:10
isPublic: true,
},
];
const now = 6000; // 00:01
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 0,
timeToNext: 72000000 - now,
nextEvent: singleEventList[0],
nextPublicEvent: singleEventList[0],
currentEvent: null,
currentPublicEvent: null,
};
const state = getRollTimers(singleEventList as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('handles roll that goes over midnight', () => {
const singleEventList: Partial<OntimeEvent>[] = [
{
id: '1',
timeStart: 72000000, // 20:00
timeEnd: 60000, // 00:10
isPublic: true,
},
];
const now = 6000; // 00:01
const expected = {
nowIndex: 0,
nowId: singleEventList[0].id,
publicIndex: 0,
nextIndex: null,
publicNextIndex: null,
timeToNext: null,
nextEvent: null,
nextPublicEvent: null,
currentEvent: singleEventList[0],
currentPublicEvent: singleEventList[0],
};
const state = getRollTimers(singleEventList as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
});
describe('getRollTimers() test that roll behaviour with overlapping times', () => {
const eventlist: Partial<OntimeEvent>[] = [
{
id: '1',
timeStart: 10,
timeEnd: 10,
isPublic: false,
},
{
id: '2',
timeStart: 10,
timeEnd: 20,
isPublic: true,
},
{
id: '3',
timeStart: 10,
timeEnd: 30,
isPublic: false,
},
];
it('if timer is at 0', () => {
const now = 0;
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 1,
timeToNext: 10,
nextEvent: eventlist[0],
nextPublicEvent: eventlist[1],
currentEvent: null,
currentPublicEvent: null,
};
const state = getRollTimers(eventlist as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 10', () => {
const now = 10;
const expected = {
nowIndex: 1,
nowId: eventlist[1].id,
publicIndex: 1,
nextIndex: 2,
publicNextIndex: null,
timeToNext: 0,
nextEvent: eventlist[2],
nextPublicEvent: null,
currentEvent: eventlist[1],
currentPublicEvent: eventlist[1],
};
const state = getRollTimers(eventlist as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 15', () => {
const now = 15;
const expected = {
nowIndex: 1,
nowId: eventlist[1].id,
publicIndex: 1,
nextIndex: 2,
publicNextIndex: null,
timeToNext: -5,
nextEvent: eventlist[2],
nextPublicEvent: null,
currentEvent: eventlist[1],
currentPublicEvent: eventlist[1],
};
const state = getRollTimers(eventlist as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 20', () => {
const now = 20;
const expected = {
nowIndex: 2,
nowId: eventlist[2].id,
publicIndex: 1,
nextIndex: null,
publicNextIndex: null,
timeToNext: null,
nextEvent: null,
nextPublicEvent: null,
currentEvent: eventlist[2],
currentPublicEvent: eventlist[1],
};
const state = getRollTimers(eventlist as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 25', () => {
const now = 25;
const expected = {
nowIndex: 2,
nowId: eventlist[2].id,
publicIndex: 1,
nextIndex: null,
publicNextIndex: null,
timeToNext: null,
nextEvent: null,
nextPublicEvent: null,
currentEvent: eventlist[2],
currentPublicEvent: eventlist[1],
};
const state = getRollTimers(eventlist as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
});
// issue #58
describe('getRollTimers() test that roll behaviour multi day event edge cases', () => {
it('if the start time is the day after end time, and start time is earlier than now', () => {
const now = 66600000; // 19:30
const eventlist: Partial<OntimeEvent>[] = [
{
id: '1',
timeStart: 66000000, // 19:20
timeEnd: 54600000, // 16:10
isPublic: false,
},
];
const expected = {
nowIndex: 0,
nowId: '1',
publicIndex: null,
nextIndex: null,
publicNextIndex: null,
timeToNext: null,
nextEvent: null,
nextPublicEvent: null,
currentEvent: eventlist[0],
currentPublicEvent: null,
};
const state = getRollTimers(eventlist as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('if the start time is the day after end time, and both are later than now', () => {
const now = 66840000; // 19:34
const eventlist: Partial<OntimeEvent>[] = [
{
id: '1',
timeStart: 67200000, // 19:40
timeEnd: 66900000, // 19:35
isPublic: false,
},
];
const expected = {
currentEvent: {
id: '1',
isPublic: false,
timeEnd: 66900000,
timeStart: 67200000,
},
currentPublicEvent: null,
nextEvent: null,
nextIndex: null,
nextPublicEvent: null,
nowId: '1',
nowIndex: 0,
publicIndex: null,
publicNextIndex: null,
timeToNext: null,
};
const state = getRollTimers(eventlist as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
});
test('normaliseEndTime()', () => {
const t1 = {
start: 10,
-93
View File
@@ -1,93 +0,0 @@
import { dayInMs, getFirstEvent, getLastEvent } from 'ontime-utils';
import { OntimeEvent, MaybeNumber, PlayableEvent, isPlayableEvent } from 'ontime-types';
import { normaliseEndTime } from './timerUtils.js';
/**
* Finds current event in a rolling rundown
*/
export function loadRoll(
timedEvents: OntimeEvent[],
timeNow: number,
): {
event: PlayableEvent | null;
index: MaybeNumber;
isPending?: boolean;
} {
const { firstEvent } = getFirstEvent(timedEvents);
const { lastEvent } = getLastEvent(timedEvents);
if (!firstEvent || !lastEvent) {
return { event: null, index: null };
}
// check that the rundown wraps around midnight
const wrapsAroundMidnight = firstEvent.timeStart > lastEvent.timeEnd;
if (!wrapsAroundMidnight) {
// check whether we are before or after the rundown
const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd);
const isAfterRundown = timeNow > lastNormalEnd;
const isBeforeRundown = timeNow < firstEvent.timeStart && !isAfterRundown;
if (isAfterRundown || isBeforeRundown) {
return { event: firstEvent, index: 0, isPending: true };
}
}
// we know we are in the middle of the rundown and we need to find the current event
// account for number of times we went over midnight
let daySpan = 0;
for (let i = 0; i < timedEvents.length; i++) {
const event = timedEvents[i];
if (!isPlayableEvent(event)) {
continue;
}
// we check if event crosses midnight
if (event.timeStart > event.timeEnd) {
daySpan++;
}
const correctedDays = dayInMs * daySpan;
const correctedStart = event.timeStart + correctedDays;
const correctedEnd = event.timeEnd + correctedDays;
/**
* there are 3 possible states for an event
* 1. event is already finished
* 2. event is running
* 3. event is in the future
*/
// 1. event is already finished
// when does the event end (handle midnight)
const normalEnd = normaliseEndTime(correctedStart, correctedEnd);
if (normalEnd <= timeNow) {
continue;
}
// 2. event is running and is the first event in our time slot
const isFromDayBefore = normalEnd > dayInMs && timeNow < event.timeEnd;
const hasStarted = isFromDayBefore || timeNow >= event.timeStart;
if (hasStarted) {
return { event, index: i };
}
// 3. event will run in the future
// we set the isPending flag to indicate that the event is currently playing
return { event, index: i, isPending: true };
}
// in case we were unable to find anything, we load the first event
return { event: firstEvent, index: 0, isPending: true };
}
/**
* Utility function, checks whether the event start is the day after
*/
export function normaliseRollStart(start: number, clock: number) {
return start < clock ? start + dayInMs : start;
}
@@ -20,19 +20,18 @@ import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../runtime-service/RuntimeService.js';
import * as cache from './rundownCache.js';
import { getPlayableEvents, getTimedEvents } from './rundownUtils.js';
import { getPlayableEvents } from './rundownUtils.js';
import { eventStore } from '../../stores/EventStore.js';
type PatchWithId = (Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) & { id: string };
type CompleteEntry<T> =
T extends Partial<OntimeEvent>
? OntimeEvent
: T extends Partial<OntimeDelay>
? OntimeDelay
: T extends Partial<OntimeBlock>
? OntimeBlock
: never;
type CompleteEntry<T> = T extends Partial<OntimeEvent>
? OntimeEvent
: T extends Partial<OntimeDelay>
? OntimeDelay
: T extends Partial<OntimeBlock>
? OntimeBlock
: never;
function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>>(
eventData: T,
@@ -215,8 +214,8 @@ export async function swapEvents(from: string, to: string) {
* Called when we make changes to the rundown object
*/
function updateRuntimeOnChange() {
const timedEvents = getTimedEvents();
const numEvents = timedEvents.length;
const playableEvents = getPlayableEvents();
const numEvents = playableEvents.length;
const metadata = cache.getMetadata();
// schedule an update for the end of the event loop
@@ -241,7 +240,7 @@ function notifyChanges(options: { timer?: boolean | string[]; external?: boolean
// notify timer service of changed events
// timer can be true or an array of changed IDs
const affected = Array.isArray(options.timer) ? options.timer : undefined;
runtimeService.notifyOfChangedEvents(affected);
runtimeService.maybeUpdate(playableEvents, affected);
}
}
@@ -10,7 +10,7 @@ import {
TimeStrategy,
TimerType,
} from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils';
import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils';
import { calculateRuntimeDelays, getDelayAt, calculateRuntimeDelaysFrom } from '../delayUtils.js';
import {
@@ -70,13 +70,13 @@ describe('generate()', () => {
it('accounts for gaps in rundown when calculating delays', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200 } as OntimeEvent,
{ type: SupportedEvent.Delay, id: 'delay', duration: 200 } as OntimeDelay,
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300 } as OntimeEvent,
{ type: SupportedEvent.Block, id: 'block', title: 'break' } as OntimeBlock,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500 } as OntimeEvent,
{ type: SupportedEvent.Block, id: 'another-block', title: 'another-break' } as OntimeBlock,
{ type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700 } as OntimeEvent,
];
const initResult = generate(testRundown);
@@ -89,74 +89,15 @@ describe('generate()', () => {
expect(initResult.totalDuration).toBe(700 - 100);
});
it('accounts for overlaps in rundown', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 } as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.totalDuration).toBe(10500 - 9000); // last end - first start
});
it('accounts for overlaps in rundown (with added gap)', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '4', timeStart: 15000, timeEnd: 20000, duration: 5000 } as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.totalDuration).toBe(20000 - 9000); // last end - first start
});
it('accounts for overlaps in rundown (with multiple days)', () => {
const testRundown: OntimeRundown = [
{
type: SupportedEvent.Event,
id: '1',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 10 * MILLIS_PER_HOUR,
duration: MILLIS_PER_HOUR,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '2',
timeStart: 9 * MILLIS_PER_HOUR + 15 * MILLIS_PER_MINUTE,
timeEnd: 9 * MILLIS_PER_HOUR + 45 * MILLIS_PER_MINUTE,
duration: 30 * MILLIS_PER_MINUTE,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '3',
timeStart: 9 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE,
timeEnd: 10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE,
duration: MILLIS_PER_HOUR,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '4',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 10 * MILLIS_PER_HOUR,
duration: MILLIS_PER_HOUR,
} as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.totalDuration).toBe(dayInMs + MILLIS_PER_HOUR); // day + last end - first start
});
it('handles negative delays', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200 } as OntimeEvent,
{ type: SupportedEvent.Delay, id: 'delay', duration: -200 } as OntimeDelay,
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300 } as OntimeEvent,
{ type: SupportedEvent.Block, id: 'block', title: 'break' } as OntimeBlock,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500 } as OntimeEvent,
{ type: SupportedEvent.Block, id: 'another-block', title: 'another-break' } as OntimeBlock,
{ type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700 } as OntimeEvent,
];
const initResult = generate(testRundown);
@@ -231,17 +172,10 @@ describe('generate()', () => {
it('calculates total duration', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent,
{
type: SupportedEvent.Event,
id: 'skipped',
skip: true,
timeStart: 300,
timeEnd: 400,
duration: 100,
} as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300 } as OntimeEvent,
{ type: SupportedEvent.Event, id: 'skipped', skip: true, timeStart: 300, timeEnd: 400 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500 } as OntimeEvent,
];
const initResult = generate(testRundown);
@@ -252,16 +186,9 @@ describe('generate()', () => {
it('calculates total duration with 0 duration events without causing a next day', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 100, duration: 0 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 100, timeEnd: 300, duration: 200 } as OntimeEvent,
{
type: SupportedEvent.Event,
id: 'skipped',
skip: true,
timeStart: 300,
timeEnd: 400,
duration: 0,
} as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 100, timeEnd: 300 } as OntimeEvent,
{ type: SupportedEvent.Event, id: 'skipped', skip: true, timeStart: 300, timeEnd: 400 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500 } as OntimeEvent,
];
const initResult = generate(testRundown);
@@ -274,28 +201,27 @@ describe('generate()', () => {
{
type: SupportedEvent.Event,
id: '1',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 23 * MILLIS_PER_HOUR,
duration: (23 - 9) * MILLIS_PER_HOUR,
timeStart: new Date(0).setHours(9),
timeEnd: new Date(0).setHours(23),
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '2',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 23 * MILLIS_PER_HOUR,
duration: (23 - 9) * MILLIS_PER_HOUR,
timeStart: new Date(0).setHours(9),
timeEnd: new Date(0).setHours(23),
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '3',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 23 * MILLIS_PER_HOUR,
duration: (23 - 9) * MILLIS_PER_HOUR,
timeStart: new Date(0).setHours(9),
timeEnd: new Date(0).setHours(23),
} as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.totalDuration).toBe((23 - 9 + 48) * MILLIS_PER_HOUR);
const expectedDuration = (23 - 9 + 48) * MILLIS_PER_HOUR;
expect(millisToString(initResult.totalDuration)).toBe('62:00:00');
expect(initResult.totalDuration).toBe(expectedDuration);
});
it('calculates total duration across days', () => {
@@ -303,21 +229,20 @@ describe('generate()', () => {
{
type: SupportedEvent.Event,
id: '1',
timeStart: 12 * MILLIS_PER_HOUR,
timeEnd: 22 * MILLIS_PER_HOUR,
duration: 10 * MILLIS_PER_HOUR,
timeStart: new Date(0).setHours(12),
timeEnd: new Date(0).setHours(22),
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '2',
timeStart: 22 * MILLIS_PER_HOUR,
timeEnd: 8 * MILLIS_PER_HOUR,
duration: (24 - 22 + 8) * MILLIS_PER_HOUR,
timeStart: new Date(0).setHours(22),
timeEnd: new Date(0).setHours(8),
} as OntimeEvent,
];
const initResult = generate(testRundown);
const expectedDuration = 8 * MILLIS_PER_HOUR + (dayInMs - 12 * MILLIS_PER_HOUR);
expect(millisToString(initResult.totalDuration)).toBe('20:00:00');
expect(initResult.totalDuration).toBe(expectedDuration);
});
@@ -421,7 +346,7 @@ describe('generate()', () => {
];
const initResult = generate(testRundown, customProperties);
expect(initResult.order.length).toBe(2);
expect(initResult.assignedCustomFields).toMatchObject({
expect(initResult.assignedCustomProperties).toMatchObject({
lighting: ['1', '2'],
sound: ['2'],
});
@@ -557,6 +482,20 @@ describe('swap() mutation', () => {
});
});
/**
*
*
*
*
*
*
*
*
*
*
*
*/
describe('calculateRuntimeDelays', () => {
it('calculates all delays in a given rundown', () => {
const rundown: OntimeRundown = [
@@ -4,16 +4,16 @@ import {
CustomFields,
isOntimeDelay,
isOntimeEvent,
isPlayableEvent,
MaybeNumber,
OntimeEvent,
OntimeRundown,
OntimeRundownEntry,
PlayableEvent,
} from 'ontime-types';
import { generateId, insertAtIndex, reorderArray, swapEventData, getTimeFromPrevious, isNewLatest } from 'ontime-utils';
import { generateId, insertAtIndex, reorderArray, swapEventData, checkIsNextDay } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { createPatch } from '../../utils/parser.js';
import { getTotalDuration } from '../timerUtils.js';
import { apply } from './delayUtils.js';
import { handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js';
@@ -83,78 +83,74 @@ export function generate(
totalDuration = 0;
totalDelay = 0;
let lastEntry: PlayableEvent | null = null;
let accumulatedDelay = 0;
let daySpan = 0;
let previousStart: MaybeNumber = null;
let previousEnd: MaybeNumber = null;
let previousDuration: MaybeNumber = null;
for (let i = 0; i < initialRundown.length; i++) {
// TODO: filter properties that should not be persisted (eg: delay)
// we assign a reference to the current entry, this will be mutated in place
const currentEntry = initialRundown[i];
const currentEvent = initialRundown[i];
const updatedEvent = { ...currentEvent };
if (isOntimeEvent(currentEntry)) {
// 1. handle links - mutates updatedEvent
handleLink(i, initialRundown, currentEntry, links);
if (isOntimeEvent(updatedEvent)) {
// 1. handle links
handleLink(i, initialRundown, updatedEvent, links);
// 2. handle custom fields - mutates updatedEvent
handleCustomField(customFields, customFieldChangelog, currentEntry, assignedCustomFields);
// 2. handle custom fields
handleCustomField(customFields, customFieldChangelog, updatedEvent, assignedCustomFields);
// update rundown metadata, it only concerns playable events
if (isPlayableEvent(currentEntry)) {
// fist start is always the first event
// update the persisted event
initialRundown[i] = updatedEvent;
// we need to generate the skip event, but dont want to use its times
if (!updatedEvent.skip) {
// update rundown duration
if (firstStart === null) {
firstStart = currentEntry.timeStart;
firstStart = updatedEvent.timeStart;
}
lastEnd = updatedEvent.timeEnd;
const timeFromPrevious: number = getTimeFromPrevious(
currentEntry.timeStart,
lastEntry?.timeStart,
lastEntry?.timeEnd,
lastEntry?.duration,
);
if (timeFromPrevious === 0) {
// event starts on previous finish, we add its duration
totalDuration += currentEntry.duration;
} else if (timeFromPrevious > 0) {
// event has a gap, we add the gap and the duration
totalDuration += timeFromPrevious + currentEntry.duration;
} else if (timeFromPrevious < 0) {
// there is an overlap, we remove the overlap from the duration
// ensuring that the sum is not negative (ie: fully overlapped events)
// NOTE: we add the gap since it is a negative number
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
}
// remove eventual gaps from the accumulated delay
// we only affect positive delays (time forwards)
if (totalDelay > 0 && timeFromPrevious > 0) {
totalDelay = Math.max(totalDelay - timeFromPrevious, 0);
}
// current event delay is the current accumulated delay
currentEntry.delay = totalDelay;
// lastEntry is the event with the latest end time
if (isNewLatest(currentEntry.timeStart, currentEntry.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
lastEntry = currentEntry;
// check if we go over midnight, account for eventual gaps
const gapOverMidnight =
previousStart !== null && checkIsNextDay(previousStart, updatedEvent.timeStart, previousDuration);
const durationOverMidnight = updatedEvent.timeStart > updatedEvent.timeEnd;
if (gapOverMidnight || durationOverMidnight) {
daySpan++;
}
}
}
// calculate delays
// !!! this must happen after handling the links
if (isOntimeDelay(currentEntry)) {
totalDelay += currentEntry.duration;
if (isOntimeDelay(updatedEvent)) {
accumulatedDelay += updatedEvent.duration;
} else if (isOntimeEvent(updatedEvent) && !updatedEvent.skip) {
const eventStart = updatedEvent.timeStart;
// we only affect positive delays (time forwards)
if (accumulatedDelay > 0 && previousEnd) {
const gap = Math.max(eventStart - previousEnd, 0);
accumulatedDelay = Math.max(accumulatedDelay - gap, 0);
}
updatedEvent.delay = accumulatedDelay;
previousStart = updatedEvent.timeStart;
previousEnd = updatedEvent.timeEnd;
previousDuration = updatedEvent.duration;
}
// add id to order
order.push(currentEntry.id);
// add entry to rundown
rundown[currentEntry.id] = currentEntry;
order.push(updatedEvent.id);
rundown[updatedEvent.id] = { ...updatedEvent };
}
lastEnd = lastEntry?.timeEnd ?? null;
isStale = false;
customFieldChangelog.clear();
return { rundown, order, links, totalDelay, totalDuration, assignedCustomFields };
totalDelay = accumulatedDelay;
if (lastEnd !== null && firstStart !== null) {
totalDuration = getTotalDuration(firstStart, lastEnd, daySpan);
}
return { rundown, order, links, totalDelay, totalDuration, assignedCustomProperties: assignedCustomFields };
}
/** Returns an ID guaranteed to be unique */
@@ -1,17 +1,14 @@
import { OntimeEvent, OntimeRundown, RundownCached, OntimeRundownEntry, PlayableEvent } from 'ontime-types';
import { filterPlayable, filterTimedEvents } from 'ontime-utils';
import { OntimeEvent, OntimeRundown, isOntimeEvent, RundownCached, OntimeRundownEntry } from 'ontime-types';
import * as cache from './rundownCache.js';
/**
* returns the normalised rundown
*/
export function getNormalisedRundown(): RundownCached {
return cache.get();
}
/**
* returns entire unfiltered rundown
* @return {array}
*/
export function getRundown(): OntimeRundown {
return cache.getPersistedRundown();
@@ -19,20 +16,32 @@ export function getRundown(): OntimeRundown {
/**
* returns all events of type OntimeEvent
* @return {array}
*/
export function getTimedEvents(): OntimeEvent[] {
return filterTimedEvents(getRundown());
return getRundown().filter((event) => isOntimeEvent(event)) as OntimeEvent[];
}
/**
* returns all events that can be loaded
* @return {array}
*/
export function getPlayableEvents(): PlayableEvent[] {
return filterPlayable(getRundown());
export function getPlayableEvents(): OntimeEvent[] {
return getRundown().filter((event) => isOntimeEvent(event) && !event.skip) as OntimeEvent[];
}
/**
* returns number of events that can be loaded
* @return {number}
*/
export function getNumEvents(): number {
return getPlayableEvents().length;
}
/**
* returns an event given its index after filtering for OntimeEvents
* @param {number} eventIndex
* @return {OntimeEvent | undefined}
*/
export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined {
const timedEvents = getTimedEvents();
@@ -41,6 +50,8 @@ export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined {
/**
* returns first event that matches a given ID
* @param {string} eventId
* @return {object | undefined}
*/
export function getEventWithId(eventId: string): OntimeRundownEntry | undefined {
const rundown = getRundown();
@@ -49,14 +60,17 @@ export function getEventWithId(eventId: string): OntimeRundownEntry | undefined
/**
* returns first event that matches a given cue
* @param {string} targetCue
* @param {number} currentEventIndex
* @return {object | undefined}
*/
export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): OntimeEvent | undefined {
const playableEvents = getPlayableEvents();
const timedEvents = getPlayableEvents();
const lowerCaseCue = targetCue.toLowerCase();
for (let i = currentEventIndex; i < playableEvents.length; i++) {
const event = playableEvents.at(i);
if (event?.cue.toLowerCase() === lowerCaseCue) {
for (let i = currentEventIndex; i < timedEvents.length; i++) {
const event = timedEvents.at(i);
if (event && event.cue.toLowerCase() === lowerCaseCue) {
return event;
}
}
@@ -64,41 +78,43 @@ export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): O
/**
* finds the previous event
* @return {object | undefined}
*/
export function findPrevious(currentEventId?: string): OntimeEvent | null {
const playableEvents = getPlayableEvents();
if (!playableEvents || !playableEvents.length) {
const timedEvents = getPlayableEvents();
if (!timedEvents || !timedEvents.length) {
return null;
}
// if there is no event running, go to first
if (!currentEventId) {
return playableEvents.at(0) ?? null;
return timedEvents.at(0) ?? null;
}
const currentIndex = playableEvents.findIndex((event) => event.id === currentEventId);
const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId);
const newIndex = Math.max(currentIndex - 1, 0);
const previousEvent = playableEvents.at(newIndex) ?? null;
const previousEvent = timedEvents.at(newIndex) ?? null;
return previousEvent;
}
/**
* finds the next event
* @return {object | undefined}
*/
export function findNext(currentEventId?: string): PlayableEvent | null {
const playableEvents = getPlayableEvents();
if (!playableEvents.length) {
export function findNext(currentEventId?: string): OntimeEvent | null {
const timedEvents = getPlayableEvents();
if (!timedEvents || !timedEvents.length) {
return null;
}
// if there is no event running, go to first
if (!currentEventId) {
return playableEvents.at(0) ?? null;
return timedEvents.at(0) ?? null;
}
const currentIndex = playableEvents.findIndex((event) => event.id === currentEventId);
const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId);
const newIndex = currentIndex + 1;
const nextEvent = playableEvents.at(newIndex);
const nextEvent = timedEvents.at(newIndex);
return nextEvent ?? null;
}
@@ -1,7 +1,6 @@
import {
EndAction,
isOntimeEvent,
isPlayableEvent,
LogOrigin,
MaybeNumber,
OntimeEvent,
@@ -9,7 +8,6 @@ import {
RuntimeStore,
TimerLifeCycle,
TimerPhase,
TimerState,
} from 'ontime-types';
import { millisToString, validatePlayback } from 'ontime-utils';
@@ -21,7 +19,7 @@ import type { RuntimeState } from '../../stores/runtimeState.js';
import { timerConfig } from '../../config/config.js';
import { eventStore } from '../../stores/EventStore.js';
import { EventTimer } from '../EventTimer.js';
import { TimerService } from '../TimerService.js';
import { RestorePoint, restoreService } from '../RestoreService.js';
import {
findNext,
@@ -29,20 +27,19 @@ import {
getEventAtIndex,
getNextEventWithCue,
getEventWithId,
getRundown,
getTimedEvents,
getPlayableEvents,
} from '../rundown-service/rundownUtils.js';
import { skippedOutOfEvent } from '../timerUtils.js';
import { integrationService } from '../integration-service/IntegrationService.js';
import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js';
import { skippedOutOfEvent } from '../timerUtils.js';
/**
* Service manages runtime status of app
* Coordinating with necessary services
*/
class RuntimeService {
private eventTimer: EventTimer;
private eventTimer: TimerService;
private lastIntegrationClockUpdate = -1;
private lastIntegrationTimerValue = -1;
@@ -54,8 +51,8 @@ class RuntimeService {
/** last known state */
static previousState: RuntimeState;
constructor(eventTimer: EventTimer) {
this.eventTimer = eventTimer;
constructor(timerService: TimerService) {
this.eventTimer = timerService;
RuntimeService.previousTimerUpdate = -1;
RuntimeService.previousTimerValue = -1;
@@ -63,13 +60,11 @@ class RuntimeService {
RuntimeService.previousState = {} as RuntimeState;
}
/**
* Checks result of an update and notifies integrations as needed
* This is the only exception of a private method that has broadcast result
* */
/** Checks result of an update and notifies integrations as needed */
@broadcastResult
private checkTimerUpdate({ hasTimerFinished, hasSecondaryTimerFinished }: runtimeState.UpdateResult) {
checkTimerUpdate({ shouldCallRoll, hasTimerFinished }: runtimeState.UpdateResult) {
const newState = runtimeState.getState();
// 1. find if we need to dispatch integrations related to the phase
const timerPhaseChanged = RuntimeService.previousState.timer?.phase !== newState.timer.phase;
if (timerPhaseChanged) {
@@ -86,36 +81,35 @@ class RuntimeService {
// 2. handle edge cases related to roll
if (newState.timer.playback === Playback.Roll) {
// check if we need to call any side effects
const keepOffset = newState.runtime.offset;
if (hasSecondaryTimerFinished) {
// if the secondary timer has finished, we need to call roll
// since event is already loaded
this.rollLoaded(keepOffset);
} else if (hasTimerFinished) {
// if the timer has finished, we need to load next and keep rolling
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onFinish);
});
this.handleLoadNext();
this.rollLoaded(keepOffset);
} else if (skippedOutOfEvent(newState, this.lastIntegrationClockUpdate, timerConfig.skipLimit)) {
// if we have skipped out of the event, we will recall roll
// to push the playback to the right place
// this comes with the caveat that we will lose our runtime data
this.roll(true);
// check if we need to call roll again
const needsEvent =
newState.eventNow === null
? true
: skippedOutOfEvent(newState, this.lastIntegrationClockUpdate, timerConfig.skipLimit);
const hasFinishedRoll = hasTimerFinished && shouldCallRoll;
if (shouldCallRoll || needsEvent) {
if (hasFinishedRoll) {
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onFinish);
});
}
// we dont call this.roll because we need to bypass the checks
const rundown = getPlayableEvents();
// TODO: by not calling roll, we dont get the events
this.eventTimer.roll(rundown);
}
}
// 3. find if we need to process actions related to the timer finishing
if (newState.timer.playback === Playback.Play && hasTimerFinished) {
if (newState.timer.playback !== Playback.Roll && hasTimerFinished) {
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onFinish);
});
// handle end action if there was a timer playing
// actions are added to the queue stack to ensure that the order of operations is maintained
if (newState.eventNow) {
if (newState.timer.playback === Playback.Play && newState.eventNow) {
if (newState.eventNow.endAction === EndAction.Stop) {
setTimeout(this.stop.bind(this), 0);
} else if (newState.eventNow.endAction === EndAction.LoadNext) {
@@ -148,7 +142,7 @@ class RuntimeService {
}
/** delay initialisation until we have a restore point */
public init(resumable: RestorePoint | null) {
init(resumable: RestorePoint | null) {
logger.info(LogOrigin.Server, 'Runtime service started');
this.eventTimer.setOnUpdateCallback((updateResult) => this.checkTimerUpdate(updateResult));
@@ -157,7 +151,7 @@ class RuntimeService {
}
}
public shutdown() {
shutdown() {
if (this.eventTimer) {
logger.info(LogOrigin.Server, 'Runtime service shutting down');
this.eventTimer.shutdown();
@@ -182,7 +176,7 @@ class RuntimeService {
}
private isNewNext() {
const timedEvents = getTimedEvents();
const timedEvents = getPlayableEvents();
const state = runtimeState.getState();
const now = state.eventNow?.id;
const next = state.eventNext?.id;
@@ -223,7 +217,7 @@ class RuntimeService {
* Called when the underlying data has changed,
* we check if the change affects the runtime
*/
public notifyOfChangedEvents(affectedIds?: string[]) {
maybeUpdate(playableEvents: OntimeEvent[], affectedIds?: string[]) {
const state = runtimeState.getState();
const hasLoadedElements = state.eventNow !== null || state.eventNext !== null;
if (!hasLoadedElements) {
@@ -234,55 +228,49 @@ class RuntimeService {
// 1. we are not confident that changes do not affect running event (eg. all events where changed)
const safeOption = typeof affectedIds === 'undefined';
// 2. the edited event is in memory (now or next) running
// behind conditional to avoid doing unnecessary work
const eventInMemory = safeOption ? false : this.affectsLoaded(affectedIds);
// 3. the edited event replaces next event
let isNext = false;
// if we are not sure, or the event is in memory, we reload
if (safeOption || eventInMemory) {
if (state.eventNow !== null) {
// load stuff again, but keep running if our events still exist
const eventNow = getEventWithId(state.eventNow.id);
if (!isOntimeEvent(eventNow) || !isPlayableEvent(eventNow)) {
// maybe the event was deleted or the skip state was changed
runtimeState.stop();
return;
}
const onlyChangedNow = affectedIds?.length === 1 && affectedIds.at(0) === eventNow.id;
if (onlyChangedNow) {
runtimeState.updateLoaded(eventNow);
} else {
const rundown = getRundown();
runtimeState.updateAll(rundown);
}
if (state.timer.playback === Playback.Roll) {
this.roll();
}
// load stuff again, but keep running if our events still exist
const eventNow = getEventWithId(state.eventNow.id);
if (!isOntimeEvent(eventNow)) {
return;
}
const onlyChangedNow = affectedIds?.length === 1 && affectedIds.at(0) === eventNow.id;
if (onlyChangedNow) {
runtimeState.reload(eventNow);
} else {
runtimeState.reloadAll(eventNow, playableEvents);
}
return;
}
// Maybe the event will become the next
isNext = this.isNewNext();
if (isNext) {
const timedEvents = getTimedEvents();
runtimeState.loadNext(timedEvents);
runtimeState.loadNext(playableEvents);
}
}
/**
* makes calls for loading and starting given event
* @param {PlayableEvent} event
* @param {Partial<TimerState & RestorePoint>} initialData
* @param {OntimeEvent} event
* @return {boolean} success - whether an event was loaded
*/
private loadEvent(event: OntimeEvent, initialData?: Partial<TimerState & RestorePoint>): boolean {
if (!isPlayableEvent(event)) {
@broadcastResult
loadEvent(event: OntimeEvent): boolean {
if (event.skip) {
logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`);
return false;
}
const rundown = getRundown();
const success = runtimeState.load(event, rundown, initialData);
const timedEvents = getPlayableEvents();
const success = runtimeState.load(event, timedEvents);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
@@ -298,8 +286,7 @@ class RuntimeService {
* @param {string} eventId
* @return {boolean} success - whether an event was started
*/
@broadcastResult
public startById(eventId: string): boolean {
startById(eventId: string): boolean {
const event = getEventWithId(eventId);
if (!event || !isOntimeEvent(event)) {
return false;
@@ -308,7 +295,7 @@ class RuntimeService {
if (!loaded) {
return false;
}
return this.handleStart();
return this.start();
}
/**
@@ -316,8 +303,7 @@ class RuntimeService {
* @param {number} eventIndex
* @return {boolean} success - whether an event was started
*/
@broadcastResult
public startByIndex(eventIndex: number): boolean {
startByIndex(eventIndex: number): boolean {
const event = getEventAtIndex(eventIndex);
if (!event) {
return false;
@@ -326,7 +312,7 @@ class RuntimeService {
if (!loaded) {
return false;
}
return this.handleStart();
return this.start();
}
/**
@@ -334,8 +320,7 @@ class RuntimeService {
* @param {string} cue
* @return {boolean} success - whether an event was started
*/
@broadcastResult
public startByCue(cue: string): boolean {
startByCue(cue: string): boolean {
const event = getNextEventWithCue(cue); //TODO: add index
if (!event) {
return false;
@@ -344,7 +329,7 @@ class RuntimeService {
if (!loaded) {
return false;
}
return this.handleStart();
return this.start();
}
/**
@@ -352,8 +337,7 @@ class RuntimeService {
* @param {string} eventId
* @return {boolean} success - whether an event was loaded
*/
@broadcastResult
public loadById(eventId: string): boolean {
loadById(eventId: string): boolean {
const event = getEventWithId(eventId);
if (!event || !isOntimeEvent(event)) {
return false;
@@ -366,8 +350,7 @@ class RuntimeService {
* @param {number} eventIndex
* @return {boolean} success - whether an event was loaded
*/
@broadcastResult
public loadByIndex(eventIndex: number): boolean {
loadByIndex(eventIndex: number): boolean {
const event = getEventAtIndex(eventIndex);
if (!event) {
return false;
@@ -380,8 +363,7 @@ class RuntimeService {
* @param {string} cue
* @return {boolean} success - whether an event was loaded
*/
@broadcastResult
public loadByCue(cue: string): boolean {
loadByCue(cue: string): boolean {
const event = getNextEventWithCue(cue); //TODO: add index
if (!event) {
return false;
@@ -390,12 +372,10 @@ class RuntimeService {
}
/**
* Contains logic for loading the previous event
*
* we need to isolate handleLoadPrevious so we have control over the side effects
* startSelected being a private function does not trigger emits
* Loads event before currently selected
* @return {boolean} success - whether an event was loaded
*/
private handleLoadPrevious(): boolean {
loadPrevious(): boolean {
const state = runtimeState.getState();
const previousEvent = findPrevious(state.eventNow?.id);
if (previousEvent) {
@@ -405,28 +385,13 @@ class RuntimeService {
}
/**
* Loads event before currently selected
* @return {boolean} success - whether an event was loaded
* Loads event after currently selected
* @return {boolean} success
*/
@broadcastResult
public loadPrevious(): boolean {
return this.handleLoadPrevious();
}
/**
* Contains logic for loading the next event
*
* we need to isolate handleLoadNext so we have control over the side effects
* startSelected being a private function does not trigger emits
* and pass on runtime offset in case of roll mode
*/
private handleLoadNext(): boolean {
loadNext(): boolean {
const state = runtimeState.getState();
const nextEvent = findNext(state.eventNow?.id);
if (nextEvent) {
if (state.timer.playback === Playback.Roll) {
return this.loadEvent(nextEvent, { firstStart: state.runtime.actualStart });
}
return this.loadEvent(nextEvent);
}
@@ -435,31 +400,18 @@ class RuntimeService {
}
/**
* Loads event after currently selected
* @return {boolean} success
* Starts playback on selected event
*/
@broadcastResult
public loadNext(): boolean {
return this.handleLoadNext();
}
/**
* Contains logic for starting selected event
*
* we need to isolate handleStart so we have control over the side effects
* startSelected being a private function does not trigger emits
*/
private handleStart(): boolean {
const previousState = runtimeState.getState();
const canStart = validatePlayback(previousState.timer.playback, previousState.timer.phase).start;
start(): boolean {
const state = runtimeState.getState();
const canStart = validatePlayback(state.timer.playback).start;
if (!canStart) {
return false;
}
const didStart = this.eventTimer?.start() ?? false;
const newState = runtimeState.getState();
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
logger.info(LogOrigin.Playback, `Play Mode ${state.timer.playback.toUpperCase()}`);
if (didStart) {
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onStart);
@@ -468,52 +420,43 @@ class RuntimeService {
return didStart;
}
/**
* Starts playback on selected event
*/
@broadcastResult
public start(): boolean {
return this.handleStart();
}
/**
* Starts playback on previous event
*/
@broadcastResult
public startPrevious(): boolean {
const hasPrevious = this.handleLoadPrevious();
startPrevious(): boolean {
const hasPrevious = this.loadPrevious();
if (!hasPrevious) {
return false;
}
return this.handleStart();
return this.start();
}
/**
* Starts playback on next event
*/
@broadcastResult
public startNext(): boolean {
const hasNext = this.handleLoadNext();
startNext(): boolean {
const hasNext = this.loadNext();
if (!hasNext) {
return false;
}
return this.handleStart();
return this.start();
}
/**
* Pauses playback on selected event
*/
@broadcastResult
public pause() {
pause() {
const state = runtimeState.getState();
const canPause = validatePlayback(state.timer.playback, state.timer.phase).pause;
const canPause = validatePlayback(state.timer.playback).pause;
if (!canPause) {
return;
}
this.eventTimer?.pause();
const newState = runtimeState.getState();
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
const newState = state.timer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onPause);
});
@@ -523,16 +466,16 @@ class RuntimeService {
* Stops timer and unloads any events
*/
@broadcastResult
public stop(): boolean {
stop(): boolean {
const state = runtimeState.getState();
const canStop = validatePlayback(state.timer.playback, state.timer.phase).stop;
const canStop = validatePlayback(state.timer.playback).stop;
if (!canStop) {
return false;
}
const didStop = this.eventTimer?.stop();
if (didStop) {
const newState = runtimeState.getState();
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
const newState = state.timer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onStop);
});
@@ -546,23 +489,16 @@ class RuntimeService {
* Reloads current event
*/
@broadcastResult
public reload() {
reload() {
const state = runtimeState.getState();
if (state.eventNow) {
return this.loadEvent(state.eventNow);
}
return false;
}
/**
* Handles special case to call roll on a loaded event which we do not want to discard
*/
private rollLoaded(offset?: number) {
const rundown = getRundown();
try {
runtimeState.roll(rundown, offset);
} catch (error) {
logger.error(LogOrigin.Server, `Roll: ${error}`);
const eventId = runtimeState.reload();
if (eventId) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${eventId}`);
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onLoad);
});
}
}
}
@@ -570,40 +506,24 @@ class RuntimeService {
* Sets playback to roll
*/
@broadcastResult
public roll(skipCheck: boolean = false) {
const previousState = runtimeState.getState();
if (!skipCheck) {
const canRoll = validatePlayback(previousState.timer.playback, previousState.timer.phase).roll;
if (!canRoll) {
return;
}
}
try {
const rundown = getRundown();
const result = runtimeState.roll(rundown);
if (result.eventId !== previousState.eventNow?.id) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`);
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onLoad);
});
}
if (result.didStart) {
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onStart);
});
}
} catch (error) {
logger.error(LogOrigin.Server, `Roll: ${error}`);
roll() {
const beforeState = runtimeState.getState();
const canRoll = validatePlayback(beforeState.timer.playback).roll;
if (!canRoll) {
return;
}
const newState = runtimeState.getState();
if (previousState.timer.playback !== newState.timer.playback) {
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
const playableEvents = getPlayableEvents();
if (playableEvents.length === 0) {
logger.warning(LogOrigin.Server, 'Roll: no events found');
return;
}
this.eventTimer.roll(playableEvents);
const state = runtimeState.getState();
const newState = state.timer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
/**
@@ -611,7 +531,7 @@ class RuntimeService {
* @param restorePoint
*/
@broadcastResult
public resume(restorePoint: RestorePoint) {
resume(restorePoint: RestorePoint) {
const { selectedEventId, playback } = restorePoint;
if (playback === Playback.Roll) {
this.roll();
@@ -623,14 +543,14 @@ class RuntimeService {
}
// the db would have to change for the event not to exist
// we do not know the reason for the crash, so we check anyway
// we do not kow the reason for the crash, so we check anyway
const event = getEventWithId(selectedEventId);
if (!isOntimeEvent(event) || !isPlayableEvent(event)) {
if (!event || !isOntimeEvent(event)) {
return;
}
const rundown = getRundown();
runtimeState.resume(restorePoint, event, rundown);
const timedEvents = getPlayableEvents();
runtimeState.resume(restorePoint, event, timedEvents);
logger.info(LogOrigin.Playback, 'Resuming playback');
}
@@ -638,8 +558,7 @@ class RuntimeService {
* Adds time to current event
* @param {number} time - time to add in milliseconds
*/
@broadcastResult
public addTime(time: number) {
addTime(time: number) {
if (this.eventTimer.addTime(time)) {
logger.info(LogOrigin.Playback, `${time > 0 ? 'Added' : 'Removed'} ${millisToString(time)}`);
}
@@ -647,7 +566,7 @@ class RuntimeService {
}
// calculate at 30fps, refresh at 1fps
const eventTimer = new EventTimer({
const eventTimer = new TimerService({
refresh: timerConfig.updateRate,
updateInterval: timerConfig.notificationRate,
});
@@ -655,8 +574,6 @@ export const runtimeService = new RuntimeService(eventTimer);
/**
* Decorator manages side effects from updating the runtime
* This should only be applied to functions that are exposed for consumption
* ie: whenever an external service makes a request, we update the state with the mutation result
*/
function broadcastResult(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
@@ -669,6 +586,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
// we do the comparison by explicitly for each property
// to apply custom logic for different datasets
const shouldUpdateClock = getShouldClockUpdate(RuntimeService.previousClockUpdate, state.clock);
const shouldForceTimerUpdate = getForceUpdate(RuntimeService.previousTimerUpdate, state.clock);
const shouldUpdateTimer =
shouldForceTimerUpdate || getShouldTimerUpdate(RuntimeService.previousTimerValue, state.timer.current);
@@ -702,16 +620,6 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
updateEventIfChanged('eventNext', state);
updateEventIfChanged('publicEventNext', state);
let syncBlockStartAt = false;
if (!deepEqual(RuntimeService?.previousState.currentBlock, state.currentBlock)) {
eventStore.set('currentBlock', state.currentBlock);
RuntimeService.previousState.currentBlock = { ...state.currentBlock };
syncBlockStartAt = true;
}
const shouldUpdateClock = syncBlockStartAt || getShouldClockUpdate(RuntimeService.previousClockUpdate, state.clock);
if (shouldUpdateClock) {
RuntimeService.previousClockUpdate = state.clock;
eventStore.set('clock', state.clock);
@@ -755,7 +663,6 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
addedTime: state.timer.addedTime,
pausedAt: state._timer.pausedAt,
firstStart: state.runtime.actualStart,
blockStartAt: state.currentBlock.startedAt,
});
}
@@ -1,26 +1,17 @@
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { getShouldClockUpdate, getShouldTimerUpdate } from '../rundownService.utils.js';
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(0);
});
afterEach(() => {
vi.useRealTimers();
});
describe('getShouldClockUpdate()', () => {
it('should return true when we slid forwards', () => {
const previousUpdate = Date.now(); // 2 seconds ago
const now = Date.now() + 2000;
const previousUpdate = Date.now() - 2000; // 2 seconds ago
const now = Date.now();
const result = getShouldClockUpdate(previousUpdate, now);
expect(result).toBe(true);
});
it('should return true when we slid backwards', () => {
const previousUpdate = Date.now() + 2000;
const now = Date.now(); // 2 seconds ago
const previousUpdate = Date.now();
const now = Date.now() - 2000; // 2 seconds ago
const result = getShouldClockUpdate(previousUpdate, now);
expect(result).toBe(true);
});
@@ -33,8 +24,8 @@ describe('getShouldClockUpdate()', () => {
});
it('should return false when clock is not a second ahead and force update is not required', () => {
const previousUpdate = Date.now();
const now = Date.now() + 32;
const previousUpdate = Date.now() - 32;
const now = Date.now();
const result = getShouldClockUpdate(previousUpdate, now);
expect(result).toBe(false);
});
+151 -5
View File
@@ -1,4 +1,4 @@
import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types';
import { MaybeNumber, MaybeString, OntimeEvent, Playback, TimerPhase, TimerType } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { RuntimeState } from '../stores/runtimeState.js';
@@ -93,11 +93,12 @@ export function getCurrent(state: RuntimeState): number {
* @returns {boolean}
*/
export function skippedOutOfEvent(state: RuntimeState, previousTime: number, skipLimit: number): boolean {
// we cant have skipped if we havent started
if (state.timer.expectedFinish === null || state.timer.startedAt === null) {
return false;
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (state.timer.expectedFinish === null || state.timer.startedAt === null) {
throw new Error('timerUtils.skippedOutOfEvent: invalid state received');
}
}
const { startedAt, expectedFinish } = state.timer;
const { clock } = state;
@@ -111,6 +112,151 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
return hasSkipped && (adjustedClock > adjustedExpectedFinish || adjustedClock < startedAt);
}
type RollTimers = {
nowIndex: MaybeNumber;
nowId: MaybeString;
publicIndex: MaybeNumber;
nextIndex: MaybeNumber;
publicNextIndex: MaybeNumber;
timeToNext: MaybeNumber;
nextEvent: OntimeEvent | null;
nextPublicEvent: OntimeEvent | null;
currentEvent: OntimeEvent | null;
currentPublicEvent: OntimeEvent | null;
};
/**
* Finds loading information given a current rundown and time
* @param {OntimeEvent[]} rundown - List of playable events
* @param {number} timeNow - time now in ms
*/
export const getRollTimers = (rundown: OntimeEvent[], timeNow: number, currentIndex?: number | null): RollTimers => {
let nowIndex: MaybeNumber = null; // index of event now
let nowId: MaybeString = null; // id of event now
let publicIndex: MaybeNumber = null; // index of public event now
let nextIndex: MaybeNumber = null; // index of next event
let publicNextIndex: MaybeNumber = null; // index of next public event
let timeToNext: MaybeNumber = null; // counter: time for next event
let publicTimeToNext: MaybeNumber = null; // counter: time for next public event
const hasLoaded = currentIndex !== null;
const canFilter = hasLoaded && currentIndex === rundown.length - 1;
const filteredRundown = canFilter ? rundown.slice(currentIndex) : rundown;
const lastEvent = filteredRundown.at(-1);
const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd);
let nextEvent: OntimeEvent | null = null;
let nextPublicEvent: OntimeEvent | null = null;
let currentEvent: OntimeEvent | null = null;
let currentPublicEvent: OntimeEvent | null = null;
if (timeNow > lastNormalEnd) {
// we are past last end
// preload first and find next
const firstEvent = filteredRundown.at(0);
nextIndex = 0;
nextEvent = firstEvent;
timeToNext = firstEvent.timeStart + dayInMs - timeNow;
if (firstEvent.isPublic) {
nextPublicEvent = firstEvent;
publicNextIndex = 0;
} else {
// look for next public
// dev note: we feel that this is more efficient than filtering
// since the next event will likely be close to the one playing
for (const event of filteredRundown) {
if (event.isPublic) {
nextPublicEvent = event;
// we need the index before this was sorted
publicNextIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
break;
}
}
}
} else {
// flags: select first event if several overlapping
let nowFound = false;
// keep track of the end times when looking for public
let publicTime = -1;
for (const event of filteredRundown) {
// When does the event end (handle midnight)
const normalEnd = normaliseEndTime(event.timeStart, event.timeEnd);
const hasNotEnded = normalEnd > timeNow;
const isFromDayBefore = normalEnd > dayInMs && timeNow < event.timeEnd;
const hasStarted = isFromDayBefore || timeNow >= event.timeStart;
if (normalEnd <= timeNow) {
// event ran already
if (event.isPublic && normalEnd > publicTime) {
// public event might not be the one running
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (hasNotEnded && hasStarted && !nowFound) {
// event is running
currentEvent = event;
nowIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
nowId = event.id;
nowFound = true;
// it could also be public
if (event.isPublic) {
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (normalEnd > timeNow) {
// event will run
// we already know whats next and next-public
if (nextIndex !== null && publicNextIndex !== null) {
continue;
}
// look for next events
// check how far the start is from now
const timeToEventStart = event.timeStart - timeNow;
// we don't have a next or this one starts sooner than current next
if (nextIndex === null || timeToEventStart < timeToNext) {
timeToNext = timeToEventStart;
nextEvent = event;
nextIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
if (event.isPublic) {
// if we don't have a public next or this one start sooner than assigned next
if (publicNextIndex === null || timeToEventStart < publicTimeToNext) {
publicTimeToNext = timeToEventStart;
nextPublicEvent = event;
publicNextIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
}
}
}
}
return {
nowIndex,
nowId,
publicIndex,
nextIndex,
publicNextIndex,
timeToNext,
nextEvent,
nextPublicEvent,
currentEvent,
currentPublicEvent,
};
};
/**
* Calculates difference between the runtime and the schedule of an event
* Positive offset is time ahead
@@ -1,7 +1,7 @@
import { PlayableEvent, Playback, TimerPhase } from 'ontime-types';
import { OntimeEvent, Playback } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import { RuntimeState, addTime, clear, getState, load, pause, roll, start, stop } from '../runtimeState.js';
import { RuntimeState, addTime, clear, getState, load, pause, start, stop } from '../runtimeState.js';
import { initRundown } from '../../services/rundown-service/RundownService.js';
const mockEvent = {
@@ -11,8 +11,7 @@ const mockEvent = {
timeStart: 0,
timeEnd: 1000,
duration: 1000,
skip: false,
} as PlayableEvent;
} as OntimeEvent;
const mockState = {
clock: 666,
@@ -99,7 +98,6 @@ describe('mutation on runtimeState', () => {
expect(newState.eventNow?.id).toBe(mockEvent.id);
expect(newState.timer.playback).toBe(Playback.Armed);
expect(newState.clock).not.toBe(666);
expect(newState.currentBlock.block).toBeNull();
// 2. Start event
let success = start();
@@ -145,7 +143,6 @@ describe('mutation on runtimeState', () => {
// 5. Stop event
success = stop();
newState = getState();
expect(success).toBe(true);
expect(newState.eventNow).toBe(null);
expect(newState.timer).toMatchObject({
@@ -174,7 +171,6 @@ describe('mutation on runtimeState', () => {
expect(newState.runtime.actualStart).toBeNull();
expect(newState.runtime.plannedStart).toBe(0);
expect(newState.runtime.plannedEnd).toBe(1500);
expect(newState.currentBlock.block).toBeNull();
// 2. Start event
start();
@@ -206,7 +202,6 @@ describe('mutation on runtimeState', () => {
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);
expect(newState.currentBlock.block).toBeNull();
// 4. Add time
addTime(10);
@@ -227,103 +222,7 @@ describe('mutation on runtimeState', () => {
});
test.todo('runtime offset on timers in overtime', () => {});
});
});
describe('roll mode', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime('jan 1 00:00');
clear();
});
afterEach(() => {
vi.useRealTimers();
});
describe('normal roll', () => {
const rundown = [
{ ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 },
{ ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 },
{ ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 },
] as PlayableEvent[];
test('pending event', () => {
const { eventId, didStart } = roll(rundown);
const state = getState();
expect(eventId).toBe('1');
expect(didStart).toBe(false);
expect(state.timer.phase).toBe(TimerPhase.Pending);
expect(state.timer.secondaryTimer).toBe(1000);
});
test('roll events', () => {
vi.setSystemTime('jan 1 00:00:01');
let result = roll(rundown);
expect(result).toStrictEqual({ eventId: '1', didStart: true });
vi.setSystemTime('jan 1 00:00:02');
result = roll(rundown);
expect(result).toStrictEqual({ eventId: '2', didStart: true });
vi.setSystemTime('jan 1 00:00:03:500');
result = roll(rundown);
expect(result).toStrictEqual({ eventId: '3', didStart: true });
});
});
describe('roll takover', () => {
const rundown = [
{ ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 },
{ ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 },
{ ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 },
] as PlayableEvent[];
test('from load', () => {
load(rundown[2], rundown);
const result = roll(rundown);
expect(result).toStrictEqual({ eventId: '3', didStart: false });
const state = getState();
expect(state.timer.phase).toBe(TimerPhase.Pending);
expect(state.timer.secondaryTimer).toBe(3000);
});
test('from play', () => {
load(rundown[0], rundown);
start();
const result = roll(rundown);
expect(result).toStrictEqual({ eventId: '1', didStart: false });
expect(getState().runtime.offset).toBe(1000);
});
});
describe('roll continue with offset', () => {
test('no gaps', () => {
const rundown = [
{ ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 },
{ ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 },
{ ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 },
] as PlayableEvent[];
load(rundown[0], rundown);
start();
let result = roll(rundown, getState().runtime.offset);
expect(result).toStrictEqual({ eventId: '1', didStart: false });
expect(getState().runtime.offset).toBe(1000);
vi.setSystemTime('jan 1 00:00:01');
result = roll(rundown, getState().runtime.offset);
expect(result).toStrictEqual({ eventId: '2', didStart: true });
expect(getState().runtime.offset).toBe(1000);
vi.setSystemTime('jan 1 00:00:02');
result = roll(rundown, getState().runtime.offset);
expect(result).toStrictEqual({ eventId: '3', didStart: true });
expect(getState().runtime.offset).toBe(1000);
});
test.todo('with gaps', () => {
//this is a bit involved as it also depends somewhat on the RintimeService
});
test.todo('roll mode', () => {});
});
});
+105 -336
View File
@@ -1,17 +1,5 @@
import {
CurrentBlockState,
isPlayableEvent,
MaybeNumber,
MaybeString,
OntimeEvent,
OntimeRundown,
PlayableEvent,
Playback,
Runtime,
TimerPhase,
TimerState,
} from 'ontime-types';
import { calculateDuration, checkIsNow, dayInMs, filterTimedEvents, getPreviousBlock } from 'ontime-utils';
import { MaybeNumber, OntimeEvent, Playback, Runtime, TimerPhase, TimerState } from 'ontime-types';
import { calculateDuration, dayInMs } from 'ontime-utils';
import { clock } from '../services/Clock.js';
import { RestorePoint } from '../services/RestoreService.js';
@@ -19,21 +7,21 @@ import {
getCurrent,
getExpectedEnd,
getExpectedFinish,
getRollTimers,
getRuntimeOffset,
getTimerPhase,
isPlaybackActive,
} from '../services/timerUtils.js';
import { timerConfig } from '../config/config.js';
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
const initialRuntime: Runtime = {
selectedEventIndex: null, // changes if rundown changes or we load a new event
numEvents: 0, // change initiated by user
offset: 0, // changes at runtime
plannedStart: 0, // only changes if event changes
plannedEnd: 0, // only changes if event changes, overflows over dayInMs
plannedEnd: 0, // only changes if event changes
actualStart: null, // set once we start the timer
expectedEnd: null, // changes with runtime, based on offset, overflows over dayInMs
expectedEnd: null, // changes with runtime, based on offset
} as const;
const initialTimer: TimerState = {
@@ -41,7 +29,8 @@ const initialTimer: TimerState = {
current: null, // changes on every update
duration: null, // only changes if event changes
elapsed: null, // changes on every update
expectedFinish: null, // change can only be initiated by user, can roll over midnight
// TODO: expected finish could account for midnight, we cleanup in the clients
expectedFinish: null, // change can only be initiated by user
finishedAt: null, // can change on update or user action
phase: TimerPhase.None, // can change on update or user action
playback: Playback.Stop, // change initiated by user
@@ -51,28 +40,22 @@ const initialTimer: TimerState = {
export type RuntimeState = {
clock: number; // realtime clock
eventNow: PlayableEvent | null;
currentBlock: CurrentBlockState;
publicEventNow: PlayableEvent | null;
eventNext: PlayableEvent | null;
publicEventNext: PlayableEvent | null;
eventNow: OntimeEvent | null;
publicEventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
publicEventNext: OntimeEvent | null;
runtime: Runtime;
timer: TimerState;
// private properties of the timer calculations
_timer: {
forceFinish: MaybeNumber; // whether we should declare an event as finished, will contain the finish time
forceFinish: MaybeNumber; // wether we should declare an event as finished, will contain the finish time
totalDelay: number; // this value comes from rundown service
pausedAt: MaybeNumber;
secondaryTarget: MaybeNumber;
};
};
const runtimeState: RuntimeState = {
clock: clock.timeNow(),
currentBlock: {
block: null,
startedAt: null,
},
eventNow: null,
publicEventNow: null,
eventNext: null,
@@ -83,32 +66,17 @@ const runtimeState: RuntimeState = {
forceFinish: null,
totalDelay: 0,
pausedAt: null,
secondaryTarget: null,
},
};
export function getState(): Readonly<RuntimeState> {
// create a shallow copy of the state
return {
...runtimeState,
eventNow: runtimeState.eventNow ? { ...runtimeState.eventNow } : null,
eventNext: runtimeState.eventNext ? { ...runtimeState.eventNext } : null,
publicEventNow: runtimeState.publicEventNow ? { ...runtimeState.publicEventNow } : null,
publicEventNext: runtimeState.publicEventNext ? { ...runtimeState.publicEventNext } : null,
runtime: { ...runtimeState.runtime },
timer: { ...runtimeState.timer },
_timer: { ...runtimeState._timer },
};
return runtimeState;
}
export function clear() {
runtimeState.eventNow = null;
runtimeState.publicEventNow = null;
runtimeState.eventNext = null;
runtimeState.currentBlock.block = null;
runtimeState.currentBlock.startedAt = null;
runtimeState.publicEventNext = null;
runtimeState.runtime.offset = 0;
@@ -137,7 +105,7 @@ function patchTimer(newState: Partial<TimerState>) {
}
type RundownData = {
numEvents: number; // length of rundown filtered for timed events
numEvents: number;
firstStart: MaybeNumber;
lastEnd: MaybeNumber;
totalDelay: number;
@@ -149,7 +117,6 @@ type RundownData = {
* @param playableRundown
*/
export function updateRundownData(rundownData: RundownData) {
// we keep this in private state since there is no UI use case for it
runtimeState._timer.totalDelay = rundownData.totalDelay;
runtimeState.runtime.numEvents = rundownData.numEvents;
@@ -161,65 +128,44 @@ export function updateRundownData(rundownData: RundownData) {
/**
* Loads a given event into state
* @param event
* @param rundown
* @param initialData
*/
export function load(
event: PlayableEvent,
rundown: OntimeRundown,
event: OntimeEvent,
rundown: OntimeEvent[],
initialData?: Partial<TimerState & RestorePoint>,
): boolean {
// we need to persist the current block state across loads
const prevCurrentBlock = { ...runtimeState.currentBlock };
clear();
runtimeState.currentBlock = prevCurrentBlock;
// filter rundown
const timedEvents = filterTimedEvents(rundown);
const eventIndex = timedEvents.findIndex((eventInMemory) => eventInMemory.id === event.id);
const eventIndex = rundown.findIndex((eventInMemory) => eventInMemory.id === event.id);
if (timedEvents.length === 0 || eventIndex === -1 || !isPlayableEvent(event)) {
return false;
}
runtimeState.runtime.selectedEventIndex = eventIndex;
// load events in memory along with their data
loadNow(timedEvents, eventIndex);
loadNext(timedEvents, eventIndex);
loadBlock(rundown);
loadNow(event, rundown);
loadNext(rundown);
// update state
runtimeState.clock = clock.timeNow();
runtimeState.timer.playback = Playback.Armed;
runtimeState.timer.duration = calculateDuration(event.timeStart, event.timeEnd);
runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.runtime.numEvents = timedEvents.length;
// patch with potential provided data
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 = getExpectedEnd(runtimeState);
}
if (typeof initialData.blockStartAt === 'number') {
runtimeState.currentBlock.startedAt = initialData.blockStartAt;
}
}
return event.id === runtimeState.eventNow?.id;
}
/**
* Loads current event and its public counterpart
*/
export function loadNow(timedEvents: OntimeEvent[], eventIndex: MaybeNumber = runtimeState.runtime.selectedEventIndex) {
if (eventIndex === null) {
// reset the state to indicate there is no selection
runtimeState.runtime.selectedEventIndex = null;
runtimeState.eventNow = null;
return;
}
const event = timedEvents[eventIndex] as PlayableEvent;
runtimeState.runtime.selectedEventIndex = eventIndex;
export function loadNow(event: OntimeEvent, playableEvents: OntimeEvent[]) {
runtimeState.eventNow = event;
// check if current is also public
@@ -230,81 +176,63 @@ export function loadNow(timedEvents: OntimeEvent[], eventIndex: MaybeNumber = ru
runtimeState.publicEventNow = null;
// if there is nothing before, return
if (!eventIndex) {
if (!runtimeState.runtime.selectedEventIndex) {
return;
}
// iterate backwards to find it
for (let i = eventIndex; i >= 0; i--) {
const event = timedEvents[i];
// we dont deal with events that are not playable
if (!isPlayableEvent(event)) {
continue;
}
if (event.isPublic) {
runtimeState.publicEventNow = event;
for (let i = runtimeState.runtime.selectedEventIndex; i >= 0; i--) {
if (playableEvents[i].isPublic) {
runtimeState.publicEventNow = playableEvents[i];
break;
}
}
}
}
/**
* Loads the next event and its public counterpart
*/
export function loadNext(
timedEvents: OntimeEvent[],
eventIndex: MaybeNumber = runtimeState.runtime.selectedEventIndex,
) {
if (eventIndex === null) {
// reset the state to indicate there is no future event
runtimeState.eventNext = null;
runtimeState.publicEventNext = null;
export function loadNext(playableEvents: OntimeEvent[]) {
// assume there are no next events
runtimeState.eventNext = null;
runtimeState.publicEventNext = null;
if (runtimeState.runtime.selectedEventIndex === null) {
return;
}
for (let i = eventIndex + 1; i < timedEvents.length; i++) {
const event = timedEvents[i];
// we dont deal with events that are not playable
if (!isPlayableEvent(event)) {
continue;
}
const numEvents = playableEvents.length;
// the private event is the one immediately after the current event
if (runtimeState.eventNext === null) {
runtimeState.eventNext = event;
}
if (runtimeState.runtime.selectedEventIndex < numEvents - 1) {
let nextPublic = false;
let nextProduction = false;
// if event is public
if (event.isPublic) {
runtimeState.publicEventNext = event;
}
for (let i = runtimeState.runtime.selectedEventIndex + 1; i < numEvents; i++) {
// if we have not set private
if (!nextProduction) {
runtimeState.eventNext = playableEvents[i];
nextProduction = true;
}
// Stop if both are set
if (runtimeState.eventNext !== null && runtimeState.publicEventNext !== null) {
return;
// if event is public
if (playableEvents[i].isPublic) {
runtimeState.publicEventNext = playableEvents[i];
nextPublic = true;
}
// Stop if both are set
if (nextPublic && nextProduction) break;
}
}
}
/**
* Resume from restore point
*/
export function resume(restorePoint: RestorePoint, event: PlayableEvent, rundown: OntimeRundown) {
export function resume(restorePoint: RestorePoint, event: OntimeEvent, rundown: OntimeEvent[]) {
load(event, rundown, restorePoint);
}
/**
* We only pass an event if we are hot reloading
* @param {PlayableEvent} event only passed if we are changing the data if a playing timer
* @param {OntimeEvent} event only passed if we are changing the data if a playing timer
*/
export function updateLoaded(event?: PlayableEvent): string | undefined {
// if there is no event loaded, nothing to do
if (runtimeState.eventNow === null) {
return;
}
export function reload(event?: OntimeEvent) {
// we only pass an event for hot reloading, ie: the event has changed
if (event) {
runtimeState.eventNow = event;
@@ -313,51 +241,33 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
// handle edge cases with roll
if (runtimeState.timer.playback === Playback.Roll) {
const offsetClock = runtimeState.clock + runtimeState.runtime.offset;
// 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;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
} else {
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
}
}
}
return runtimeState.eventNow.id;
}
// reset changes to timer progress
runtimeState.timer.playback = Playback.Armed;
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
runtimeState.timer.current = runtimeState.timer.duration;
runtimeState.timer.elapsed = null;
runtimeState.timer.startedAt = null;
runtimeState.timer.finishedAt = null;
runtimeState.timer.addedTime = 0;
runtimeState._timer.pausedAt = null;
runtimeState.timer.addedTime = 0;
// this could be looked after by the timer
runtimeState.timer.elapsed = null;
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
return runtimeState.eventNow.id;
}
/**
* Used in situations when we want to hot-reload all events without interrupting timer
* Used in situations when we want to reload all events
* without interrupting timer
* @param eventNow
* @param playableEvents
*/
export function updateAll(rundown: OntimeRundown) {
const timedEvents = filterTimedEvents(rundown);
loadNow(timedEvents);
loadNext(timedEvents);
updateLoaded(runtimeState.eventNow ?? undefined);
loadBlock(rundown);
export function reloadAll(eventNow: OntimeEvent, playableEvents: OntimeEvent[]) {
loadNow(eventNow, playableEvents);
loadNext(playableEvents);
reload(eventNow);
}
export function start(state: RuntimeState = runtimeState): boolean {
@@ -381,11 +291,6 @@ export function start(state: RuntimeState = runtimeState): boolean {
state.timer.startedAt = state.clock;
}
// update block start time
if (state.currentBlock.startedAt === null) {
state.currentBlock.startedAt = state.clock;
}
state.timer.playback = Playback.Play;
state.timer.expectedFinish = getExpectedFinish(state);
state.timer.elapsed = 0;
@@ -427,22 +332,11 @@ export function stop(state: RuntimeState = runtimeState): boolean {
return true;
}
/**
* Exposes functionality to add user time to the timer externally
*/
export function addTime(amount: number) {
if (runtimeState.timer.current === null) {
return false;
}
// as long as there is a timer, we need an expected finish
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (runtimeState.timer.expectedFinish === null) {
throw new Error('runtimeState.addTime: invalid state received');
}
}
// handle edge cases
// !!! we need to handle side effects before updating the state
const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current;
@@ -472,7 +366,7 @@ export function addTime(amount: number) {
export type UpdateResult = {
hasTimerFinished: boolean;
hasSecondaryTimerFinished: boolean;
shouldCallRoll: boolean;
};
export function update(): UpdateResult {
@@ -486,21 +380,18 @@ export function update(): UpdateResult {
// 2. are we waiting to roll?
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
return updateIfWaitingToRoll();
return updateIfWaitingToRoll(runtimeState.timer.secondaryTimer);
}
// 3. at this point we know that we are playing an event
// reset data
runtimeState.timer.secondaryTimer = null;
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (!runtimeState.timer.duration) {
throw new Error('runtimeState.update: invalid state received');
}
// update timer state
if (!runtimeState.timer.duration) {
throw new Error('Timer duration is not set');
}
// update timer state
runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
runtimeState.timer.phase = getTimerPhase(runtimeState);
@@ -516,181 +407,59 @@ export function update(): UpdateResult {
if (finishedNow) {
// reset state
runtimeState._timer.forceFinish;
runtimeState.timer.finishedAt = runtimeState._timer.forceFinish ?? runtimeState.clock;
} else {
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
}
return { hasTimerFinished: finishedNow, hasSecondaryTimerFinished: false };
return { hasTimerFinished: finishedNow, shouldCallRoll: finishedNow };
function updateIfIdle() {
// if nothing is running, nothing to do
return { hasTimerFinished: false, hasSecondaryTimerFinished: false };
return { hasTimerFinished: false, shouldCallRoll: false };
}
function updateIfWaitingToRoll() {
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (runtimeState.eventNow === null || runtimeState._timer.secondaryTarget === null) {
throw new Error('runtimeState.updateIfWaitingToRoll: invalid state received');
}
}
//account for offset
const offsetClock = runtimeState.clock + runtimeState.runtime.offset;
function updateIfWaitingToRoll(targetTime: number) {
runtimeState.timer.secondaryTimer = targetTime - runtimeState.clock;
runtimeState.timer.phase = TimerPhase.Pending;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
return { hasTimerFinished: false, hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0 };
return { hasTimerFinished: false, shouldCallRoll: runtimeState.timer.secondaryTimer < 0 };
}
}
export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString; didStart: boolean } {
// 1. if an event is running, we simply take over the playback
if (runtimeState.timer.playback === Playback.Play && runtimeState.runtime.selectedEventIndex !== null) {
runtimeState.timer.playback = Playback.Roll;
return { eventId: runtimeState.eventNow?.id ?? null, didStart: false };
}
export function roll(rundown: OntimeEvent[]) {
const selectedEventIndex = runtimeState.runtime.selectedEventIndex;
clear();
runtimeState.runtime.numEvents = rundown.length;
// 2. if there is an event armed, we use it
if (runtimeState.timer.playback === Playback.Armed || runtimeState.timer.phase === TimerPhase.Pending) {
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (runtimeState.eventNow === null) {
throw new Error('runtimeState.roll: invalid state received');
}
}
const { nextEvent, currentEvent } = getRollTimers(rundown, runtimeState.clock, selectedEventIndex);
runtimeState.runtime.offset = offset;
runtimeState.timer.playback = Playback.Roll;
if (currentEvent) {
// there is something running, load
runtimeState.timer.secondaryTimer = null;
// account for event that finishes the day after
const normalisedEndTime =
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
? runtimeState.eventNow.timeEnd + dayInMs
: runtimeState.eventNow.timeEnd;
runtimeState.timer.expectedFinish = normalisedEndTime;
const endTime =
currentEvent.timeEnd < currentEvent.timeStart ? currentEvent.timeEnd + dayInMs : currentEvent.timeEnd;
//account for offset
const offsetClock = runtimeState.clock + runtimeState.runtime.offset;
// state catch up
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime);
runtimeState.timer.current = runtimeState.timer.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);
if (isNow) {
runtimeState.timer.startedAt = runtimeState.clock;
// update runtime
if (runtimeState.currentBlock.startedAt === null) {
runtimeState.currentBlock.startedAt = runtimeState.clock;
}
if (!runtimeState.runtime.actualStart) {
runtimeState.runtime.actualStart = runtimeState.clock;
}
runtimeState.timer.secondaryTimer = null;
} else {
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
runtimeState.timer.phase = TimerPhase.Pending;
// when we load a timer in roll, we do the same things as before
// but also pre-populate some data as to the running state
load(currentEvent, rundown, {
startedAt: currentEvent.timeStart,
expectedFinish: currentEvent.timeEnd,
current: endTime - runtimeState.clock,
});
} else if (nextEvent) {
if (nextEvent.isPublic) {
runtimeState.publicEventNext = nextEvent;
}
return { eventId: runtimeState.eventNow.id, didStart: isNow };
}
// 3. if there is no event running, we need to find the next event
const timedEvents = filterTimedEvents(rundown);
if (timedEvents.length === 0) {
throw new Error('No playable events found');
}
// we need to persist the current block state across loads
const prevCurrentBlock = { ...runtimeState.currentBlock };
clear();
runtimeState.currentBlock = prevCurrentBlock;
//account for offset but we only keep it if passed to us
runtimeState.runtime.offset = offset;
const offsetClock = runtimeState.clock + runtimeState.runtime.offset;
const { index, isPending } = loadRoll(timedEvents, offsetClock);
// load events in memory along with their data
loadNow(timedEvents, index);
loadNext(timedEvents, index);
loadBlock(rundown);
// update roll state
runtimeState.timer.playback = Playback.Roll;
runtimeState.runtime.numEvents = timedEvents.length;
// in roll mode spec, there should always be something to load
// as long as playableEvents is not empty
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (runtimeState.eventNow === null) {
throw new Error('runtimeState.roll: invalid state received');
}
}
if (isPending) {
// there is nothing now, but something coming up
runtimeState.eventNext = nextEvent;
// account for day after
const nextStart = nextEvent.timeStart < runtimeState.clock ? nextEvent.timeStart + dayInMs : nextEvent.timeStart;
// nothing now, but something coming up
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);
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
// preload timer properties
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
runtimeState.timer.current = runtimeState.timer.duration;
return { eventId: runtimeState.eventNow.id, didStart: false };
runtimeState.timer.secondaryTimer = nextStart - runtimeState.clock;
}
// there is something to run, load event
// event will finish on time
// account for event that finishes the day after
const endTime =
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
? runtimeState.eventNow.timeEnd + dayInMs
: runtimeState.eventNow.timeEnd;
runtimeState.timer.startedAt = runtimeState.clock;
runtimeState.timer.expectedFinish = endTime;
// we add time to allow timer to catch up
runtimeState.timer.addedTime = -(runtimeState.clock - runtimeState.eventNow.timeStart);
// state catch up
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, endTime);
runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.timer.elapsed = 0;
// update runtime
runtimeState.runtime.actualStart = runtimeState.clock;
return { eventId: runtimeState.eventNow.id, didStart: true };
}
function loadBlock(rundown: OntimeRundown) {
if (runtimeState.eventNow === null) {
// we need a loaded event to have a block
runtimeState.currentBlock.block = null;
runtimeState.currentBlock.startedAt = null;
return;
}
const newCurrentBlock = getPreviousBlock(rundown, runtimeState.eventNow.id);
// test all block change posibiletys
const formNoBlockToBlock = runtimeState.currentBlock.block === null && newCurrentBlock !== null;
const formBlockToNoBlock = runtimeState.currentBlock.block !== null && newCurrentBlock === null;
const formBlockToNewBlock = runtimeState.currentBlock.block?.id !== newCurrentBlock?.id;
// update time only if the block has changed
if (formNoBlockToBlock || formBlockToNoBlock || formBlockToNewBlock) {
runtimeState.currentBlock.startedAt = null;
}
// update the block anyway
runtimeState.currentBlock.block = newCurrentBlock === null ? null : { ...newCurrentBlock };
runtimeState.timer.playback = Playback.Roll;
}
-1
View File
@@ -1,6 +1,5 @@
{
"compilerOptions": {
"strict": false,
"target": "ESNext",
"module": "Node16",
"moduleResolution": "Node16",
-63
View File
@@ -1,63 +0,0 @@
# ROLL Mode
Roll mode is intended to be a fully automatic playback that takes precedence over event end actions.
It can be user either on its own, or as in conjunction with manual playback to allow for automated rundown sections.
## Overview
- As long as there are non-skipped events in the rundown, we will always accept roll mode
- If there are no events in the current time frame, we load the next event and count-down to its start
- Roll will always load the first matching event in the current time, this could cause issues if there are multiple days planned or if the rundown is not in order.
- If we go from manual playback, to Roll mode, the playback should continue as is. Roll mode will automate loading the next event when the current is finished
## Implementation details
### starting to roll
> RuntimeService.roll(rundown: OntimeRundown)
When calling the roll function, we try and find events to load. There should always be an event as long as the rundown is not empty.
#### Taking over playback
If we are currently in "Play" mode and an event is playing, roll simply takes over playback. No other data changes are made
#### Starting an event
If there is nothing playing and roll finds an element that in the current time frame playing, it will start the event
#### Waiting to start
If we do not find an event that should be playing now, but find an event for the future, we load the next event, set roll mode and wait
### tick update
> RuntimeState.onUpdate()
Updating in roll mode attempts to have the least amount of custom logic in relation to normal updates. The only difference in behaviour is the automation of loading the next event when the current one is finished.
#### normal update
On the update of timers, there is no logic specific to Roll mode, all side effects (ie: integrations) should have the same behaviour as Play mode
#### waiting to start
If we are currently waiting to start, we just need to update the `secondaryTimer`.
If waiting to start is finished, we load the next event and start it
#### an event is finished
If an event is finished, we check if the next event is ready to start, this is similarly as if we had a conditional `load-next` `play-next` automation
If there is a gap between the events, we add `secondaryTimer` to match and wait for the next event to start
Finish time should account for `timer.addedTime`
Finish actions are ignored in roll mode
#### time has skipped
If we find that the new time update has slid in comparison to the old update (either too long, or time went backwards), we re-calculate
### Finding an event
> loadRoll(timedEvents: OntimeEvent[], timeNow: number)
This is a helper function which iterates trough a rundown to find the first matching element in the current time. As a trade-off, the wrong event will be loaded if the rundown is not in order.
It is important to note that all times in the rundown are in milliseconds from midnight. In the case of multiple days being scheduled, Roll will return the first match.
### Assumptions
The function receives a pre-filtered list of `TimedEvents`. This is to avoid issues with inconsistent index references.
- `TimedEvents` cannot be an empty array
- `TimedEvents` is assumed to be in order
### Specification
- we should always receive an `eventNow` or `eventNext`
- if the current clock is past the last event end, we roll for the events tomorrow
- if the current clock is before the first event, we do not load next day events, even if there is a match, we always start on first day
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.5.0",
"version": "3.5.0-beta.1",
"description": "Time keeping for live events",
"keywords": [
"ontime",
@@ -43,5 +43,3 @@ export type OntimeEvent = OntimeBaseEvent & {
timeDanger: number;
custom: EventCustomFields;
};
export type PlayableEvent = OntimeEvent & { skip: false };
@@ -1,7 +0,0 @@
import type { MaybeNumber } from '../../utils/utils.type.js';
import type { OntimeBlock } from '../core/OntimeEvent.type.js';
export type CurrentBlockState = {
block: OntimeBlock | null;
startedAt: MaybeNumber;
};
@@ -1,6 +1,5 @@
import type { OntimeEvent } from '../core/OntimeEvent.type.js';
import type { SimpleTimerState } from './AuxTimer.type.js';
import type { CurrentBlockState } from './CurrentBlockState.type.js';
import type { MessageState } from './MessageControl.type.js';
import type { Runtime } from './Runtime.type.js';
import type { TimerState } from './TimerState.type.js';
@@ -16,7 +15,6 @@ export type RuntimeStore = {
// rundown data
runtime: Runtime;
currentBlock: CurrentBlockState;
eventNow: OntimeEvent | null;
publicEventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
+1 -10
View File
@@ -8,7 +8,6 @@ export {
type OntimeDelay,
type OntimeBlock,
type OntimeEvent,
type PlayableEvent,
SupportedEvent,
} from './definitions/core/OntimeEvent.type.js';
export type { OntimeEntryCommonKeys, OntimeRundown, OntimeRundownEntry } from './definitions/core/Rundown.type.js';
@@ -63,7 +62,6 @@ export type { Message, TimerMessage, MessageState } from './definitions/runtime/
export type { Runtime } from './definitions/runtime/Runtime.type.js';
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
export type { CurrentBlockState } from './definitions/runtime/CurrentBlockState.type.js';
// ---> Extra Timer
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
@@ -72,12 +70,5 @@ export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './defini
export type { Client, ClientList, ClientType } from './definitions/Clients.type.js';
// TYPE UTILITIES
export {
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
isPlayableEvent,
isOntimeCycle,
isKeyOfType,
} from './utils/guards.js';
export { isOntimeBlock, isOntimeDelay, isOntimeEvent, isOntimeCycle, isKeyOfType } from './utils/guards.js';
export type { DeepPartial, MaybeNumber, MaybeString } from './utils/utils.type.js';
+1 -5
View File
@@ -1,4 +1,4 @@
import type { OntimeBlock, OntimeDelay, OntimeEvent, PlayableEvent } from '../definitions/core/OntimeEvent.type.js';
import type { OntimeBlock, OntimeDelay, OntimeEvent } from '../definitions/core/OntimeEvent.type.js';
import { SupportedEvent } from '../definitions/core/OntimeEvent.type.js';
import type { OntimeRundownEntry } from '../definitions/core/Rundown.type.js';
import type { TimerLifeCycleKey } from '../definitions/core/TimerLifecycle.type.js';
@@ -10,10 +10,6 @@ export function isOntimeEvent(event: MaybeEvent): event is OntimeEvent {
return event?.type === SupportedEvent.Event;
}
export function isPlayableEvent(event: OntimeEvent): event is PlayableEvent {
return !event.skip;
}
export function isOntimeDelay(event: MaybeEvent): event is OntimeDelay {
return event?.type === SupportedEvent.Delay;
}
-8
View File
@@ -8,8 +8,6 @@ export { sanitiseCue } from './src/cue-utils/cueUtils.js';
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
export { generateId } from './src/generate-id/generateId.js';
export {
filterPlayable,
filterTimedEvents,
getEventWithId,
getFirst,
getFirstEvent,
@@ -19,7 +17,6 @@ export {
getLastEventNormal,
getLastNormal,
getNext,
getNextBlockNormal,
getNextEvent,
getNextEventNormal,
getNextNormal,
@@ -27,8 +24,6 @@ export {
getPreviousEvent,
getPreviousEventNormal,
getPreviousNormal,
getPreviousBlock,
getPreviousBlockNormal,
swapEventData,
} from './src/rundown-utils/rundownUtils.js';
@@ -73,10 +68,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
// feature business logic
// feature business logic - rundown
export { checkIsNow } from './src/date-utils/checkIsNow.js';
export { checkIsNextDay } from './src/date-utils/checkIsNextDay.js';
export { getTimeFromPrevious } from './src/date-utils/getTimeFromPrevious.js';
export { isNewLatest } from './src/date-utils/isNewLatest.js';
// feature business logic - spreadsheet import
export {
@@ -1,60 +0,0 @@
import { checkIsNextDay } from './checkIsNextDay';
import { MILLIS_PER_HOUR } from './conversionUtils';
describe('checkIsNextDay', () => {
it('returns false if the previous event duration is 0', () => {
const previousStart = 0;
const previousDuration = 0;
const timeStart = 0;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
});
it('returns false if event starts after one before', () => {
const previousStart = 10;
const previousDuration = 2;
const timeStart = 11;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
});
it('returns true if event starts after one before', () => {
const previousStart = 10;
const previousDuration = 2;
const timeStart = 9;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
});
it('returns true if event starts at the same time as one before', () => {
const previousStart = 10;
const previousDuration = 2;
const timeStart = 10;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
});
it('should account for an event that crossed midnight', () => {
const previousStart = 20 * MILLIS_PER_HOUR;
const previousDuration = 6 * MILLIS_PER_HOUR; // event finished at 02:00:00
const timeStart = 1 * MILLIS_PER_HOUR;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
});
it('should account for an event that crossed midnight and there is a gap', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const timeStart = 2 * MILLIS_PER_HOUR;
const previousDuration = 2 * MILLIS_PER_HOUR;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
});
it('should account for an event that crossed midnight with no overlaps', () => {
const previousStart = 20 * MILLIS_PER_HOUR;
const previousDuration = 6 * MILLIS_PER_HOUR; // event finished at 02:00:00
const timeStart = 19 * MILLIS_PER_HOUR;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
});
it('should account for an event that finishes exactly at midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousDuration = 1 * MILLIS_PER_HOUR;
const timeStart = 2 * MILLIS_PER_HOUR;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
});
});
@@ -1,40 +1,13 @@
import { dayInMs } from './conversionUtils.js';
/**
* Utility function checks whether a given event is the day after from its predecessor
* We consider an event to be the day after, if it begins before the start of the previous
* @example day after
* 09:00 - 10:00
* 08:00 - 10:30
* @example day after
* 23:00 - 00:00
* 02:00 - 03:00
* @example same day
* 09:00 - 10:00
* 09:30 - 10:30
* @example same day, but previous crosses midnight
* 23:00 - 01:00
* 02:00 - 03:00
* @example same day, but previous crosses midnight (with overlap)
* 22:00 - 02:00
* 01:00 - 03:00
*/
export function checkIsNextDay(previousStart: number, timeStart: number, previousDuration: number): boolean {
if (previousDuration === 0) {
return false;
}
if (timeStart <= previousStart) {
const normalisedPreviousEnd = previousStart + previousDuration;
if (normalisedPreviousEnd === dayInMs) {
return true;
}
// handle exception for an event that finishes exactly at midnight
if (normalisedPreviousEnd > dayInMs) {
return false;
}
return true;
}
return false;
export function checkIsNextDay(previousStart: number, timeStart: number, previousDuration?: number): boolean {
return previousDuration === 0 ? false : timeStart <= previousStart;
}
@@ -1,29 +0,0 @@
import { checkIsNow } from './checkIsNow';
import { MILLIS_PER_HOUR } from './conversionUtils';
describe('checkIsNow()', () => {
test('should return true if now is between timeStart and timeEnd', () => {
const timeStart = 9;
const timeEnd = 16;
const now = 10;
expect(checkIsNow(timeStart, timeEnd, now)).toBe(true);
});
test('should return false if now is before start', () => {
const timeStart = 9;
const timeEnd = 16;
const now = 8;
expect(checkIsNow(timeStart, timeEnd, now)).toBe(false);
});
test('should return false if now is after end', () => {
const timeStart = 9;
const timeEnd = 16;
const now = 20;
expect(checkIsNow(timeStart, timeEnd, now)).toBe(false);
});
test('should return true accounting for events that roll over midnight', () => {
expect(checkIsNow(22 * MILLIS_PER_HOUR, 8 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(true);
});
});
@@ -1,9 +0,0 @@
import { dayInMs } from './conversionUtils.js';
/**
* Utility function checks whether a given event should be playing now
*/
export function checkIsNow(timeStart: number, timeEnd: number, clock: number): boolean {
const normalisedEnd = timeEnd < timeStart ? timeEnd + dayInMs : timeEnd;
return timeStart <= clock && clock <= normalisedEnd;
}
@@ -1,64 +0,0 @@
import { dayInMs, MILLIS_PER_HOUR } from './conversionUtils';
import { getTimeFromPrevious } from './getTimeFromPrevious';
describe('getTimeFromPrevious', () => {
it('returns the time elapsed (gap or overlap) from the previous', () => {
const previousStart = 69600000; // 19:20
const previousEnd = 71700000; // 19:55
const previousDuration = 2100000; // 35 minutes
const currentStart = 75600000; // 21:00
const expected = 75600000 - 71700000; // current start - previousEnd
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
});
it('accounts for partially overlapping events', () => {
const previousStart = 10;
const previousEnd = 12;
const previousDuration = 2;
const currentStart = 11;
const expected = -(previousEnd - currentStart);
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
});
it('accounts for events that are fully contained', () => {
const previousStart = 8;
const previousEnd = 16;
const previousDuration = 8;
const currentStart = 10;
const expected = -(previousEnd - currentStart);
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
});
it('fully overlapping events are the next day', () => {
const previousStart = 10 * MILLIS_PER_HOUR;
const previousEnd = 12 * MILLIS_PER_HOUR;
const previousDuration = previousEnd - previousStart;
const currentStart = 10 * MILLIS_PER_HOUR;
const expected = dayInMs - previousDuration;
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
});
it('accounts for events that are the day after', () => {
const previousStart = 20 * MILLIS_PER_HOUR;
const previousEnd = 23 * MILLIS_PER_HOUR;
const previousDuration = 3 * MILLIS_PER_HOUR;
const currentStart = 22 * MILLIS_PER_HOUR;
const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart);
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
});
it('accounts for events that cross midnight', () => {
const previousStart = 20 * MILLIS_PER_HOUR;
const previousEnd = 2 * MILLIS_PER_HOUR;
const previousDuration = 6 * MILLIS_PER_HOUR;
const currentStart = 1 * MILLIS_PER_HOUR;
const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart);
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
});
});
@@ -1,45 +0,0 @@
import { checkIsNextDay } from './checkIsNextDay.js';
import { dayInMs } from './conversionUtils.js';
/**
* Utility returns the time elapsed (gap or overlap) from the previous
* It uses deconstructed parameters to simplify implementation in UI
*/
export function getTimeFromPrevious(
currentStart: number,
previousStart?: number,
previousEnd?: number,
previousDuration?: number,
): number {
// there is no previous event
if (previousStart === undefined || previousEnd === undefined || previousDuration === undefined) {
return 0;
}
// event is linked to previous
if (currentStart === previousEnd) {
return 0;
}
// event is the day after
if (checkIsNextDay(previousStart, currentStart, previousDuration)) {
// time from previous is difference between normalised start and previous end
return currentStart + dayInMs - previousEnd;
}
// event has a gap from previous
if (currentStart > previousEnd) {
// time from previous is difference between start and previous end
return currentStart - previousEnd;
}
// event overlaps with previous
const overlap = previousEnd - currentStart;
if (overlap > 0) {
// time is a negative number indicating the amount of overlap
return -overlap;
}
// we need to make sure we return a number, but there are no business cases for this
return 0;
}
@@ -1,48 +0,0 @@
import { MILLIS_PER_HOUR } from './conversionUtils';
import { isNewLatest } from './isNewLatest';
describe('isNewLatest', () => {
it('should be true if there is no previous', () => {
expect(isNewLatest(0, 60000)).toBeTruthy();
});
it('should be true if it starts when the previous finishes', () => {
const nowStart = 10 * MILLIS_PER_HOUR;
const nowEnd = 11 * MILLIS_PER_HOUR;
const previousStart = 9 * MILLIS_PER_HOUR;
const previousEnd = 10 * MILLIS_PER_HOUR;
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
});
it('should be true if it starts the same day the previous finishes', () => {
const nowStart = 22 * MILLIS_PER_HOUR;
const nowEnd = 23 * MILLIS_PER_HOUR;
const previousStart = 9 * MILLIS_PER_HOUR;
const previousEnd = 20 * MILLIS_PER_HOUR;
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
});
it('should be true if it finishes after the previous, accounting for passing midnight', () => {
const nowStart = 1 * MILLIS_PER_HOUR;
const nowEnd = 3 * MILLIS_PER_HOUR;
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 2 * MILLIS_PER_HOUR;
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
});
it('should be true if it the next day', () => {
const nowStart = 8 * MILLIS_PER_HOUR;
const nowEnd = 10 * MILLIS_PER_HOUR;
const previousStart = 9 * MILLIS_PER_HOUR;
const previousEnd = 11 * MILLIS_PER_HOUR;
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
});
it('should be true if it the next day (2)', () => {
const nowStart = 9 * MILLIS_PER_HOUR;
const nowEnd = 11 * MILLIS_PER_HOUR;
const previousStart = 9 * MILLIS_PER_HOUR;
const previousEnd = 11 * MILLIS_PER_HOUR;
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
});
});
@@ -1,24 +0,0 @@
import { checkIsNextDay } from './checkIsNextDay.js';
/**
* Checks whether a new element is the latest in the list
*/
export function isNewLatest(timeStart: number, timeEnd: number, previousStart?: number, previousEnd?: number): boolean {
// true if there is no previous
if (previousStart === undefined || previousEnd === undefined) {
return true;
}
// true if it starts after the previous is finished
if (timeStart >= previousEnd) {
return true;
}
// true if it finishes later than previous
if (timeEnd > previousEnd) {
return true;
}
// true if it is the day after
return checkIsNextDay(previousStart, timeStart, previousEnd - previousStart);
}
@@ -2,13 +2,11 @@ import type { NormalisedRundown, OntimeEvent, OntimeRundown } from 'ontime-types
import { SupportedEvent } from 'ontime-types';
import {
filterPlayable,
getLastEvent,
getLastNormal,
getNext,
getNextEvent,
getPrevious,
getPreviousBlock,
getPreviousEvent,
swapEventData,
} from './rundownUtils';
@@ -264,54 +262,4 @@ describe('getLastEvent', () => {
expect(lastEntry).toBe(null);
});
});
describe('relevantBlock', () => {
const testRundown = [
{ id: 'a', type: SupportedEvent.Event },
{ id: 'b', type: SupportedEvent.Event },
{ id: 'c', type: SupportedEvent.Event },
{ id: 'd', type: SupportedEvent.Delay },
{ id: 'e', type: SupportedEvent.Block },
{ id: 'f', type: SupportedEvent.Event },
{ id: 'g', type: SupportedEvent.Block },
{ id: 'h', type: SupportedEvent.Event },
];
it('returns the relevant block', () => {
const block = getPreviousBlock(testRundown as unknown as OntimeRundown, 'h');
expect(block?.id).toBe('g');
});
it('returns the relevant block', () => {
const block = getPreviousBlock(testRundown as unknown as OntimeRundown, 'f');
expect(block?.id).toBe('e');
});
it('returns the relevant block', () => {
const block = getPreviousBlock(testRundown as unknown as OntimeRundown, 'a');
expect(block).toBeNull();
});
it('also works on index 0', () => {
testRundown.unshift({ id: '0', type: SupportedEvent.Block });
const block = getPreviousBlock(testRundown as unknown as OntimeRundown, 'a');
expect(block?.id).toBe('0');
});
});
describe('filterPlayable()', () => {
test('should return an array with only playable events', () => {
const eventA = { id: 'a', type: SupportedEvent.Event } as OntimeEvent;
const eventB = { id: 'b', skip: true, type: SupportedEvent.Event } as OntimeEvent;
const testRundown = [
eventA,
eventB,
{ id: 'c', type: SupportedEvent.Delay },
{ id: 'd', type: SupportedEvent.Block },
];
const result = filterPlayable(testRundown as unknown as OntimeRundown);
expect(result).toMatchObject([eventA]);
});
});
});
+53 -103
View File
@@ -1,19 +1,14 @@
import type {
NormalisedRundown,
OntimeBlock,
OntimeEvent,
OntimeRundown,
OntimeRundownEntry,
PlayableEvent,
} from 'ontime-types';
import { isOntimeBlock, isOntimeEvent, isPlayableEvent } from 'ontime-types';
import type { NormalisedRundown, OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { isOntimeEvent } from 'ontime-types';
type IndexAndEntry = { entry: OntimeRundownEntry | null; index: number | null };
/**
* Gets first event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {OntimeRundownEntry | null}
*/
export function getFirst(rundown: OntimeRundown) {
export function getFirst(rundown: OntimeRundownEntry[]) {
return rundown.length ? rundown[0] : null;
}
@@ -30,14 +25,16 @@ export function getFirstNormal(rundown: NormalisedRundown, order: string[]) {
/**
* Gets first scheduled event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {{ firstEvent: OntimeEvent | null; firstIndex: number | null } }
*/
export function getFirstEvent(rundown: OntimeRundown): {
firstEvent: PlayableEvent | null;
export function getFirstEvent(rundown: OntimeRundownEntry[]): {
firstEvent: OntimeEvent | null;
firstIndex: number | null;
} {
for (let i = 0; i < rundown.length; i++) {
const firstEvent = rundown[i];
if (isOntimeEvent(firstEvent) && isPlayableEvent(firstEvent)) {
if (isOntimeEvent(firstEvent) && !firstEvent.skip) {
return { firstEvent, firstIndex: i };
}
}
@@ -46,18 +43,21 @@ export function getFirstEvent(rundown: OntimeRundown): {
/**
* Gets first scheduled event in a normalised rundown, if it exists
* @param rundown
* @param order
* @returns
*/
export function getFirstEventNormal(
rundown: NormalisedRundown,
order: string[],
): {
firstEvent: PlayableEvent | null;
firstEvent: OntimeEvent | null;
firstIndex: number | null;
} {
for (let i = 0; i < order.length; i++) {
const firstId = order[i];
const firstEvent = rundown[firstId];
if (isOntimeEvent(firstEvent) && isPlayableEvent(firstEvent)) {
if (isOntimeEvent(firstEvent) && !firstEvent.skip) {
return { firstEvent, firstIndex: i };
}
}
@@ -66,6 +66,9 @@ export function getFirstEventNormal(
/**
* Gets last event in a normalised rundown, if it exists
* @param rundown
* @param order
* @returns
*/
export function getLastNormal(rundown: NormalisedRundown, order: string[]): OntimeRundownEntry | null {
const lastId = order.at(-1);
@@ -77,9 +80,11 @@ export function getLastNormal(rundown: NormalisedRundown, order: string[]): Onti
/**
* Gets last scheduled event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {{ firstEvent: OntimeEvent | null; firstIndex: number | null } }
*/
export function getLastEvent(rundown: OntimeRundown): {
lastEvent: PlayableEvent | null;
lastEvent: OntimeEvent | null;
lastIndex: number | null;
} {
if (rundown.length < 1) {
@@ -88,7 +93,7 @@ export function getLastEvent(rundown: OntimeRundown): {
for (let i = rundown.length - 1; i >= 0; i--) {
const lastEvent = rundown.at(i);
if (isOntimeEvent(lastEvent) && isPlayableEvent(lastEvent)) {
if (isOntimeEvent(lastEvent) && !lastEvent.skip) {
return { lastEvent, lastIndex: i };
}
}
@@ -97,6 +102,9 @@ export function getLastEvent(rundown: OntimeRundown): {
/**
* Gets last scheduled event in a normalised rundown, if it exists
* @param rundown
* @param order
* @return {{ firstEvent: OntimeEvent | null; firstIndex: number | null } }
*/
export function getLastEventNormal(
rundown: NormalisedRundown,
@@ -121,9 +129,12 @@ export function getLastEventNormal(
/**
* Gets next entry in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {{ nextEvent: OntimeRundownEntry | null; nextIndex: number | null } }
*/
export function getNext(
rundown: OntimeRundown,
rundown: OntimeRundownEntry[],
currentId: string,
): { nextEvent: OntimeRundownEntry | null; nextIndex: number | null } {
const index = rundown.findIndex((event) => event.id === currentId);
@@ -138,6 +149,10 @@ export function getNext(
/**
* Gets next entry in rundown, if it exists
* @param rundown
* @param order
* @param currentId
* @returns
*/
export function getNextNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry {
const currentIndex = order.findIndex((id) => id === currentId);
@@ -153,9 +168,12 @@ export function getNextNormal(rundown: NormalisedRundown, order: string[], curre
/**
* Gets next scheduled event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {{ nextEvent: OntimeEvent | null; nextIndex: number | null } }
*/
export function getNextEvent(
rundown: OntimeRundown,
rundown: OntimeRundownEntry[],
currentId: string,
): { nextEvent: OntimeEvent | null; nextIndex: number | null } {
const index = rundown.findIndex((event) => event.id === currentId);
@@ -174,6 +192,10 @@ export function getNextEvent(
/**
* Gets next scheduled event in a normalised rundown, if it exists
* @param rundown
* @param order
* @param {string} currentId
* @return {{ nextEvent: OntimeEvent | null; nextIndex: number | null } }
*/
export function getNextEventNormal(
rundown: NormalisedRundown,
@@ -197,8 +219,10 @@ export function getNextEventNormal(
/**
* Gets previous entry in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
*/
export function getPrevious(rundown: OntimeRundown, currentId: string): IndexAndEntry {
export function getPrevious(rundown: OntimeRundownEntry[], currentId: string): IndexAndEntry {
const currentIndex = rundown.findIndex((event) => event.id === currentId);
if (currentIndex !== -1 && currentIndex - 1 >= 0) {
const index = currentIndex - 1;
@@ -211,6 +235,9 @@ export function getPrevious(rundown: OntimeRundown, currentId: string): IndexAnd
/**
* Gets previous entry in a nornalised rundown, if it exists
* @param rundown
* @param order
* @param {string} currentId
*/
export function getPreviousNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry {
const currentIndex = order.findIndex((id) => id === currentId);
@@ -226,9 +253,12 @@ export function getPreviousNormal(rundown: NormalisedRundown, order: string[], c
/**
* Gets previous scheduled event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {{ previousEvent: OntimeRundownEntry | null; previousIndex: number | null } }
*/
export function getPreviousEvent(
rundown: OntimeRundown,
rundown: OntimeRundownEntry[],
currentId: string,
): { previousEvent: OntimeEvent | null; previousIndex: number | null } {
const index = rundown.findIndex((event) => event.id === currentId);
@@ -272,6 +302,8 @@ export function getPreviousEventNormal(
/**
* @description swaps two OntimeEvents in the rundown
* @param {OntimeEvent} eventA
* @param {OntimeEvent} eventB
*/
export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): { newA: OntimeEvent; newB: OntimeEvent } => {
const newA = {
@@ -298,85 +330,3 @@ export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): { newA:
export function getEventWithId(rundown: OntimeRundown, id: string): OntimeRundownEntry | undefined {
return rundown.find((event) => event.id === id);
}
/**
* Gets relevant block element for a given ID
*/
export function getPreviousBlockNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry {
let foundCurrentEvent = false;
// Iterate backwards through the rundown to find the current event
for (let index = order.length - 1; index >= 0; index--) {
const id = order[index];
if (!foundCurrentEvent && id === currentId) {
// set the flag when the current event is found
foundCurrentEvent = true;
continue;
}
// the first block before the current event is the relevant one
const entry = rundown[id];
if (foundCurrentEvent && isOntimeBlock(entry)) {
return { entry, index };
}
}
// no blocks exist before current event
return { entry: null, index: null };
}
/**
* Gets next block element for a given ID
*/
export function getNextBlockNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry {
let foundCurrentEvent = false;
// Iterate backwards through the rundown to find the current event
for (let index = 0; index < order.length; index++) {
const id = order[index];
if (!foundCurrentEvent && id === currentId) {
// set the flag when the current event is found
foundCurrentEvent = true;
continue;
}
// the first block before the current event is the relevant one
const entry = rundown[id];
if (foundCurrentEvent && isOntimeBlock(entry)) {
return { entry, index };
}
}
// no blocks exist before current event
return { entry: null, index: null };
}
/**
* Gets relevant block element for a given ID
*/
export function getPreviousBlock(rundown: OntimeRundown, currentId: string): OntimeBlock | null {
let foundCurrentEvent = false;
// Iterate backwards through the rundown to find the current event
for (let i = rundown.length - 1; i >= 0; i--) {
const entry = rundown[i];
if (!foundCurrentEvent && entry.id === currentId) {
// set the flag when the current event is found
foundCurrentEvent = true;
continue;
}
// the first block before the current event is the relevant one
if (foundCurrentEvent && isOntimeBlock(entry)) {
return entry;
}
}
// no blocks exist before current event
return null;
}
/**
* filters a rundown to timed events
*/
export function filterPlayable(rundown: OntimeRundown): PlayableEvent[] {
return rundown.filter((event) => isOntimeEvent(event) && !event.skip) as PlayableEvent[];
}
/**
* filters a rundown to events that can be played
*/
export function filterTimedEvents(rundown: OntimeRundown): OntimeEvent[] {
return rundown.filter((event) => isOntimeEvent(event)) as OntimeEvent[];
}
@@ -1,13 +1,13 @@
import { Playback, TimerPhase } from 'ontime-types';
import { Playback } from 'ontime-types';
/**
* Simple rules to determine whether a playback action is valid
*/
export function validatePlayback(currentPlayback: Playback, timerPhase: TimerPhase) {
export function validatePlayback(currentPlayback: Playback) {
return {
start: currentPlayback !== Playback.Stop && currentPlayback !== Playback.Play,
start: currentPlayback !== Playback.Stop,
pause: currentPlayback === Playback.Play,
roll: currentPlayback !== Playback.Roll && timerPhase !== TimerPhase.Overtime,
roll: currentPlayback !== Playback.Roll,
stop: currentPlayback !== Playback.Stop,
reload: currentPlayback !== Playback.Stop && currentPlayback !== Playback.Roll,
};
+538 -538
View File
File diff suppressed because it is too large Load Diff