Compare commits

...

11 Commits

Author SHA1 Message Date
arc-alex 832457620e refactor: move totalDelay to _rundown 2025-04-03 12:53:38 +02:00
arc-alex 9feec76858 remove comments 2025-04-02 11:40:31 +02:00
arc-alex 05e3c8a9f8 show relative in offset overview 2025-03-25 02:04:54 +08:00
arc-alex 08c580f773 spelling and comments 2025-03-25 01:47:39 +08:00
arc-alex c33cbbea7d add dev guard 2025-03-23 18:39:03 +08:00
arc-alex 7c642f3832 pass data from ssocket 2025-03-20 16:04:44 +01:00
arc-alex 729c68e9e2 add test relative and update calc function 2025-03-20 15:42:31 +01:00
arc-alex 932bb8102c don't persist offset mode 2025-03-19 10:48:49 +01:00
arc-alex ee39bc296f add relative to test 2025-03-19 10:48:48 +01:00
arc-alex 8cda9eac0a send imediat update on mode change 2025-03-19 10:48:48 +01:00
arc-alex 140a3310cb inti relative mode 2025-03-19 10:48:48 +01:00
17 changed files with 554 additions and 115 deletions
+12 -3
View File
@@ -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,
@@ -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,265 @@ 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, // relative
// offset: -100, // absolute
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, // relative
// offset: -100, // absolute
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, // relative
// offset: -100, // absolute
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 = {
// timeStart: 10,
// dayOffset: 0,
delay: 0,
currentDay: 0,
// totalGap: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0, // relative
// offset: -100, // absolute
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,
// timeStart: 20,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: 0,
// totalGap: 10,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0, // relative
// offset: -100, // absolute
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?
});
+30 -12
View File
@@ -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;
}
@@ -170,7 +170,7 @@ function RuntimeOverview() {
return (
<>
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} />
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} testId='offset' />
<TimeColumn label='Time now' value={formatedTime(clock)} />
</>
);
@@ -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 (
<div className={style.column}>
<span className={style.label}>{label}</span>
<span className={cx([style.clock, muted && style.muted, className])}>{value}</span>
<span className={cx([style.clock, muted && style.muted, className])} data-testid={testId}>
{value}
</span>
</div>
);
}
@@ -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 (
<div className={style.header}>
<ButtonGroup isAttached>
@@ -22,6 +26,22 @@ export default function RundownHeader() {
Edit
</Button>
</ButtonGroup>
<ButtonGroup isAttached>
<Button
size='sm'
variant={offsetMode === OffsetMode.Absolute ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => setOffsetMode(OffsetMode.Absolute)}
>
Absolute
</Button>
<Button
size='sm'
variant={offsetMode === OffsetMode.Relative ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => setOffsetMode(OffsetMode.Relative)}
>
Relative
</Button>
</ButtonGroup>
<RundownMenu />
</div>
);
@@ -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<string, ActionHandler> = {
throw new Error('No matching method provided');
},
offsetmode: (payload) => {
const mode = coerceEnum<OffsetMode>(payload, OffsetMode);
runtimeService.setOffsetMode(mode);
return { payload: 'success' };
},
};
/**
@@ -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);
@@ -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
+18 -1
View File
@@ -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;
}
/**
@@ -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>): RuntimeState {
@@ -4,7 +4,7 @@ import { deepmerge } from 'ontime-utils';
import {
type RuntimeState,
addTime,
clear,
hardClear,
getState,
load,
loadBlock,
@@ -71,7 +71,7 @@ beforeAll(() => {
describe('mutation on runtimeState', () => {
beforeEach(() => {
clear();
hardClear();
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();
hardClear();
});
afterEach(() => {
vi.useRealTimers();
+48 -14
View File
@@ -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<RuntimeState> {
@@ -79,10 +85,36 @@ export function getState(): Readonly<RuntimeState> {
runtime: { ...runtimeState.runtime },
timer: { ...runtimeState.timer },
_timer: { ...runtimeState._timer },
_rundown: { totalDelay: 0 },
};
}
export function clear() {
//TODO: find other things that dose not need clearing
export function softClear() {
runtimeState.eventNow = null;
runtimeState.publicEventNow = null;
runtimeState.eventNext = null;
runtimeState.publicEventNext = null;
// runtimeState.currentBlock.block = null;
// runtimeState.currentBlock.startedAt = 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;
}
export function hardClear() {
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<TimerState & RestorePoint>,
): boolean {
// we need to persist the current block state across loads
const prevCurrentBlock = { ...runtimeState.currentBlock };
clear();
runtimeState.currentBlock = prevCurrentBlock;
softClear();
// 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;
hardClear();
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;
softClear();
//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;
}
+40 -1
View File
@@ -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');
@@ -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;
};
@@ -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<RuntimeStore> = {
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,
+1
View File
@@ -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';