mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 02:43:50 +00:00
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
This commit is contained in:
committed by
GitHub
parent
a94b10cb0f
commit
dce87c3a81
@@ -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
|
||||
|
||||
@@ -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,
|
||||
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();
|
||||
|
||||
@@ -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: { ...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<TimerState & RestorePoint>,
|
||||
): 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user