diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts
index e4cab2010..6bbd8634f 100644
--- a/apps/client/src/common/hooks/useSocket.ts
+++ b/apps/client/src/common/hooks/useSocket.ts
@@ -1,4 +1,4 @@
-import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
+import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket';
@@ -160,7 +160,7 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
- offset: state.runtime.offset,
+ offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
currentBlock: state.currentBlock,
}));
@@ -172,8 +172,11 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
clock: state.clock,
- offset: state.runtime.offset,
+ offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
+ offsetMode: state.runtime.offsetMode,
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
+ actualStart: state.runtime.actualStart,
+ plannedStart: state.runtime.plannedStart,
}));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
@@ -189,6 +192,12 @@ export const useIsOnline = createSelector((state: RuntimeStore) => ({
isOnline: state.ping > 0,
}));
+export const useOffsetMode = createSelector((state: RuntimeStore) => ({
+ offsetMode: state.runtime.offsetMode,
+}));
+
+export const setOffsetMode = (payload: OffsetMode) => socketSendJson('offsetmode', payload);
+
export const usePlayback = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
diff --git a/apps/client/src/common/utils/__tests__/time.test.ts b/apps/client/src/common/utils/__tests__/time.test.ts
index beb195e5a..a518ac0e5 100644
--- a/apps/client/src/common/utils/__tests__/time.test.ts
+++ b/apps/client/src/common/utils/__tests__/time.test.ts
@@ -1,3 +1,6 @@
+import { OffsetMode } from 'ontime-types';
+import { dayInMs } from 'ontime-utils';
+
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
describe('nowInMillis()', () => {
@@ -40,82 +43,255 @@ describe('formatTime()', () => {
});
describe('calculateTimeUntilStart()', () => {
- test('ontime', () => {
+ describe('Absolute offset mode', () => {
+ test('ontime', () => {
+ const test = {
+ timeStart: 100,
+ dayOffset: 0,
+ delay: 0,
+ currentDay: 0,
+ totalGap: 0,
+ clock: 90,
+ offset: 0,
+ offsetMode: OffsetMode.Absolute,
+ actualStart: null,
+ plannedStart: null,
+ };
+
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
+ });
+
+ test('running behind', () => {
+ const test = {
+ timeStart: 100,
+ dayOffset: 0,
+ delay: 0,
+ currentDay: 0,
+ totalGap: 0,
+ clock: 90,
+ offset: -20,
+ offsetMode: OffsetMode.Absolute,
+ actualStart: null,
+ plannedStart: null,
+ };
+
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
+ });
+
+ test('running ahead', () => {
+ const test = {
+ timeStart: 100,
+ dayOffset: 0,
+ delay: 0,
+ currentDay: 0,
+ totalGap: 0,
+ clock: 80,
+ offset: 10,
+ offsetMode: OffsetMode.Absolute,
+ actualStart: null,
+ plannedStart: null,
+ };
+
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
+ });
+
+ test('running behind with enough gaps', () => {
+ const test = {
+ timeStart: 100,
+ dayOffset: 0,
+ delay: 0,
+ currentDay: 0,
+ totalGap: 20,
+ clock: 50,
+ offset: -20,
+ offsetMode: OffsetMode.Absolute,
+ actualStart: null,
+ plannedStart: null,
+ };
+
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50); // <-- when gap is enough to compensate for the running behind
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
+ });
+
+ test('running behind with too little gaps', () => {
+ const test = {
+ timeStart: 100,
+ dayOffset: 0,
+ delay: 0,
+ currentDay: 0,
+ totalGap: 10,
+ clock: 50,
+ offset: -20,
+ offsetMode: OffsetMode.Absolute,
+ actualStart: 0,
+ plannedStart: 0,
+ };
+
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
+ });
+ });
+
+ describe('Relative offset mode', () => {
+ test('basic function', () => {
+ const test = {
+ timeStart: 0,
+ dayOffset: 0,
+ delay: 0,
+ currentDay: 0,
+ totalGap: 0,
+ clock: 100,
+ actualStart: 100,
+ plannedStart: 0,
+ offset: 0,
+ offsetMode: OffsetMode.Relative,
+ };
+
+ const timeStartEvent2 = 10;
+ const timeStartEvent3 = 20;
+
+ //event 1 is the currently running event
+
+ //event 2
+ expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(10);
+ expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(10);
+
+ //event 3
+ expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(20);
+ expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(20);
+
+ // When clock advances by 5ms, time until start should decrease by 5ms
+ test.clock = 105;
+
+ //event 2
+ expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(5);
+ expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(5);
+
+ //event 3
+ expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(15);
+ expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(15);
+ });
+
+ test('gaps', () => {
+ const test = {
+ timeStart: 20,
+ dayOffset: 0,
+ delay: 0,
+ currentDay: 0,
+ totalGap: 10,
+ clock: 100,
+ actualStart: 100,
+ plannedStart: 0,
+ offset: 0,
+ offsetMode: OffsetMode.Relative,
+ };
+
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
+
+ // When clock advances by 5ms, time until start should decrease by 5ms
+ test.clock = 105;
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(15);
+ });
+
+ test('added/remove time', () => {
+ const test = {
+ timeStart: 20,
+ dayOffset: 0,
+ delay: 0,
+ currentDay: 0,
+ totalGap: 0,
+ clock: 100,
+ actualStart: 100,
+ plannedStart: 0,
+ offset: 0,
+ offsetMode: OffsetMode.Relative,
+ };
+
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
+
+ test.offset = 5; // remove 5 with addtime - we are ahead of time
+
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // unlocked evets will stay on schedule
+
+ test.offset = -5; // add 5 with addtime - we are behind
+
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(25);
+ expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(25);
+ });
+
+ test('next day', () => {
+ const test = {
+ delay: 0,
+ currentDay: 0,
+ clock: 100,
+ actualStart: 100,
+ plannedStart: 0,
+ offset: 0,
+ offsetMode: OffsetMode.Relative,
+ };
+
+ // this event will start the current day
+ expect(
+ calculateTimeUntilStart({
+ ...test,
+ timeStart: 10,
+ dayOffset: 0,
+ totalGap: 0,
+ isLinkedToLoaded: false,
+ }),
+ ).toBe(10);
+
+ // this event will start the next day
+ // in absolute mode this would start in dayInMs - 100 since the gap would compensate
+ // but in relative mode with and actual start that is 100 offset it starts in dayInMs
+ expect(
+ calculateTimeUntilStart({
+ ...test,
+ timeStart: 0,
+ dayOffset: 1,
+ totalGap: dayInMs - 20,
+ isLinkedToLoaded: false,
+ }),
+ ).toBe(dayInMs);
+
+ // advancing 100ms
+ test.clock = 200;
+
+ expect(
+ calculateTimeUntilStart({
+ ...test,
+ timeStart: 0,
+ dayOffset: 1,
+ totalGap: dayInMs - 20,
+ isLinkedToLoaded: false,
+ }),
+ ).toBe(dayInMs - 100);
+ });
+ });
+
+ test('overlap with negative total gap', () => {
const test = {
- timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
- totalGap: 0,
- clock: 90,
+ clock: 100,
+ actualStart: 100,
+ plannedStart: 0,
offset: 0,
+ offsetMode: OffsetMode.Relative,
+ isLinkedToLoaded: false,
};
- expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
- expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
+ // the overlap will be pushed out to the expected available time
+ expect(calculateTimeUntilStart({ ...test, timeStart: 5, totalGap: -5 })).toBe(10);
+
+ test.clock = 105;
});
-
- test('running behind', () => {
- const test = {
- timeStart: 100,
- dayOffset: 0,
- delay: 0,
- currentDay: 0,
- totalGap: 0,
- clock: 90,
- offset: -20,
- };
-
- expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
- expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
- });
-
- test('running ahead', () => {
- const test = {
- timeStart: 100,
- dayOffset: 0,
- delay: 0,
- currentDay: 0,
- totalGap: 0,
- clock: 80,
- offset: 10,
- };
-
- expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
- expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
- });
-
- test('running behind with enough gaps', () => {
- const test = {
- timeStart: 100,
- dayOffset: 0,
- delay: 0,
- currentDay: 0,
- totalGap: 20,
- clock: 50,
- offset: -20,
- };
-
- expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50);
- expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
- });
-
- test('running behind with to little gaps', () => {
- const test = {
- timeStart: 100,
- dayOffset: 0,
- delay: 0,
- currentDay: 0,
- totalGap: 10,
- clock: 50,
- offset: -20,
- };
-
- expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60);
- expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
- });
-
- //TODO: more indepth testing,
- // including day offset handling
- // and more?
});
diff --git a/apps/client/src/common/utils/time.ts b/apps/client/src/common/utils/time.ts
index 4c5ee9ed7..fc1c42dce 100644
--- a/apps/client/src/common/utils/time.ts
+++ b/apps/client/src/common/utils/time.ts
@@ -1,4 +1,4 @@
-import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
+import { MaybeNumber, OffsetMode, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
@@ -140,8 +140,8 @@ export function useTimeUntilStart(
isLinkedToLoaded: boolean;
},
): number {
- const { offset, clock, currentDay } = useTimeUntilData();
- return calculateTimeUntilStart({ ...data, currentDay, clock, offset });
+ const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData();
+ return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart });
}
/**
@@ -160,9 +160,24 @@ export function calculateTimeUntilStart(
isLinkedToLoaded: boolean;
clock: number;
offset: number;
+ offsetMode: OffsetMode;
+ actualStart: MaybeNumber;
+ plannedStart: MaybeNumber;
},
): number {
- const { timeStart, dayOffset, currentDay, totalGap, isLinkedToLoaded, clock, offset, delay } = data;
+ const {
+ timeStart,
+ dayOffset,
+ currentDay,
+ totalGap,
+ isLinkedToLoaded,
+ clock,
+ offset,
+ delay,
+ offsetMode,
+ actualStart,
+ plannedStart,
+ } = data;
//How many days from the currently running event to this one
const relativeDayOffset = dayOffset - currentDay;
@@ -172,20 +187,23 @@ export function calculateTimeUntilStart(
//The normalised start time of this event relative to the currently running event
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
- const offsetTimestart = normalisedTimeStart - offset;
- const offsetTimeUntil = offsetTimestart - clock;
+ let relativeStartOffset = 0;
+
+ if (offsetMode === OffsetMode.Relative) {
+ relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0);
+ }
+
+ const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset;
+
+ const offsetTimeUntil = scheduledTimeUntil - offset;
if (isLinkedToLoaded) {
//if we are directly linked back to the loaded event we just follow the offset
return offsetTimeUntil;
}
- const scheduledTimeUntil = normalisedTimeStart - clock;
-
- const isAheadOfSchedule = offset >= 0;
- const gapsCanCompensadeForOffset = totalGap + offset >= 0;
-
- if (isAheadOfSchedule || gapsCanCompensadeForOffset) {
+ const gapsCanCompensateForOffset = totalGap + offset >= 0;
+ if (gapsCanCompensateForOffset) {
// if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time
return scheduledTimeUntil;
}
diff --git a/apps/client/src/features/overview/Overview.tsx b/apps/client/src/features/overview/Overview.tsx
index 1e10a82d7..a2eee0ea8 100644
--- a/apps/client/src/features/overview/Overview.tsx
+++ b/apps/client/src/features/overview/Overview.tsx
@@ -170,7 +170,7 @@ function RuntimeOverview() {
return (
<>
-
+
>
);
diff --git a/apps/client/src/features/overview/composite/TimeLayout.tsx b/apps/client/src/features/overview/composite/TimeLayout.tsx
index 181af8da1..a1a19cd9a 100644
--- a/apps/client/src/features/overview/composite/TimeLayout.tsx
+++ b/apps/client/src/features/overview/composite/TimeLayout.tsx
@@ -10,13 +10,16 @@ interface TimeLayoutProps {
muted?: boolean;
daySpan?: number;
className?: string;
+ testId?: string;
}
-export function TimeColumn({ label, value, muted, className }: TimeLayoutProps) {
+export function TimeColumn({ label, value, muted, className, testId }: TimeLayoutProps) {
return (
{label}
- {value}
+
+ {value}
+
);
}
diff --git a/apps/client/src/features/rundown/rundown-header/RundownHeader.tsx b/apps/client/src/features/rundown/rundown-header/RundownHeader.tsx
index 7ca1b20eb..deb5fdaaa 100644
--- a/apps/client/src/features/rundown/rundown-header/RundownHeader.tsx
+++ b/apps/client/src/features/rundown/rundown-header/RundownHeader.tsx
@@ -1,5 +1,7 @@
import { Button, ButtonGroup } from '@chakra-ui/react';
+import { OffsetMode } from 'ontime-types';
+import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
import RundownMenu from './RundownMenu';
@@ -12,6 +14,8 @@ export default function RundownHeader() {
const setRunMode = () => setAppMode(AppMode.Run);
const setEditMode = () => setAppMode(AppMode.Edit);
+ const { offsetMode } = useOffsetMode();
+
return (
@@ -22,6 +26,22 @@ export default function RundownHeader() {
Edit
+
+
+
+
);
diff --git a/apps/server/src/api-integration/integration.controller.ts b/apps/server/src/api-integration/integration.controller.ts
index ded76835e..73a6e3ac1 100644
--- a/apps/server/src/api-integration/integration.controller.ts
+++ b/apps/server/src/api-integration/integration.controller.ts
@@ -1,4 +1,4 @@
-import { MessageState, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
+import { MessageState, OffsetMode, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils';
import { DeepPartial } from 'ts-essentials';
@@ -17,6 +17,7 @@ import { throttle } from '../utils/throttle.js';
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
import { handleLegacyMessageConversion } from './integration.legacy.js';
+import { coerceEnum } from '../utils/coerceType.js';
const throttledUpdateEvent = throttle(updateEvent, 20);
let lastRequest: Date | null = null;
@@ -286,6 +287,11 @@ const actionHandlers: Record = {
throw new Error('No matching method provided');
},
+ offsetmode: (payload) => {
+ const mode = coerceEnum(payload, OffsetMode);
+ runtimeService.setOffsetMode(mode);
+ return { payload: 'success' };
+ },
};
/**
diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts
index a418e7fd3..07d7f9ed9 100644
--- a/apps/server/src/services/__tests__/timerUtils.test.ts
+++ b/apps/server/src/services/__tests__/timerUtils.test.ts
@@ -4,6 +4,7 @@ import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime
import {
getCurrent,
getExpectedFinish,
+ getRelativeOffset,
getRuntimeOffset,
getTimerPhase,
normaliseEndTime,
@@ -1046,6 +1047,84 @@ describe('getRuntimeOffset()', () => {
});
});
+describe('getRelativeOffset()', () => {
+ it('relative offset is 0 when starting at the planed time', () => {
+ const state = {
+ eventNow: {
+ id: '1',
+ timeStart: 150,
+ },
+ timer: {
+ startedAt: 150,
+ addedTime: 0,
+ current: 0,
+ },
+ _timer: {
+ pausedAt: null,
+ },
+ runtime: {
+ actualStart: 150,
+ plannedStart: 150,
+ },
+ } as RuntimeState;
+
+ state.runtime.offset = getRuntimeOffset(state);
+ expect(state.runtime.offset).toBe(0);
+ const relativeOffsetoffset = getRelativeOffset(state);
+ expect(relativeOffsetoffset).toBe(0);
+ });
+ it('relative offset is 0 when starting after the planed time', () => {
+ const state = {
+ eventNow: {
+ id: '1',
+ timeStart: 100,
+ },
+ timer: {
+ startedAt: 150,
+ addedTime: 0,
+ current: 0,
+ },
+ _timer: {
+ pausedAt: null,
+ },
+ runtime: {
+ actualStart: 150,
+ plannedStart: 100,
+ },
+ } as RuntimeState;
+
+ state.runtime.offset = getRuntimeOffset(state);
+ expect(state.runtime.offset).toBe(-50);
+ const relativeOffsetoffset = getRelativeOffset(state);
+ expect(relativeOffsetoffset).toBe(0);
+ });
+ it('relative offset is 0 when starting before the planed time', () => {
+ const state = {
+ eventNow: {
+ id: '1',
+ timeStart: 150,
+ },
+ timer: {
+ startedAt: 100,
+ addedTime: 0,
+ current: 0,
+ },
+ _timer: {
+ pausedAt: null,
+ },
+ runtime: {
+ actualStart: 100,
+ plannedStart: 150,
+ },
+ } as RuntimeState;
+
+ state.runtime.offset = getRuntimeOffset(state);
+ expect(state.runtime.offset).toBe(50);
+ const relativeOffsetoffset = getRelativeOffset(state);
+ expect(relativeOffsetoffset).toBe(0);
+ });
+});
+
describe('getTimerPhase()', () => {
it('should be None if the timer is not running', () => {
const state = {
@@ -1169,9 +1248,11 @@ describe('getTimerPhase()', () => {
},
_timer: {
forceFinish: null,
- totalDelay: 0,
pausedAt: null,
},
+ _rundown: {
+ totalDelay: 0,
+ },
} as RuntimeState;
const phase = getTimerPhase(state);
@@ -1208,9 +1289,11 @@ describe('getTimerPhase()', () => {
},
_timer: {
forceFinish: null,
- totalDelay: 0,
pausedAt: null,
},
+ _rundown: {
+ totalDelay: 0,
+ },
} as RuntimeState;
const phase = getTimerPhase(state);
diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts
index 1dc486992..46232b82a 100644
--- a/apps/server/src/services/runtime-service/RuntimeService.ts
+++ b/apps/server/src/services/runtime-service/RuntimeService.ts
@@ -4,6 +4,7 @@ import {
isPlayableEvent,
LogOrigin,
MaybeNumber,
+ OffsetMode,
OntimeEvent,
Playback,
TimerLifeCycle,
@@ -68,6 +69,11 @@ class RuntimeService {
RuntimeService.previousState = {} as RuntimeState;
}
+ @broadcastResult
+ setOffsetMode(mode: OffsetMode) {
+ runtimeState.setOffsetMode(mode);
+ }
+
/**
* Checks result of an update and notifies integrations as needed
* This is the only exception of a private method that has broadcast result
@@ -697,11 +703,14 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
// for the very fist run there will be nothing in the previousState so we force an update
const justStarted = !RuntimeService.previousState?.timer;
+ // offset mode has been changed
+ const offsetModeChanged = RuntimeService.previousState?.runtime?.offsetMode !== state.runtime.offsetMode;
+
// if playback changes most things should update
const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback;
// combine all big changes
- const hasImmediateChanges = hasNewLoaded || justStarted || hasChangedPlayback;
+ const hasImmediateChanges = hasNewLoaded || justStarted || hasChangedPlayback || offsetModeChanged;
/**
* Timer should be updated if
diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts
index 2cc59c4ea..003f76314 100644
--- a/apps/server/src/services/timerUtils.ts
+++ b/apps/server/src/services/timerUtils.ts
@@ -156,6 +156,23 @@ export function getRuntimeOffset(state: RuntimeState): number {
return startOffset - addedTime - pausedTime + overtime;
}
+/**
+ * Calculates relative offset
+ * should always be calculated after the absolute offset
+ */
+export function getRelativeOffset(state: RuntimeState): number {
+ const { actualStart, plannedStart, offset } = state.runtime;
+ // eslint-disable-next-line no-unused-labels -- dev code path
+ DEV: {
+ // we know actualStart and plannedStart exists as long as a timer is running
+ if (actualStart === null || plannedStart === null) {
+ throw new Error('timerUtils.calculate: actualStart and plannedStart must be set');
+ }
+ }
+ const relativeStartOffset = actualStart - plannedStart;
+ return offset + relativeStartOffset;
+}
+
/**
* Calculates the expected end of the rundown
*/
@@ -164,7 +181,7 @@ export function getExpectedEnd(state: RuntimeState): MaybeNumber {
if (state.runtime.actualStart === null || state.runtime.plannedEnd === null) {
return null;
}
- return state.runtime.plannedEnd - state.runtime.offset + state._timer.totalDelay;
+ return state.runtime.plannedEnd - state.runtime.offset + state._rundown.totalDelay;
}
/**
diff --git a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts
index 7c4d96839..074aa08d7 100644
--- a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts
+++ b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts
@@ -1,4 +1,4 @@
-import { TimerPhase, Playback } from 'ontime-types';
+import { TimerPhase, Playback, OffsetMode } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import type { RuntimeState } from '../runtimeState.js';
@@ -16,10 +16,12 @@ const baseState: RuntimeState = {
selectedEventIndex: null,
numEvents: 0,
offset: 0,
+ relativeOffset: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
expectedEnd: null,
+ offsetMode: OffsetMode.Absolute,
},
timer: {
addedTime: 0,
@@ -35,10 +37,12 @@ const baseState: RuntimeState = {
},
_timer: {
forceFinish: null,
- totalDelay: 0,
pausedAt: null,
secondaryTarget: null,
},
+ _rundown: {
+ totalDelay: 0,
+ },
};
export function makeRuntimeStateData(patch?: Partial): RuntimeState {
diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts
index 2abae6b89..b385e6588 100644
--- a/apps/server/src/stores/__tests__/runtimeState.test.ts
+++ b/apps/server/src/stores/__tests__/runtimeState.test.ts
@@ -4,7 +4,7 @@ import { deepmerge } from 'ontime-utils';
import {
type RuntimeState,
addTime,
- clear,
+ clearState,
getState,
load,
loadBlock,
@@ -71,7 +71,7 @@ beforeAll(() => {
describe('mutation on runtimeState', () => {
beforeEach(() => {
- clear();
+ clearState();
vi.mock('../../services/rundown-service/RundownService.js', async (importOriginal) => {
const actual = (await importOriginal()) as object;
@@ -246,7 +246,7 @@ describe('roll mode', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime('jan 1 00:00');
- clear();
+ clearState();
});
afterEach(() => {
vi.useRealTimers();
diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts
index 3c01871e1..269d9012b 100644
--- a/apps/server/src/stores/runtimeState.ts
+++ b/apps/server/src/stores/runtimeState.ts
@@ -3,6 +3,7 @@ import {
isPlayableEvent,
MaybeNumber,
MaybeString,
+ OffsetMode,
OntimeEvent,
OntimeRundown,
PlayableEvent,
@@ -27,6 +28,7 @@ import {
getCurrent,
getExpectedEnd,
getExpectedFinish,
+ getRelativeOffset,
getRuntimeOffset,
getTimerPhase,
} from '../services/timerUtils.js';
@@ -45,10 +47,12 @@ export type RuntimeState = {
// private properties of the timer calculations
_timer: {
forceFinish: MaybeNumber; // whether we should declare an event as finished, will contain the finish time
- totalDelay: number; // this value comes from rundown service
pausedAt: MaybeNumber;
secondaryTarget: MaybeNumber;
};
+ _rundown: {
+ totalDelay: number; // this value comes from rundown service
+ };
};
const runtimeState: RuntimeState = {
@@ -62,10 +66,12 @@ const runtimeState: RuntimeState = {
timer: { ...runtimeStorePlaceholder.timer },
_timer: {
forceFinish: null,
- totalDelay: 0,
pausedAt: null,
secondaryTarget: null,
},
+ _rundown: {
+ totalDelay: 0,
+ },
};
export function getState(): Readonly {
@@ -79,10 +85,36 @@ export function getState(): Readonly {
runtime: { ...runtimeState.runtime },
timer: { ...runtimeState.timer },
_timer: { ...runtimeState._timer },
+ _rundown: { ...runtimeState._rundown },
};
}
-export function clear() {
+/* clear data related to the current event, but leav in place data about the global run state
+ * used when loading a new event but the playback is not interrupted
+ */
+export function clearEventData() {
+ runtimeState.eventNow = null;
+ runtimeState.publicEventNow = null;
+ runtimeState.eventNext = null;
+ runtimeState.publicEventNext = null;
+
+ runtimeState.runtime.offset = 0;
+ runtimeState.runtime.relativeOffset = 0;
+ runtimeState.runtime.expectedEnd = null;
+ runtimeState.runtime.selectedEventIndex = null;
+
+ runtimeState.timer.playback = Playback.Stop;
+ runtimeState.clock = clock.timeNow();
+ runtimeState.timer = { ...runtimeStorePlaceholder.timer };
+
+ // when clearing, we maintain the total delay from the rundown
+ runtimeState._timer.forceFinish = null;
+ runtimeState._timer.pausedAt = null;
+ runtimeState._timer.secondaryTarget = null;
+}
+
+// clear all necessary data when doing a full stop and the event is unloaded
+export function clearState() {
runtimeState.eventNow = null;
runtimeState.publicEventNow = null;
runtimeState.eventNext = null;
@@ -93,6 +125,7 @@ export function clear() {
runtimeState.publicEventNext = null;
runtimeState.runtime.offset = 0;
+ runtimeState.runtime.relativeOffset = 0;
runtimeState.runtime.actualStart = null;
runtimeState.runtime.expectedEnd = null;
runtimeState.runtime.selectedEventIndex = null;
@@ -137,7 +170,7 @@ type RundownData = {
*/
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._rundown.totalDelay = rundownData.totalDelay;
runtimeState.runtime.numEvents = rundownData.numEvents;
runtimeState.runtime.plannedStart = rundownData.firstStart;
@@ -154,10 +187,7 @@ export function load(
rundown: OntimeRundown,
initialData?: Partial,
): boolean {
- // we need to persist the current block state across loads
- const prevCurrentBlock = { ...runtimeState.currentBlock };
- clear();
- runtimeState.currentBlock = prevCurrentBlock;
+ clearEventData();
// filter rundown
const timedEvents = filterTimedEvents(rundown);
@@ -185,6 +215,7 @@ export function load(
if (firstStart === null || typeof firstStart === 'number') {
runtimeState.runtime.actualStart = firstStart;
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
+ runtimeState.runtime.relativeOffset = getRelativeOffset(runtimeState);
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
}
if (typeof initialData.blockStartAt === 'number') {
@@ -390,6 +421,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
// update offset
state.runtime.offset = getRuntimeOffset(state);
+ state.runtime.relativeOffset = getRelativeOffset(state);
state.runtime.expectedEnd = state.runtime.plannedEnd - state.runtime.offset;
return true;
}
@@ -409,9 +441,7 @@ export function stop(state: RuntimeState = runtimeState): boolean {
if (state.timer.playback === Playback.Stop) {
return false;
}
- clear();
- runtimeState.runtime.actualStart = null;
- runtimeState.runtime.expectedEnd = null;
+ clearState();
return true;
}
@@ -453,6 +483,7 @@ export function addTime(amount: number) {
// update runtime delays: over - under
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
+ runtimeState.runtime.relativeOffset = getRelativeOffset(runtimeState);
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
return true;
@@ -498,6 +529,7 @@ export function update(): UpdateResult {
// update runtime, needs up-to-date timer state
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
+ runtimeState.runtime.relativeOffset = getRelativeOffset(runtimeState);
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
const finishedNow =
@@ -604,9 +636,7 @@ export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString
}
// we need to persist the current block state across loads
- const prevCurrentBlock = { ...runtimeState.currentBlock };
- clear();
- runtimeState.currentBlock = prevCurrentBlock;
+ clearEventData();
//account for offset but we only keep it if passed to us
runtimeState.runtime.offset = offset;
@@ -696,3 +726,7 @@ export function loadBlock(rundown: OntimeRundown, state = runtimeState) {
// update the block anyway
state.currentBlock.block = newCurrentBlock === null ? null : { ...newCurrentBlock };
}
+
+export function setOffsetMode(mode: OffsetMode) {
+ runtimeState.runtime.offsetMode = mode;
+}
diff --git a/e2e/tests/features/212-time-until.spec.ts b/e2e/tests/features/212-time-until.spec.ts
index cf546b92a..81b5beb40 100644
--- a/e2e/tests/features/212-time-until.spec.ts
+++ b/e2e/tests/features/212-time-until.spec.ts
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
-test('linked time until', async ({ page }) => {
+test('time until absolute', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
await page.getByRole('button', { name: 'Clear rundown' }).click();
@@ -11,7 +11,46 @@ test('linked time until', async ({ page }) => {
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
+ await page.getByRole('button', { name: 'Absolute' }).click();
await page.getByTestId('entry-1').getByLabel('Start event').click();
+ await expect(page.getByTestId('offset')).not.toContainText('00:00:00'); // This might be a bad test requires that the test is not run at 0h
+ await page.getByLabel('Pause event').click();
+
+ await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('9m');
+ await expect(page.getByTestId('entry-3').locator('#event-block')).toContainText('19m');
+ await expect(page.getByTestId('entry-4').locator('#event-block')).toContainText('29m');
+
+ await page.getByTestId('entry-1').getByTestId('time-input-duration').click();
+ await page.getByTestId('entry-1').getByTestId('time-input-duration').fill('6h');
+ await page.getByTestId('entry-1').getByTestId('time-input-duration').press('Enter');
+
+ await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('5h59m');
+ await expect(page.getByTestId('entry-3').locator('#event-block')).toContainText('6h9m');
+ await expect(page.getByTestId('entry-4').locator('#event-block')).toContainText('6h19m');
+
+ await page.getByTestId('entry-1').getByTestId('time-input-duration').click();
+ await page.getByTestId('entry-1').getByTestId('time-input-duration').fill('30s');
+ await page.getByTestId('entry-1').getByTestId('time-input-duration').press('Enter');
+
+ await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('29s');
+ await expect(page.getByTestId('entry-3').locator('#event-block')).toContainText('10m');
+ await expect(page.getByTestId('entry-4').locator('#event-block')).toContainText('20m');
+});
+
+test('time until relative', async ({ page }) => {
+ await page.goto('http://localhost:4001/editor');
+
+ await page.getByRole('button', { name: 'Clear rundown' }).click();
+ await page.getByRole('button', { name: 'Delete all' }).click();
+
+ await page.getByRole('button', { name: 'Create Event' }).click();
+ await page.getByRole('button', { name: 'Event' }).nth(4).click();
+ await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
+ await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
+
+ await page.getByRole('button', { name: 'Relative' }).click();
+ await page.getByTestId('entry-1').getByLabel('Start event').click();
+ await expect(page.getByTestId('offset')).toContainText('00:00:00'); // This might be a bad test as it ruires the evaluation to happen within 1s
await page.getByLabel('Pause event').click();
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('9m');
diff --git a/packages/types/src/definitions/runtime/Runtime.type.ts b/packages/types/src/definitions/runtime/Runtime.type.ts
index 1fc669d70..f19e36700 100644
--- a/packages/types/src/definitions/runtime/Runtime.type.ts
+++ b/packages/types/src/definitions/runtime/Runtime.type.ts
@@ -1,11 +1,18 @@
import type { MaybeNumber } from '../../utils/utils.type.js';
+export enum OffsetMode {
+ Absolute = 'absolute',
+ Relative = 'relative',
+}
+
export type Runtime = {
numEvents: number;
selectedEventIndex: MaybeNumber;
offset: number;
+ relativeOffset: number;
plannedStart: MaybeNumber;
actualStart: MaybeNumber;
plannedEnd: MaybeNumber;
expectedEnd: MaybeNumber;
+ offsetMode: OffsetMode;
};
diff --git a/packages/types/src/definitions/runtime/RuntimeStore.ts b/packages/types/src/definitions/runtime/RuntimeStore.ts
index e9674ace4..47f32a1d0 100644
--- a/packages/types/src/definitions/runtime/RuntimeStore.ts
+++ b/packages/types/src/definitions/runtime/RuntimeStore.ts
@@ -1,9 +1,10 @@
import { SimpleDirection, SimplePlayback } from './AuxTimer.type.js';
import { Playback } from './Playback.type.js';
+import { OffsetMode } from './Runtime.type.js';
import type { RuntimeStore } from './RuntimeStore.type.js';
import { TimerPhase } from './TimerState.type.js';
-export const runtimeStorePlaceholder: RuntimeStore = {
+export const runtimeStorePlaceholder: Readonly = {
clock: 0,
timer: {
addedTime: 0,
@@ -32,10 +33,12 @@ export const runtimeStorePlaceholder: RuntimeStore = {
selectedEventIndex: null, // changes if rundown changes or we load a new event
numEvents: 0, // change initiated by user
offset: 0, // changes at runtime
+ relativeOffset: 0, // changes at runtime
plannedStart: 0, // only changes if event changes
plannedEnd: 0, // only changes if event changes, overflows over dayInMs
actualStart: null, // set once we start the timer
expectedEnd: null, // changes with runtime, based on offset, overflows over dayInMs
+ offsetMode: OffsetMode.Absolute,
},
currentBlock: {
block: null,
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 1360b8b91..781d09736 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -87,6 +87,7 @@ export { TimerLifeCycle, timerLifecycleValues } from './definitions/core/TimerLi
export type { TimerMessage, MessageState, SecondarySource } from './definitions/runtime/MessageControl.type.js';
export type { Runtime } from './definitions/runtime/Runtime.type.js';
+export { OffsetMode } from './definitions/runtime/Runtime.type.js';
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js';
export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';