fix: pause timer over midnight

This commit is contained in:
alex-arc
2026-07-26 13:00:26 +02:00
parent 25466c8376
commit a2d6847ea6
5 changed files with 43 additions and 29 deletions
@@ -1,6 +1,7 @@
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types'; import { EndAction, Instant, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
import * as timeCore from '../../lib/time-core/timeCore.js';
import type { RuntimeState } from '../../stores/runtimeState.js'; import type { RuntimeState } from '../../stores/runtimeState.js';
import { import {
findDayOffset, findDayOffset,
@@ -53,11 +54,12 @@ describe('getElapsed()', () => {
it('uses the current pause start while paused', () => { it('uses the current pause start while paused', () => {
const state = { const state = {
clock: 10 * MILLIS_PER_MINUTE, clock: 10 * MILLIS_PER_MINUTE,
_now: timeCore.toInstant((10 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
timer: { timer: {
startedAt: 2 * MILLIS_PER_MINUTE, startedAt: 2 * MILLIS_PER_MINUTE,
}, },
_timer: { _timer: {
pausedAt: 7 * MILLIS_PER_MINUTE, pausedAt: timeCore.toInstant((7 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
pausedDuration: 1 * MILLIS_PER_MINUTE, pausedDuration: 1 * MILLIS_PER_MINUTE,
}, },
} as RuntimeState; } as RuntimeState;
@@ -984,13 +986,18 @@ describe('getRuntimeOffset()', () => {
dayOffset: 0, dayOffset: 0,
}, },
clock: 3 * MILLIS_PER_MINUTE, // 00:03 (after midnight) clock: 3 * MILLIS_PER_MINUTE, // 00:03 (after midnight)
_now: timeCore.toInstant((3 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
timer: { timer: {
startedAt: 23 * MILLIS_PER_HOUR, // started on time at 23:00 startedAt: 23 * MILLIS_PER_HOUR, // started on time at 23:00
current: 25, // still counting down current: 25, // still counting down
addedTime: 0, addedTime: 0,
}, },
_timer: { _timer: {
pausedAt: 23 * MILLIS_PER_HOUR + 58 * MILLIS_PER_MINUTE, // 23:58, before midnight pausedAt: timeCore.toInstant(
(23 * MILLIS_PER_HOUR + 58 * MILLIS_PER_MINUTE) as TimeOfDay,
(timeCore.now() - dayInMs) as Instant,
), // 23:58, before midnight
pausedDuration: 0,
}, },
rundown: { rundown: {
actualStart: 23 * MILLIS_PER_HOUR, actualStart: 23 * MILLIS_PER_HOUR,
@@ -1000,9 +1007,8 @@ describe('getRuntimeOffset()', () => {
_startDayOffset: 0, _startDayOffset: 0,
} as RuntimeState; } as RuntimeState;
// paused from 23:58 to 00:03 -> 5 minutes, regardless of the midnight wrap // paused from 23:58 to 00:03 -> so elapsed should still be 58 minutes
const { absolute } = getRuntimeOffset(state); expect(getElapsed(state)).toBe(58 * MILLIS_PER_MINUTE);
expect(absolute).toBe(5 * MILLIS_PER_MINUTE);
}); });
it('offset doesnt exist if we havent started', () => { it('offset doesnt exist if we havent started', () => {
+7 -5
View File
@@ -1,6 +1,7 @@
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types'; import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils'; import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import * as timeCore from '../lib/time-core/timeCore.js';
import type { RuntimeState } from '../stores/runtimeState.js'; import type { RuntimeState } from '../stores/runtimeState.js';
/** /**
@@ -96,17 +97,18 @@ export function getCurrent(state: RuntimeState): number {
* Calculates active time elapsed since the timer started. * Calculates active time elapsed since the timer started.
*/ */
export function getElapsed(state: RuntimeState): MaybeNumber { export function getElapsed(state: RuntimeState): MaybeNumber {
const { clock } = state; const { clock, _now } = state;
const { startedAt } = state.timer; const { startedAt } = state.timer;
const { pausedAt, pausedDuration } = state._timer; const { pausedDuration, pausedAt } = state._timer;
if (startedAt === null) { if (startedAt === null) {
return null; return null;
} }
const referenceClock = pausedAt ?? clock; const currentPauseDuration = pausedAt !== null ? timeCore.timeSince(_now, pausedAt) : 0;
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration; const elapsedSinceStart = getTimeSinceStart(clock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration - currentPauseDuration;
return Math.max(0, activeElapsed); return Math.max(0, activeElapsed);
} }
@@ -1,10 +1,11 @@
import { OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types'; import { Instant, OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
import { deepmerge } from 'ontime-utils'; import { deepmerge } from 'ontime-utils';
import type { RuntimeState } from '../runtimeState.js'; import type { RuntimeState } from '../runtimeState.js';
const baseState: RuntimeState = { const baseState: RuntimeState = {
clock: 0 as TimeOfDay, clock: 0 as TimeOfDay,
_now: 0 as Instant,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
eventFlag: null, eventFlag: null,
@@ -135,7 +135,7 @@ describe('mutation on runtimeState', () => {
playback: Playback.Pause, playback: Playback.Pause,
addedTime: 0, addedTime: 0,
}); });
expect(newState._timer.pausedAt).toEqual(newState.clock); expect(newState._timer.pausedAt).toEqual(newState._now);
success = pause(); success = pause();
expect(success).toBe(false); expect(success).toBe(false);
+21 -16
View File
@@ -63,7 +63,9 @@ export type RuntimeState = {
// private properties of the timer calculations // private properties of the timer calculations
_timer: { _timer: {
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
pausedAt: Maybe<TimeOfDay>; pausedAt: Maybe<Instant>;
/** Accumulate pause duration but dose not include the current pause */
pausedDuration: number; pausedDuration: number;
secondaryTarget: Maybe<TimeOfDay>; secondaryTarget: Maybe<TimeOfDay>;
hasFinished: boolean; hasFinished: boolean;
@@ -76,10 +78,12 @@ export type RuntimeState = {
_end: ExpectedMetadata; _end: ExpectedMetadata;
_startEpoch: Maybe<Instant>; _startEpoch: Maybe<Instant>;
_startDayOffset: Maybe<Day>; _startDayOffset: Maybe<Day>;
_now: Instant;
}; };
const runtimeState: RuntimeState = { const runtimeState: RuntimeState = {
clock: timeCore.timeOfDayNow(), clock: timeCore.timeOfDayNow(),
_now: timeCore.now(),
groupNow: null, groupNow: null,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
@@ -104,6 +108,12 @@ const runtimeState: RuntimeState = {
_startDayOffset: null, _startDayOffset: null,
}; };
/** set the current clock to ensure parity between _now and clock */
function setClock(state: RuntimeState) {
state._now = timeCore.now();
state.clock = timeCore.toTimeOfDay(state._now);
}
export function getState(): Readonly<RuntimeState> { export function getState(): Readonly<RuntimeState> {
// create a shallow copy of the state // create a shallow copy of the state
return { return {
@@ -136,7 +146,7 @@ export function clearEventData() {
runtimeState.rundown.selectedEventIndex = null; runtimeState.rundown.selectedEventIndex = null;
runtimeState.timer.playback = Playback.Stop; runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = timeCore.timeOfDayNow(); setClock(runtimeState);
runtimeState.timer = { ...runtimeStorePlaceholder.timer }; runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown // when clearing, we maintain the total delay from the rundown
@@ -169,7 +179,7 @@ export function clearState() {
runtimeState._end = null; runtimeState._end = null;
runtimeState.timer.playback = Playback.Stop; runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = timeCore.timeOfDayNow(); setClock(runtimeState);
runtimeState.timer = { ...runtimeStorePlaceholder.timer }; runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown // when clearing, we maintain the total delay from the rundown
@@ -422,15 +432,12 @@ export function start(state: RuntimeState = runtimeState): boolean {
return false; return false;
} }
const epoch = timeCore.now(); setClock(state);
const now = timeCore.toTimeOfDay(epoch);
state.clock = now;
state.timer.secondaryTimer = null; state.timer.secondaryTimer = null;
// add paused time if it exists // add paused time if it exists
if (state._timer.pausedAt) { if (state._timer.pausedAt) {
const timeToAdd = state.clock - state._timer.pausedAt; const timeToAdd = state._now - state._timer.pausedAt;
state.timer.addedTime += timeToAdd; state.timer.addedTime += timeToAdd;
state._timer.pausedDuration += timeToAdd; state._timer.pausedDuration += timeToAdd;
state._timer.pausedAt = null; state._timer.pausedAt = null;
@@ -447,7 +454,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
if (state.rundown.actualStart === null) { if (state.rundown.actualStart === null) {
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day; state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
state.rundown.currentDay = state._startDayOffset; state.rundown.currentDay = state._startDayOffset;
state._startEpoch = epoch; state._startEpoch = state._now;
state.rundown.actualStart = state.clock; state.rundown.actualStart = state.clock;
} }
@@ -481,8 +488,8 @@ export function pause(state: RuntimeState = runtimeState): boolean {
} }
state.timer.playback = Playback.Pause; state.timer.playback = Playback.Pause;
state.clock = timeCore.timeOfDayNow(); setClock(state);
state._timer.pausedAt = state.clock; state._timer.pausedAt = state._now;
return true; return true;
} }
@@ -547,9 +554,7 @@ export type UpdateResult = {
export function update(): UpdateResult { export function update(): UpdateResult {
// 0. there are some things we always do // 0. there are some things we always do
const previousClock = runtimeState.clock; const previousClock = runtimeState.clock;
const epoch = timeCore.now(); setClock(runtimeState); // we update the clock on every update call
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now; // we update the clock on every update call
// 1. is playback idle? // 1. is playback idle?
if (!isPlaybackActive(runtimeState.timer.playback)) { if (!isPlaybackActive(runtimeState.timer.playback)) {
@@ -558,13 +563,13 @@ export function update(): UpdateResult {
// calculate currentDay from epoch (days elapsed since playback was started) // calculate currentDay from epoch (days elapsed since playback was started)
if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) { if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch); const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now);
runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart; runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
} }
// 2. are we waiting to roll? // 2. are we waiting to roll?
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) { if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now); const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, runtimeState.clock);
return updateIfWaitingToRoll(clockHasCrossedMidnight); return updateIfWaitingToRoll(clockHasCrossedMidnight);
} }