mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-21 06:59:09 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c29756abe | |||
| c6eccec30e | |||
| 5220c2c374 | |||
| 4eeeb294f7 |
@@ -200,8 +200,7 @@ $card-padding: 2rem;
|
|||||||
.overlay {
|
.overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: $zindex-backdrop;
|
z-index: $zindex-backdrop;
|
||||||
width: 100%;
|
inset: 0;
|
||||||
height: 100%;
|
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
place-content: center;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.updateIndicator {
|
||||||
|
width: 0.5em;
|
||||||
|
height: 0.5em;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 99px;
|
||||||
|
background-color: $red-400;
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
|||||||
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
|
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
import style from './AppVersion.module.scss';
|
||||||
|
|
||||||
export default function AppVersion() {
|
export default function AppVersion() {
|
||||||
const { data, isError } = useAppVersion();
|
const { data, isError } = useAppVersion();
|
||||||
|
|
||||||
@@ -18,7 +20,12 @@ export default function AppVersion() {
|
|||||||
return (
|
return (
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.Field
|
||||||
title={`Ontime ${appVersion}`}
|
title={
|
||||||
|
<>
|
||||||
|
<span className={style.updateIndicator} aria-hidden='true' />
|
||||||
|
{`Ontime ${appVersion}`}
|
||||||
|
</>
|
||||||
|
}
|
||||||
description={
|
description={
|
||||||
isOntimeCloud
|
isOntimeCloud
|
||||||
? `Version ${data.version} is available. Restart your stage to update.`
|
? `Version ${data.version} is available. Restart your stage to update.`
|
||||||
@@ -26,7 +33,7 @@ export default function AppVersion() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{!isOntimeCloud && (
|
{!isOntimeCloud && (
|
||||||
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
|
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
|
||||||
)}
|
)}
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</Panel.SubHeader>
|
</Panel.SubHeader>
|
||||||
<Panel.Loader isLoading={status === 'pending'} />
|
|
||||||
{rootError && <Panel.Error>{rootError}</Panel.Error>}
|
{rootError && <Panel.Error>{rootError}</Panel.Error>}
|
||||||
<Panel.Divider />
|
<Panel.Divider />
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
|
<Panel.Loader isLoading={status === 'pending'} />
|
||||||
{data.pendingRestart && (
|
{data.pendingRestart && (
|
||||||
<Info type='warning'>A port change is pending and will happen on the next restart.</Info>
|
<Info type='warning'>A port change is pending and will happen on the next restart.</Info>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download
|
|||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: 'New project...',
|
label: 'New project...',
|
||||||
click: () => redirectWindow('/editor?settings=project__manage&new=true'),
|
click: () => redirectWindow('/editor?settings=project__create'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Load...',
|
label: 'Load...',
|
||||||
@@ -202,6 +202,18 @@ function makeSettingsMenu(redirectWindow) {
|
|||||||
label: 'View settings',
|
label: 'View settings',
|
||||||
click: () => redirectWindow('/editor?settings=settings__view'),
|
click: () => redirectWindow('/editor?settings=settings__view'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Custom views',
|
||||||
|
click: () => redirectWindow('/editor?settings=settings__custom-views'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'MCP Server',
|
||||||
|
click: () => redirectWindow('/editor?settings=settings__mcp'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Server port',
|
||||||
|
click: () => redirectWindow('/editor?settings=settings__port'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { EndAction, Instant, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
import { EndAction, 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,
|
||||||
@@ -54,12 +53,11 @@ 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: timeCore.toInstant((7 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
|
pausedAt: 7 * MILLIS_PER_MINUTE,
|
||||||
pausedDuration: 1 * MILLIS_PER_MINUTE,
|
pausedDuration: 1 * MILLIS_PER_MINUTE,
|
||||||
},
|
},
|
||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
@@ -977,40 +975,6 @@ describe('getRuntimeOffset()', () => {
|
|||||||
expect(absolute).toBe(25);
|
expect(absolute).toBe(25);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('paused time is delayed time when the pause spans midnight', () => {
|
|
||||||
const state = {
|
|
||||||
eventNow: {
|
|
||||||
id: '1',
|
|
||||||
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
|
|
||||||
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00
|
|
||||||
dayOffset: 0,
|
|
||||||
},
|
|
||||||
clock: 3 * MILLIS_PER_MINUTE, // 00:03 (after midnight)
|
|
||||||
_now: timeCore.toInstant((3 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
|
|
||||||
timer: {
|
|
||||||
startedAt: 23 * MILLIS_PER_HOUR, // started on time at 23:00
|
|
||||||
current: 25, // still counting down
|
|
||||||
addedTime: 0,
|
|
||||||
},
|
|
||||||
_timer: {
|
|
||||||
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: {
|
|
||||||
actualStart: 23 * MILLIS_PER_HOUR,
|
|
||||||
plannedStart: 23 * MILLIS_PER_HOUR,
|
|
||||||
currentDay: 0,
|
|
||||||
},
|
|
||||||
_startDayOffset: 0,
|
|
||||||
} as RuntimeState;
|
|
||||||
|
|
||||||
// paused from 23:58 to 00:03 -> so elapsed should still be 58 minutes
|
|
||||||
expect(getElapsed(state)).toBe(58 * MILLIS_PER_MINUTE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('offset doesnt exist if we havent started', () => {
|
it('offset doesnt exist if we havent started', () => {
|
||||||
const state = {
|
const state = {
|
||||||
clock: 78480789,
|
clock: 78480789,
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { Offset, OffsetMode, Playback, TimerPhase, TimerState, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
|
import { makeOntimeEvent, makeRundown } from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
|
||||||
|
import {
|
||||||
|
findNextPlayableId,
|
||||||
|
findNextPlayableWithCue,
|
||||||
|
findPreviousPlayableId,
|
||||||
|
getEventAtIndex,
|
||||||
|
getShouldClockUpdate,
|
||||||
|
getShouldOffsetUpdate,
|
||||||
|
getShouldTimerUpdate,
|
||||||
|
isNewSecond,
|
||||||
|
} from '../runtime.utils.js';
|
||||||
|
|
||||||
|
describe('isNewSecond()', () => {
|
||||||
|
it('is false while the value moves within the same second', () => {
|
||||||
|
// count down rounds up, so both resolve to second 2
|
||||||
|
expect(isNewSecond(1500, 1200)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true once the value crosses a second boundary', () => {
|
||||||
|
expect(isNewSecond(1001, 1000)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rounds according to the given direction', () => {
|
||||||
|
// 1200 -> ceil 2 / floor 1, 1800 -> ceil 2 / floor 1
|
||||||
|
expect(isNewSecond(1200, 1800, TimerType.CountDown)).toBe(false);
|
||||||
|
expect(isNewSecond(1200, 1800, TimerType.CountUp)).toBe(false);
|
||||||
|
// 1200 -> ceil 2 / floor 1, 2200 -> ceil 3 / floor 2
|
||||||
|
expect(isNewSecond(1200, 2200, TimerType.CountDown)).toBe(true);
|
||||||
|
expect(isNewSecond(1200, 2200, TimerType.CountUp)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats null and undefined as second zero', () => {
|
||||||
|
expect(isNewSecond(undefined, null)).toBe(false);
|
||||||
|
expect(isNewSecond(null, 0)).toBe(false);
|
||||||
|
expect(isNewSecond(undefined, 500)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getShouldClockUpdate()', () => {
|
||||||
|
it('is false within the same second and true across the boundary', () => {
|
||||||
|
expect(getShouldClockUpdate(1000, 1999)).toBe(false);
|
||||||
|
expect(getShouldClockUpdate(1000, 2000)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getShouldTimerUpdate()', () => {
|
||||||
|
const baseTimer: TimerState = {
|
||||||
|
addedTime: 0,
|
||||||
|
current: 10000,
|
||||||
|
duration: 10000,
|
||||||
|
elapsed: 0,
|
||||||
|
expectedFinish: 10000,
|
||||||
|
phase: TimerPhase.Default,
|
||||||
|
playback: Playback.Play,
|
||||||
|
secondaryTimer: null,
|
||||||
|
startedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('always updates when there is no previous state', () => {
|
||||||
|
expect(getShouldTimerUpdate(undefined, baseTimer)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not update while the timer ticks within the same second', () => {
|
||||||
|
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, current: 9500 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates when the timer crosses a second', () => {
|
||||||
|
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, current: 8999 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates when the secondary timer crosses a second', () => {
|
||||||
|
const previous = { ...baseTimer, secondaryTimer: 2000 };
|
||||||
|
// counting down rounds up, so 1999 is still second 2
|
||||||
|
expect(getShouldTimerUpdate(previous, { ...previous, secondaryTimer: 1999 })).toBe(false);
|
||||||
|
expect(getShouldTimerUpdate(previous, { ...previous, secondaryTimer: 1000 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['addedTime', { addedTime: 1 }],
|
||||||
|
['duration', { duration: 1 }],
|
||||||
|
['phase', { phase: TimerPhase.Warning }],
|
||||||
|
['playback', { playback: Playback.Pause }],
|
||||||
|
['startedAt', { startedAt: 1 }],
|
||||||
|
])('updates immediately when %s changes', (_label, patch) => {
|
||||||
|
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, ...patch })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['elapsed', { elapsed: 1 }],
|
||||||
|
['expectedFinish', { expectedFinish: 1 }],
|
||||||
|
])('does not update on %s alone, since it is derived', (_label, patch) => {
|
||||||
|
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, ...patch })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getShouldOffsetUpdate()', () => {
|
||||||
|
const baseOffset: Offset = {
|
||||||
|
absolute: 0,
|
||||||
|
relative: 0,
|
||||||
|
mode: OffsetMode.Absolute,
|
||||||
|
expectedGroupEnd: null,
|
||||||
|
expectedRundownEnd: null,
|
||||||
|
expectedFlagStart: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('always updates when there is no previous state', () => {
|
||||||
|
expect(getShouldOffsetUpdate(undefined, baseOffset, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates on a mode change even when no dependency ticked', () => {
|
||||||
|
expect(getShouldOffsetUpdate(baseOffset, { ...baseOffset, mode: OffsetMode.Relative }, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds back value changes until a dependency ticks', () => {
|
||||||
|
const next = { ...baseOffset, absolute: 1000 };
|
||||||
|
expect(getShouldOffsetUpdate(baseOffset, next, false)).toBe(false);
|
||||||
|
expect(getShouldOffsetUpdate(baseOffset, next, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not update when a dependency ticked but nothing changed', () => {
|
||||||
|
expect(getShouldOffsetUpdate(baseOffset, { ...baseOffset }, true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findPreviousPlayableId()', () => {
|
||||||
|
const order = ['1', '2', '3'];
|
||||||
|
|
||||||
|
it('returns undefined when there is nothing to play', () => {
|
||||||
|
expect(findPreviousPlayableId([])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the first event when nothing is loaded', () => {
|
||||||
|
expect(findPreviousPlayableId(order)).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the preceding event', () => {
|
||||||
|
expect(findPreviousPlayableId(order, '3')).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays on the first event when already at the top', () => {
|
||||||
|
expect(findPreviousPlayableId(order, '1')).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the first event when the loaded id is unknown', () => {
|
||||||
|
expect(findPreviousPlayableId(order, 'not-in-rundown')).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findNextPlayableId()', () => {
|
||||||
|
const order = ['1', '2', '3'];
|
||||||
|
|
||||||
|
it('returns undefined when there is nothing to play', () => {
|
||||||
|
expect(findNextPlayableId([])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the first event when nothing is loaded', () => {
|
||||||
|
expect(findNextPlayableId(order)).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the following event', () => {
|
||||||
|
expect(findNextPlayableId(order, '1')).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps to the first event from the last', () => {
|
||||||
|
expect(findNextPlayableId(order, '3')).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the first event when the loaded id is unknown', () => {
|
||||||
|
expect(findNextPlayableId(order, 'not-in-rundown')).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findNextPlayableWithCue()', () => {
|
||||||
|
const rundown = makeRundown({
|
||||||
|
order: ['1', '2', '3', '4'],
|
||||||
|
entries: {
|
||||||
|
'1': makeOntimeEvent({ id: '1', cue: 'a' }),
|
||||||
|
'2': makeOntimeEvent({ id: '2', cue: 'b' }),
|
||||||
|
'3': makeOntimeEvent({ id: '3', cue: 'b', skip: true }),
|
||||||
|
'4': makeOntimeEvent({ id: '4', cue: 'b' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const order = ['1', '2', '3', '4'];
|
||||||
|
|
||||||
|
it('finds the next event with the given cue', () => {
|
||||||
|
expect(findNextPlayableWithCue(rundown, order, 'b')?.id).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips events which are not playable', () => {
|
||||||
|
expect(findNextPlayableWithCue(rundown, order, 'b', 2)?.id).toBe('4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps around to the start of the rundown', () => {
|
||||||
|
expect(findNextPlayableWithCue(rundown, order, 'a', 2)?.id).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes the current event unless allowCurrent is set', () => {
|
||||||
|
expect(findNextPlayableWithCue(rundown, order, 'b', 1)?.id).toBe('4');
|
||||||
|
expect(findNextPlayableWithCue(rundown, order, 'b', 1, true)?.id).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when no event carries the cue', () => {
|
||||||
|
expect(findNextPlayableWithCue(rundown, order, 'missing')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getEventAtIndex()', () => {
|
||||||
|
const rundown = makeRundown({
|
||||||
|
order: ['1', '2'],
|
||||||
|
entries: {
|
||||||
|
'1': makeOntimeEvent({ id: '1' }),
|
||||||
|
'2': makeOntimeEvent({ id: '2' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the event at the given index', () => {
|
||||||
|
expect(getEventAtIndex(rundown, ['1', '2'], 1)?.id).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when the index is out of range', () => {
|
||||||
|
expect(getEventAtIndex(rundown, ['1', '2'], 5)).toBeUndefined();
|
||||||
|
expect(getEventAtIndex(rundown, [], 0)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,18 +96,17 @@ 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, _now } = state;
|
const { clock } = state;
|
||||||
const { startedAt } = state.timer;
|
const { startedAt } = state.timer;
|
||||||
const { pausedDuration, pausedAt } = state._timer;
|
const { pausedAt, pausedDuration } = state._timer;
|
||||||
|
|
||||||
if (startedAt === null) {
|
if (startedAt === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentPauseDuration = pausedAt !== null ? timeCore.timeSince(_now, pausedAt) : 0;
|
const referenceClock = pausedAt ?? clock;
|
||||||
|
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
|
||||||
const elapsedSinceStart = getTimeSinceStart(clock, startedAt);
|
const activeElapsed = elapsedSinceStart - pausedDuration;
|
||||||
const activeElapsed = elapsedSinceStart - pausedDuration - currentPauseDuration;
|
|
||||||
|
|
||||||
return Math.max(0, activeElapsed);
|
return Math.max(0, activeElapsed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Instant, OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
|
import { 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._now);
|
expect(newState._timer.pausedAt).toEqual(newState.clock);
|
||||||
|
|
||||||
success = pause();
|
success = pause();
|
||||||
expect(success).toBe(false);
|
expect(success).toBe(false);
|
||||||
@@ -248,59 +248,6 @@ describe('mutation on runtimeState', () => {
|
|||||||
state = getState();
|
state = getState();
|
||||||
expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE);
|
expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('elapsed excludes a pause that spans midnight', async () => {
|
|
||||||
clearState();
|
|
||||||
// an event that runs over midnight (23:00 -> 01:00)
|
|
||||||
const event = {
|
|
||||||
...mockEvent,
|
|
||||||
id: 'elapsed-pause-midnight',
|
|
||||||
timeStart: 23 * MILLIS_PER_HOUR,
|
|
||||||
timeEnd: 1 * MILLIS_PER_HOUR,
|
|
||||||
duration: 2 * MILLIS_PER_HOUR,
|
|
||||||
};
|
|
||||||
const mockRundown = makeRundown({
|
|
||||||
entries: { [event.id]: event },
|
|
||||||
order: [event.id],
|
|
||||||
});
|
|
||||||
|
|
||||||
await initRundown(mockRundown, {});
|
|
||||||
vi.runAllTimers();
|
|
||||||
|
|
||||||
const { metadata, rundown } = rundownCache.get();
|
|
||||||
|
|
||||||
// start before midnight
|
|
||||||
vi.setSystemTime('jan 1 23:50');
|
|
||||||
load(event, rundown, metadata);
|
|
||||||
start();
|
|
||||||
|
|
||||||
// 8 minutes of active running before we pause
|
|
||||||
vi.setSystemTime('jan 1 23:58');
|
|
||||||
update();
|
|
||||||
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
|
|
||||||
pause();
|
|
||||||
|
|
||||||
// elapsed is active time since start, so it must not advance while paused,
|
|
||||||
// not even when the pause itself crosses midnight
|
|
||||||
vi.setSystemTime('jan 2 00:01');
|
|
||||||
update();
|
|
||||||
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
|
|
||||||
|
|
||||||
// resume 5 minutes after pausing, having crossed midnight (23:58 -> 00:03)
|
|
||||||
vi.setSystemTime('jan 2 00:03');
|
|
||||||
start();
|
|
||||||
let state = getState();
|
|
||||||
// the accumulated pause count is 5 minutes, regardless of the midnight wrap
|
|
||||||
expect(state._timer.pausedDuration).toBe(5 * MILLIS_PER_MINUTE);
|
|
||||||
// and elapsed still reflects only the 8 active minutes
|
|
||||||
expect(state.timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
|
|
||||||
|
|
||||||
// 2 more active minutes after resume -> 10 minutes elapsed
|
|
||||||
vi.setSystemTime('jan 2 00:05');
|
|
||||||
update();
|
|
||||||
state = getState();
|
|
||||||
expect(state.timer.elapsed).toBe(10 * MILLIS_PER_MINUTE);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runtime offset', async () => {
|
test('runtime offset', async () => {
|
||||||
|
|||||||
@@ -63,9 +63,7 @@ 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<Instant>;
|
pausedAt: Maybe<TimeOfDay>;
|
||||||
|
|
||||||
/** Accumulate pause duration but dose not include the current pause */
|
|
||||||
pausedDuration: number;
|
pausedDuration: number;
|
||||||
secondaryTarget: Maybe<TimeOfDay>;
|
secondaryTarget: Maybe<TimeOfDay>;
|
||||||
hasFinished: boolean;
|
hasFinished: boolean;
|
||||||
@@ -78,12 +76,10 @@ 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,
|
||||||
@@ -108,12 +104,6 @@ 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 {
|
||||||
@@ -146,7 +136,7 @@ export function clearEventData() {
|
|||||||
runtimeState.rundown.selectedEventIndex = null;
|
runtimeState.rundown.selectedEventIndex = null;
|
||||||
|
|
||||||
runtimeState.timer.playback = Playback.Stop;
|
runtimeState.timer.playback = Playback.Stop;
|
||||||
setClock(runtimeState);
|
runtimeState.clock = timeCore.timeOfDayNow();
|
||||||
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
|
||||||
@@ -179,7 +169,7 @@ export function clearState() {
|
|||||||
runtimeState._end = null;
|
runtimeState._end = null;
|
||||||
|
|
||||||
runtimeState.timer.playback = Playback.Stop;
|
runtimeState.timer.playback = Playback.Stop;
|
||||||
setClock(runtimeState);
|
runtimeState.clock = timeCore.timeOfDayNow();
|
||||||
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
|
||||||
@@ -432,12 +422,15 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setClock(state);
|
const epoch = timeCore.now();
|
||||||
|
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._now - state._timer.pausedAt;
|
const timeToAdd = state.clock - 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;
|
||||||
@@ -454,7 +447,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 = state._now;
|
state._startEpoch = epoch;
|
||||||
state.rundown.actualStart = state.clock;
|
state.rundown.actualStart = state.clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,8 +481,8 @@ export function pause(state: RuntimeState = runtimeState): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.timer.playback = Playback.Pause;
|
state.timer.playback = Playback.Pause;
|
||||||
setClock(state);
|
state.clock = timeCore.timeOfDayNow();
|
||||||
state._timer.pausedAt = state._now;
|
state._timer.pausedAt = state.clock;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,7 +547,9 @@ 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;
|
||||||
setClock(runtimeState); // we update the clock on every update call
|
const epoch = timeCore.now();
|
||||||
|
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)) {
|
||||||
@@ -563,13 +558,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, runtimeState._now);
|
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
|
||||||
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, runtimeState.clock);
|
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now);
|
||||||
return updateIfWaitingToRoll(clockHasCrossedMidnight);
|
return updateIfWaitingToRoll(clockHasCrossedMidnight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { colourToHex, cssOrHexToColour, hexToColour, isLightColour, mixColours } from './colour.utils';
|
||||||
|
|
||||||
|
describe('hexToColour()', () => {
|
||||||
|
it('parses a full length hex', () => {
|
||||||
|
expect(hexToColour('#ff8800')).toStrictEqual({ red: 255, green: 136, blue: 0, alpha: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a compressed hex by duplicating each digit', () => {
|
||||||
|
expect(hexToColour('#f80')).toStrictEqual(hexToColour('#ff8800'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the alpha channel of a full length hex', () => {
|
||||||
|
expect(hexToColour('#ff880000')).toStrictEqual({ red: 255, green: 136, blue: 0, alpha: 0 });
|
||||||
|
expect(hexToColour('#ff8800ff')).toStrictEqual({ red: 255, green: 136, blue: 0, alpha: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the alpha channel of a compressed hex', () => {
|
||||||
|
expect(hexToColour('#f800')).toStrictEqual(hexToColour('#ff880000'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case insensitive', () => {
|
||||||
|
expect(hexToColour('#FF8800')).toStrictEqual(hexToColour('#ff8800'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for values which are not a hex colour', () => {
|
||||||
|
// these are the values which reach us from user input
|
||||||
|
for (const invalid of ['', 'red', '#', '#ff', '#fffff', '#ffg', 'ff8800']) {
|
||||||
|
expect(hexToColour(invalid)).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('colourToHex()', () => {
|
||||||
|
it('pads single digit channels', () => {
|
||||||
|
expect(colourToHex({ red: 0, green: 1, blue: 2, alpha: 1 })).toBe('#000102ff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round trips with hexToColour', () => {
|
||||||
|
for (const hex of ['#000000ff', '#ff8800ff', '#ffffffff', '#12345600']) {
|
||||||
|
expect(colourToHex(hexToColour(hex)!)).toBe(hex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cssOrHexToColour()', () => {
|
||||||
|
it('resolves named css colours', () => {
|
||||||
|
expect(cssOrHexToColour('red')).toStrictEqual({ red: 255, green: 0, blue: 0, alpha: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves named css colours regardless of casing', () => {
|
||||||
|
expect(cssOrHexToColour('CornflowerBlue')).toStrictEqual(cssOrHexToColour('cornflowerblue'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates hex values to the hex parser', () => {
|
||||||
|
expect(cssOrHexToColour('#f80')).toStrictEqual(hexToColour('#f80'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for an unknown colour name', () => {
|
||||||
|
expect(cssOrHexToColour('not-a-colour')).toBeNull();
|
||||||
|
expect(cssOrHexToColour('')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mixColours()', () => {
|
||||||
|
const black = { red: 0, green: 0, blue: 0, alpha: 1 };
|
||||||
|
const white = { red: 255, green: 255, blue: 255, alpha: 1 };
|
||||||
|
|
||||||
|
it('defaults to an even mix', () => {
|
||||||
|
expect(mixColours(black, white)).toStrictEqual({ red: 128, green: 128, blue: 128, alpha: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weights the first colour by the given proportion', () => {
|
||||||
|
expect(mixColours(black, white, 1)).toStrictEqual({ ...black, alpha: 1 });
|
||||||
|
expect(mixColours(black, white, 0)).toStrictEqual({ ...white, alpha: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isLightColour()', () => {
|
||||||
|
it('detects light and dark colours', () => {
|
||||||
|
expect(isLightColour({ red: 255, green: 255, blue: 255, alpha: 1 })).toBe(true);
|
||||||
|
expect(isLightColour({ red: 0, green: 0, blue: 0, alpha: 1 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weights green most heavily, as per the YIQ calculation', () => {
|
||||||
|
// pure green is considered light, pure blue is not
|
||||||
|
expect(isLightColour({ red: 0, green: 255, blue: 0, alpha: 1 })).toBe(true);
|
||||||
|
expect(isLightColour({ red: 0, green: 0, blue: 255, alpha: 1 })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user