From dce87c3a8134c4f33ff447399a8a6f42acbede0b Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Sat, 19 Apr 2025 21:36:54 +0200 Subject: [PATCH] Relative run mode (#1512) * inti relative mode * send imediat update on mode change * add relative to test * don't persist offset mode * add test relative and update calc function * pass data from ssocket * add dev guard * spelling and comments * show relative in offset overview * remove comments * refactor: move totalDelay to _rundown * update clear naming and comments * remove old testing values --- apps/client/src/common/hooks/useSocket.ts | 15 +- .../src/common/utils/__tests__/time.test.ts | 316 ++++++++++++++---- apps/client/src/common/utils/time.ts | 42 ++- .../client/src/features/overview/Overview.tsx | 2 +- .../overview/composite/TimeLayout.tsx | 7 +- .../rundown/rundown-header/RundownHeader.tsx | 20 ++ .../api-integration/integration.controller.ts | 8 +- .../src/services/__tests__/timerUtils.test.ts | 87 ++++- .../runtime-service/RuntimeService.ts | 11 +- apps/server/src/services/timerUtils.ts | 19 +- .../stores/__mocks__/runtimeState.mocks.ts | 8 +- .../src/stores/__tests__/runtimeState.test.ts | 6 +- apps/server/src/stores/runtimeState.ts | 62 +++- e2e/tests/features/212-time-until.spec.ts | 41 ++- .../src/definitions/runtime/Runtime.type.ts | 7 + .../src/definitions/runtime/RuntimeStore.ts | 5 +- packages/types/src/index.ts | 1 + 17 files changed, 543 insertions(+), 114 deletions(-) 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';