diff --git a/apps/client/src/common/stores/runtime.ts b/apps/client/src/common/stores/runtime.ts index e72e39b32..fff5df1fa 100644 --- a/apps/client/src/common/stores/runtime.ts +++ b/apps/client/src/common/stores/runtime.ts @@ -1,5 +1,5 @@ import isEqual from 'react-fast-compare'; -import { Playback, RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types'; +import { Playback, RuntimeStore, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types'; import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional'; export const runtimeStorePlaceholder: RuntimeStore = { @@ -11,6 +11,7 @@ export const runtimeStorePlaceholder: RuntimeStore = { elapsed: null, expectedFinish: null, finishedAt: null, + phase: TimerPhase.None, playback: Playback.Stop, secondaryTimer: null, startedAt: null, diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index f4d3bae9f..50fb7ab59 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -1,11 +1,12 @@ import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils'; -import { EndAction, OntimeEvent, Playback, TimeStrategy, TimerType } from 'ontime-types'; +import { EndAction, OntimeEvent, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types'; import { getCurrent, getExpectedFinish, getRollTimers, getRuntimeOffset, + getTimerPhase, getTotalDuration, normaliseEndTime, skippedOutOfEvent, @@ -1756,3 +1757,97 @@ describe('getTotalDuration()', () => { expect(millisToString(duration)).toBe('62:00:00'); }); }); + +describe('getTimerPhase()', () => { + it('should be None if the timer is not running', () => { + const state = { + timer: { + addedTime: 0, + current: null, + duration: null, + elapsed: null, + expectedFinish: null, + finishedAt: null, + playback: Playback.Stop, + phase: TimerPhase.None, + secondaryTimer: null, + startedAt: null, + }, + } as RuntimeState; + + const phase = getTimerPhase(state); + expect(phase).toBe(TimerPhase.None); + }); + + it('can be negative', () => { + const state = { + timer: { + addedTime: 0, + current: -50, + duration: 1000, + playback: Playback.Play, + }, + eventNow: { + timeDanger: 100, + timeWarning: 200, + }, + } as RuntimeState; + + const phase = getTimerPhase(state); + expect(phase).toBe(TimerPhase.Negative); + }); + + it('can be danger', () => { + const state = { + timer: { + addedTime: 0, + current: 0, + duration: 1000, + playback: Playback.Play, + }, + eventNow: { + timeDanger: 100, + timeWarning: 200, + }, + } as RuntimeState; + + const phase = getTimerPhase(state); + expect(phase).toBe(TimerPhase.Danger); + }); + + it('can be warning', () => { + const state = { + timer: { + addedTime: 0, + current: 150, + duration: 1000, + playback: Playback.Play, + }, + eventNow: { + timeDanger: 100, + timeWarning: 200, + }, + } as RuntimeState; + + const phase = getTimerPhase(state); + expect(phase).toBe(TimerPhase.Warning); + }); + + it('it default if the timer is playing and there is none of the above', () => { + const state = { + timer: { + addedTime: 0, + current: 250, + duration: 1000, + playback: Playback.Play, + }, + eventNow: { + timeDanger: 100, + timeWarning: 200, + }, + } as RuntimeState; + + const phase = getTimerPhase(state); + expect(phase).toBe(TimerPhase.Default); + }); +}); diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 4c78b3d55..aadb46d21 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,4 +1,4 @@ -import { MaybeNumber, MaybeString, OntimeEvent, TimerType } from 'ontime-types'; +import { MaybeNumber, MaybeString, OntimeEvent, Playback, TimerPhase, TimerType } from 'ontime-types'; import { dayInMs } from 'ontime-utils'; import { RuntimeState } from '../stores/runtimeState.js'; import { timerConfig } from '../config/config.js'; @@ -361,3 +361,43 @@ export function getExpectedEnd(state: RuntimeState): MaybeNumber { } return state.runtime.plannedEnd - state.runtime.offset + state._timer.totalDelay; } + +/** + * Utility checks whether the playback is considered to be active + * @param state + * @returns + */ +function isPlaybackActive(state: RuntimeState): boolean { + return ( + state.timer.playback === Playback.Play || + state.timer.playback === Playback.Pause || + state.timer.playback === Playback.Roll + ); +} + +/** + * Checks running timer to see which phase it currently is in + * @param state + */ +export function getTimerPhase(state: RuntimeState): TimerPhase { + if (!isPlaybackActive(state)) { + return TimerPhase.None; + } + + const current = state.timer.current; + if (current < 0) { + return TimerPhase.Negative; + } + + const danger = state.eventNow.timeDanger; + if (current <= danger) { + return TimerPhase.Danger; + } + + const warning = state.eventNow.timeWarning; + if (current <= warning) { + return TimerPhase.Warning; + } + + return TimerPhase.Default; +} diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index 31cf80047..3c39be5e0 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -1,4 +1,4 @@ -import { MaybeNumber, OntimeEvent, Playback, Runtime, TimerState, TimerType } from 'ontime-types'; +import { MaybeNumber, OntimeEvent, Playback, Runtime, TimerPhase, TimerState, TimerType } from 'ontime-types'; import { calculateDuration, dayInMs } from 'ontime-utils'; import { clock } from '../services/Clock.js'; @@ -10,6 +10,7 @@ import { getExpectedFinish, getRollTimers, getRuntimeOffset, + getTimerPhase, skippedOutOfEvent, updateRoll, } from '../services/timerUtils.js'; @@ -32,6 +33,7 @@ const initialTimer: TimerState = { elapsed: null, expectedFinish: null, // TODO: expected finish could account for midnight, we cleanup in the clients finishedAt: null, + phase: TimerPhase.None, playback: Playback.Stop, secondaryTimer: null, startedAt: null, @@ -300,6 +302,10 @@ export function start(state: RuntimeState = runtimeState): boolean { state.runtime.actualStart = state.clock; } + // update timer phase + runtimeState.timer.phase = getTimerPhase(runtimeState); + + // update offset state.runtime.offset = getRuntimeOffset(state); state.runtime.expectedEnd = state.runtime.plannedEnd - state.runtime.offset; @@ -387,6 +393,9 @@ export function update(): UpdateResult { runtimeState.timer.duration = runtimeState.timer.current; } + // update timer phase + runtimeState.timer.phase = getTimerPhase(runtimeState); + // update offset runtimeState.runtime.offset = getRuntimeOffset(runtimeState); runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState); diff --git a/packages/types/src/definitions/runtime/TimerState.type.ts b/packages/types/src/definitions/runtime/TimerState.type.ts index 4f523805d..b56aa098c 100644 --- a/packages/types/src/definitions/runtime/TimerState.type.ts +++ b/packages/types/src/definitions/runtime/TimerState.type.ts @@ -1,6 +1,14 @@ import type { MaybeNumber } from '../../index.js'; import type { Playback } from './Playback.type.js'; +export enum TimerPhase { + None = 'none', + Default = 'default', + Warning = 'warning', + Danger = 'danger', + Negative = 'negative', +} + export type TimerState = { addedTime: number; // time added by user, can be negative current: MaybeNumber; // running countdown @@ -8,6 +16,7 @@ export type TimerState = { elapsed: MaybeNumber; // elapsed time in current timer expectedFinish: MaybeNumber; // time we expect timer to finish finishedAt: MaybeNumber; // only if timer has already finished + phase: TimerPhase; playback: Playback; secondaryTimer: MaybeNumber; // used for roll mode startedAt: MaybeNumber; // only if timer has already started diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 8267a27c7..daaa7fc5f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -60,7 +60,7 @@ export type { Message, TimerMessage, MessageState } from './definitions/runtime/ export type { Runtime } from './definitions/runtime/Runtime.type.js'; export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js'; -export type { TimerState } from './definitions/runtime/TimerState.type.js'; +export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js'; // ---> Extra Timer export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';