mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-05 23:43:57 +00:00
234 lines
7.8 KiB
TypeScript
234 lines
7.8 KiB
TypeScript
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
|
|
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
|
|
|
|
import type { RuntimeState } from '../stores/runtimeState.js';
|
|
|
|
/**
|
|
* handle events that span over midnight
|
|
*/
|
|
export const normaliseEndTime = (start: number, end: number) => (end < start ? end + dayInMs : end);
|
|
|
|
/**
|
|
* Checks whether the local wall clock wrapped into a new day
|
|
* Uses a threshold to distinguish midnight wrap (~23h backward jump)
|
|
* from DST fall back (~1h backward jump)
|
|
*/
|
|
export function hasCrossedMidnight(previous: TimeOfDay, current: TimeOfDay): boolean {
|
|
const backwardJump = previous - current;
|
|
return backwardJump > 12 * MILLIS_PER_HOUR;
|
|
}
|
|
|
|
/**
|
|
* Calculates expected finish time of a running timer
|
|
* @param {RuntimeState} state runtime state
|
|
* @returns {number | null} new current time or null if nothing is running
|
|
*/
|
|
export function getExpectedFinish(state: RuntimeState): MaybeNumber {
|
|
const { startedAt, duration, addedTime } = state.timer;
|
|
|
|
if (state.eventNow === null) {
|
|
return null;
|
|
}
|
|
|
|
const { countToEnd, timeEnd } = state.eventNow;
|
|
const { pausedAt } = state._timer;
|
|
const { clock } = state;
|
|
|
|
if (startedAt === null) {
|
|
return null;
|
|
}
|
|
|
|
const pausedTime = pausedAt != null ? clock - pausedAt : 0;
|
|
|
|
if (countToEnd) {
|
|
return timeEnd + addedTime + pausedTime;
|
|
}
|
|
|
|
// handle events that finish the day after
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if ther eis a timer
|
|
const expectedFinish = startedAt + duration! + addedTime + pausedTime;
|
|
if (expectedFinish > dayInMs) {
|
|
return expectedFinish - dayInMs;
|
|
}
|
|
|
|
// an event cannot finish before it started (user added too much negative time)
|
|
return Math.max(expectedFinish, startedAt);
|
|
}
|
|
|
|
/**
|
|
* Calculates running countdown
|
|
* @param {RuntimeState} state runtime state
|
|
* @returns {number} current time for timer
|
|
*/
|
|
|
|
export function getCurrent(state: RuntimeState): number {
|
|
// eslint-disable-next-line no-unused-labels -- dev code path
|
|
DEV: {
|
|
if (state.eventNow === null || state.timer.duration === null) {
|
|
throw new Error('timerUtils.getCurrent: invalid state received');
|
|
}
|
|
}
|
|
const { startedAt, duration, addedTime } = state.timer;
|
|
const { countToEnd, timeStart, timeEnd } = state.eventNow;
|
|
const { pausedAt } = state._timer;
|
|
const { clock } = state;
|
|
|
|
if (countToEnd) {
|
|
const isEventOverMidnight = timeStart > timeEnd;
|
|
const correctDay = isEventOverMidnight ? dayInMs : 0;
|
|
return correctDay - clock + timeEnd + addedTime;
|
|
}
|
|
|
|
if (startedAt === null) {
|
|
return duration;
|
|
}
|
|
|
|
if (pausedAt != null) {
|
|
return startedAt + duration + addedTime - pausedAt;
|
|
}
|
|
|
|
const hasPassedMidnight = startedAt > clock;
|
|
const correctDay = hasPassedMidnight ? dayInMs : 0;
|
|
return startedAt + duration + addedTime - clock - correctDay;
|
|
}
|
|
|
|
/**
|
|
* Calculates active time elapsed since the timer started.
|
|
*/
|
|
export function getElapsed(state: RuntimeState): MaybeNumber {
|
|
const { clock } = state;
|
|
const { startedAt } = state.timer;
|
|
const { pausedAt, pausedDuration } = state._timer;
|
|
|
|
if (startedAt === null) {
|
|
return null;
|
|
}
|
|
|
|
const referenceClock = pausedAt ?? clock;
|
|
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
|
|
const activeElapsed = elapsedSinceStart - pausedDuration;
|
|
|
|
return Math.max(0, activeElapsed);
|
|
}
|
|
|
|
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
|
|
if (clock < startedAt) {
|
|
return clock + dayInMs - startedAt;
|
|
}
|
|
|
|
return clock - startedAt;
|
|
}
|
|
|
|
/**
|
|
* Checks whether we have skipped out of the event
|
|
* @param {RuntimeState} state runtime state
|
|
* @param {number} previousTime previous clock
|
|
* @param {number} skipLimit how much time can we skip
|
|
* @returns {boolean}
|
|
*/
|
|
export function skippedOutOfEvent(state: RuntimeState, previousTime: number, skipLimit: number): boolean {
|
|
// we cant have skipped if we havent started
|
|
if (state.timer.expectedFinish === null || state.timer.startedAt === null) {
|
|
return false;
|
|
}
|
|
|
|
const { startedAt, expectedFinish } = state.timer;
|
|
const { clock } = state;
|
|
|
|
const isInsideEvent = checkIsNow(startedAt, expectedFinish, clock);
|
|
if (isInsideEvent) return false;
|
|
|
|
// we are outside the event, but we need to check if we skipped or just finished normally
|
|
const timeFromPrevious = Math.abs(previousTime - clock);
|
|
// account for midnight when checking skips
|
|
const hasSkipped = Math.min(timeFromPrevious, dayInMs - timeFromPrevious) > skipLimit;
|
|
|
|
return hasSkipped;
|
|
}
|
|
|
|
/**
|
|
* Calculates difference between the runtime and the schedule of an event
|
|
* Positive offset is over time / behind schedule
|
|
* Negative offset is under time / ahead of schedule
|
|
*/
|
|
export function getRuntimeOffset(state: RuntimeState): { absolute: number; relative: number } {
|
|
const { eventNow, clock, _startDayOffset } = state;
|
|
const { addedTime, current, startedAt } = state.timer;
|
|
// nothing to calculate if there are no loaded events or if we havent started
|
|
if (eventNow === null || startedAt === null || _startDayOffset === null) {
|
|
return { absolute: 0, relative: 0 };
|
|
}
|
|
|
|
const { countToEnd, timeStart, dayOffset } = eventNow;
|
|
const { plannedStart, actualStart } = state.rundown;
|
|
|
|
// eslint-disable-next-line no-unused-labels -- dev code path
|
|
DEV: {
|
|
// we know current exists as long as eventNow exists
|
|
if (current === null) throw new Error('timerUtils.getRuntimeOffset: state.timer.current must be set');
|
|
if (plannedStart === null) throw new Error('timerUtils.getRuntimeOffset: state.rundown.plannedStart must be set');
|
|
if (actualStart === null) throw new Error('timerUtils.getRuntimeOffset: state.rundown.plannedStart must be set');
|
|
}
|
|
|
|
// difference between planned event start and actual event start (will be positive if we started behind)
|
|
const eventStartOffset = startedAt + _startDayOffset * dayInMs - (timeStart + dayOffset * dayInMs);
|
|
|
|
// how long has the event been running over (is a negative number when in over timer so inverted before adding to offset)
|
|
const overtime = Math.abs(Math.min(current, 0));
|
|
|
|
// time the playback was paused, the different from now to when we paused is added to the offset TODO: brakes when crossing midnight
|
|
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
|
|
|
|
// absolute offset is difference between schedule and playback time
|
|
const absolute = eventStartOffset + overtime + pausedTime + addedTime;
|
|
|
|
// the relative offset is the same as the absolute but adjusted relative to the actual start time
|
|
const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs;
|
|
|
|
// in case of count to end, the absolute offset is just the overtime
|
|
return countToEnd ? { absolute: overtime, relative } : { absolute, relative };
|
|
}
|
|
|
|
/**
|
|
* Checks running timer to see which phase it currently is in
|
|
* @param state
|
|
*/
|
|
export function getTimerPhase(state: RuntimeState): TimerPhase {
|
|
if (!isPlaybackActive(state.timer.playback)) {
|
|
return TimerPhase.None;
|
|
}
|
|
|
|
const current = state.timer.current;
|
|
|
|
if (current === null || state.eventNow === null || state.timer.secondaryTimer != null) {
|
|
return TimerPhase.Pending;
|
|
}
|
|
|
|
if (current < 0) {
|
|
return TimerPhase.Overtime;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Finds the day offset relative to an event start
|
|
* used byt the runtimeState on first start to get correct offsets
|
|
*/
|
|
export function findDayOffset(plannedStart: number, clock: number): Day {
|
|
const distance = clock - plannedStart;
|
|
if (distance >= 12 * MILLIS_PER_HOUR) return -1 as Day;
|
|
if (distance < -12 * MILLIS_PER_HOUR) return 1 as Day;
|
|
return 0 as Day;
|
|
}
|