Compare commits

...

1 Commits

Author SHA1 Message Date
Carlos Valente 9d2c86d6be feat: count to group completion 2026-09-05 08:36:57 +02:00
39 changed files with 715 additions and 70 deletions
@@ -0,0 +1,81 @@
import { Playback, RuntimeStore, TimerPhase, TimerType, runtimeStorePlaceholder } from 'ontime-types';
import { resolveTimerDisplay } from '../useSocket.utils';
const eventTimer = { ...runtimeStorePlaceholder.timer, current: 5_000 };
const groupTimer = {
...runtimeStorePlaceholder.timer,
current: 25_000,
phase: TimerPhase.Default,
playback: Playback.Play,
};
function makeState(patch: Partial<RuntimeStore> = {}): RuntimeStore {
return {
...runtimeStorePlaceholder,
timer: eventTimer,
eventNow: {
id: 'event-1',
timerType: TimerType.CountUp,
countToEnd: true,
} as RuntimeStore['eventNow'],
...patch,
};
}
describe('resolveTimerDisplay()', () => {
it('uses the event timer by default', () => {
expect(resolveTimerDisplay(makeState())).toMatchObject({
time: eventTimer,
timerType: TimerType.CountUp,
countToEnd: true,
usesGroupTimer: false,
});
});
it('uses the group timer and display type when enabled', () => {
const display = resolveTimerDisplay(
makeState({
groupNow: { useGroupTimer: true, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
groupTimer,
}),
);
expect(display).toMatchObject({
time: groupTimer,
timerType: TimerType.CountDown,
countToEnd: false,
usesGroupTimer: true,
eventTimer,
eventTimerType: TimerType.CountUp,
});
});
it('ignores a group timer when the group setting is disabled', () => {
const display = resolveTimerDisplay(
makeState({
groupNow: { useGroupTimer: false, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
groupTimer,
}),
);
expect(display.time).toBe(eventTimer);
expect(display.usesGroupTimer).toBe(false);
});
it('falls back entirely to the event display while group timer data is unavailable', () => {
const display = resolveTimerDisplay(
makeState({
groupNow: { useGroupTimer: true, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
groupTimer: null,
}),
);
expect(display).toMatchObject({
time: eventTimer,
timerType: TimerType.CountUp,
countToEnd: true,
usesGroupTimer: false,
});
});
});
+35 -24
View File
@@ -1,7 +1,8 @@
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage, TimerType } from 'ontime-types'; import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime'; import { useRuntimeStore } from '../stores/runtime';
import { sendSocket } from '../utils/socket'; import { sendSocket } from '../utils/socket';
import { resolveTimerDisplay } from './useSocket.utils';
const createSelector = const createSelector =
<T>(selector: (state: RuntimeStore) => T) => <T>(selector: (state: RuntimeStore) => T) =>
@@ -38,15 +39,19 @@ export const useExternalMessageInput = createSelector((state: RuntimeStore) => (
visible: state.message.timer.secondarySource === 'secondary', visible: state.message.timer.secondarySource === 'secondary',
})); }));
export const useMessagePreview = createSelector((state: RuntimeStore) => ({ export const useMessagePreview = createSelector((state: RuntimeStore) => {
blink: state.message.timer.blink, const timerDisplay = resolveTimerDisplay(state);
blackout: state.message.timer.blackout, return {
phase: state.timer.phase, blink: state.message.timer.blink,
secondarySource: state.message.timer.secondarySource, blackout: state.message.timer.blackout,
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text), phase: timerDisplay.time.phase,
timerType: state.eventNow?.timerType ?? null, secondarySource: state.message.timer.secondarySource,
countToEnd: state.eventNow?.countToEnd ?? false, showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
})); timerType: timerDisplay.timerType,
countToEnd: timerDisplay.countToEnd,
usesGroupTimer: timerDisplay.usesGroupTimer,
};
});
export const setMessage = { export const setMessage = {
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }), timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }),
@@ -230,20 +235,26 @@ export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
/* ======================= View specific subscriptions ======================= */ /* ======================= View specific subscriptions ======================= */
export const useTimerSocket = createSelector((state: RuntimeStore) => ({ export const useTimerSocket = createSelector((state: RuntimeStore) => {
eventNext: state.eventNext, const timerDisplay = resolveTimerDisplay(state);
eventNow: state.eventNow, return {
message: state.message, eventNext: state.eventNext,
time: state.timer, eventNow: state.eventNow,
clock: state.clock, message: state.message,
timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown, time: timerDisplay.time,
countToEndNow: state.eventNow?.countToEnd ?? false, eventTimer: timerDisplay.eventTimer,
auxTimer: { clock: state.clock,
aux1: state.auxtimer1.current, timerTypeNow: timerDisplay.timerType,
aux2: state.auxtimer2.current, eventTimerType: timerDisplay.eventTimerType,
aux3: state.auxtimer3.current, countToEndNow: timerDisplay.countToEnd,
}, usesGroupTimer: timerDisplay.usesGroupTimer,
})); auxTimer: {
aux1: state.auxtimer1.current,
aux2: state.auxtimer2.current,
aux3: state.auxtimer3.current,
},
};
});
export const useCountdownSocket = createSelector((state: RuntimeStore) => ({ export const useCountdownSocket = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback, playback: state.timer.playback,
@@ -0,0 +1,27 @@
import { RuntimeStore, TimerType } from 'ontime-types';
type TimerDisplaySource = Pick<RuntimeStore, 'eventNow' | 'groupNow' | 'groupTimer' | 'timer'>;
export function resolveTimerDisplay(state: TimerDisplaySource) {
const eventTimerType = state.eventNow?.timerType ?? TimerType.CountDown;
if (state.groupNow?.useGroupTimer === true && state.groupTimer !== null) {
return {
time: state.groupTimer,
timerType: state.groupNow.timerType,
countToEnd: false,
usesGroupTimer: true,
eventTimer: state.timer,
eventTimerType,
};
}
return {
time: state.timer,
timerType: eventTimerType,
countToEnd: state.eventNow?.countToEnd ?? false,
usesGroupTimer: false,
eventTimer: state.timer,
eventTimerType,
};
}
@@ -1,6 +1,6 @@
import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types'; import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry, TimerType } from 'ontime-types';
import { initRundownMetadata } from '../rundownMetadata'; import { getFlatRundownMetadata, initRundownMetadata } from '../rundownMetadata';
describe('initRundownMetadata()', () => { describe('initRundownMetadata()', () => {
it('processes nested rundown data', () => { it('processes nested rundown data', () => {
@@ -300,3 +300,36 @@ describe('initRundownMetadata()', () => {
}); });
}); });
}); });
describe('getFlatRundownMetadata()', () => {
it('exposes group timer settings on a group and its events', () => {
const group = {
id: 'group',
type: SupportedEntry.Group,
entries: ['event'],
colour: 'red',
useGroupTimer: true,
timerType: TimerType.CountUp,
} as OntimeGroup;
const event = {
id: 'event',
type: SupportedEntry.Event,
parent: group.id,
timeStart: 0,
timeEnd: 1,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent;
const flat = getFlatRundownMetadata(
{ entries: { [group.id]: group, [event.id]: event }, flatOrder: [group.id, event.id] },
null,
);
expect(flat[0]).toMatchObject({ groupUsesTimer: true, groupTimerType: TimerType.CountUp });
expect(flat[1]).toMatchObject({ groupUsesTimer: true, groupTimerType: TimerType.CountUp });
});
});
@@ -3,9 +3,11 @@ import {
OntimeDelay, OntimeDelay,
OntimeEntry, OntimeEntry,
OntimeEvent, OntimeEvent,
OntimeGroup,
OntimeMilestone, OntimeMilestone,
PlayableEvent, PlayableEvent,
Rundown, Rundown,
TimerType,
isOntimeEvent, isOntimeEvent,
isOntimeGroup, isOntimeGroup,
isPlayableEvent, isPlayableEvent,
@@ -29,7 +31,11 @@ export type RundownMetadata = {
isFirstAfterGroup: boolean; isFirstAfterGroup: boolean;
}; };
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata; export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T &
RundownMetadata & {
groupUsesTimer?: boolean;
groupTimerType?: TimerType;
};
export const lastMetadataKey = 'LAST'; export const lastMetadataKey = 'LAST';
@@ -65,10 +71,23 @@ export function getFlatRundownMetadata(
): ExtendedEntry[] { ): ExtendedEntry[] {
const { process } = initRundownMetadata(selectedEventId); const { process } = initRundownMetadata(selectedEventId);
const flatRundown: ExtendedEntry[] = []; const flatRundown: ExtendedEntry[] = [];
let activeGroup: OntimeGroup | null = null;
for (const id of data.flatOrder) { for (const id of data.flatOrder) {
const entry = data.entries[id]; const entry = data.entries[id];
const extendedEntry = { ...entry, ...process(entry) }; if (isOntimeGroup(entry)) {
activeGroup = entry;
} else if (entry.parent !== activeGroup?.id) {
activeGroup = null;
}
const timerGroup = isOntimeGroup(entry) ? entry : activeGroup;
const extendedEntry = {
...entry,
...process(entry),
groupUsesTimer: timerGroup?.useGroupTimer ?? false,
groupTimerType: timerGroup?.timerType,
};
flatRundown.push(extendedEntry); flatRundown.push(extendedEntry);
} }
@@ -27,6 +27,13 @@
border-top: 1px solid $white-7; border-top: 1px solid $white-7;
} }
.timerSource {
color: $active-indicator;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.blackout { .blackout {
display: none; display: none;
} }
@@ -1,5 +1,5 @@
import { TimerPhase, TimerType } from 'ontime-types'; import { TimerPhase, TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5'; import { IoArrowDown, IoArrowUp, IoBan, IoTime, IoTimerOutline } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu'; import { LuArrowDownToLine } from 'react-icons/lu';
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils'; import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
@@ -20,7 +20,8 @@ const secondarySourceLabels: Record<string, string> = {
}; };
export default function TimerPreview() { export default function TimerPreview() {
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview(); const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType, usesGroupTimer } =
useMessagePreview();
const { data } = useViewSettings(); const { data } = useViewSettings();
const main = (() => { const main = (() => {
@@ -35,7 +36,9 @@ export default function TimerPreview() {
const secondary = (() => { const secondary = (() => {
// message is a fullscreen overlay or secondary is not active // message is a fullscreen overlay or secondary is not active
if (showTimerMessage || !secondarySource) return null; if (showTimerMessage) return null;
if (usesGroupTimer) return 'Event timer';
if (!secondarySource) return null;
// we need to check aux first since it takes priority // we need to check aux first since it takes priority
return secondarySourceLabels[secondarySource]; return secondarySourceLabels[secondarySource];
@@ -55,6 +58,7 @@ export default function TimerPreview() {
<div className={style.preview}> <div className={style.preview}>
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} /> <CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
<div className={contentClasses}> <div className={contentClasses}>
{usesGroupTimer && <div className={style.timerSource}>Group timer</div>}
<div <div
className={style.mainContent} className={style.mainContent}
data-phase={showColourOverride && phase} data-phase={showColourOverride && phase}
@@ -65,6 +69,14 @@ export default function TimerPreview() {
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>} {secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
</div> </div>
<div className={style.eventStatus}> <div className={style.eventStatus}>
<Tooltip
text='Timer display controlled by group'
render={<span />}
className={style.statusIcon}
data-active={usesGroupTimer}
>
<IoTimerOutline />
</Tooltip>
<Tooltip <Tooltip
text='Time type: Count down' text='Time type: Count down'
render={<span />} render={<span />}
@@ -32,6 +32,12 @@
gap: 1rem; gap: 1rem;
} }
.timerDisplaySettings {
display: flex;
flex-direction: column;
gap: 1rem;
}
.column { .column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1,10 +1,12 @@
import { MaybeNumber, OntimeGroup } from 'ontime-types'; import { MaybeNumber, OntimeGroup, TimerType } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { useCallback } from 'react'; import { useCallback } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import AppLink from '../../../common/components/link/app-link/AppLink'; import AppLink from '../../../common/components/link/app-link/AppLink';
import Select from '../../../common/components/select/Select';
import Switch from '../../../common/components/switch/Switch';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getOffsetState } from '../../../common/utils/offset'; import { getOffsetState } from '../../../common/utils/offset';
@@ -107,6 +109,38 @@ export default function GroupEditor({ group }: GroupEditorProps) {
<EventTextArea field='note' label='Note' initialValue={group.note} submitHandler={handleSubmit} /> <EventTextArea field='note' label='Note' initialValue={group.note} submitHandler={handleSubmit} />
</div> </div>
<div className={style.column}>
<Editor.Title>Timer display</Editor.Title>
<div className={style.timerDisplaySettings}>
<div>
<Editor.Label htmlFor='useGroupTimer'>Use group timer</Editor.Label>
<Editor.Label className={style.switchLabel}>
<Switch
id='useGroupTimer'
checked={group.useGroupTimer}
onCheckedChange={(useGroupTimer) => updateEntry({ id: group.id, useGroupTimer })}
/>
{group.useGroupTimer ? 'On' : 'Off'}
</Editor.Label>
</div>
<div>
<Editor.Label htmlFor='groupTimerType'>Timer type</Editor.Label>
<Select
id='groupTimerType'
disabled={!group.useGroupTimer}
value={group.timerType}
onValueChange={(timerType: TimerType | null) => {
if (timerType !== null) updateEntry({ id: group.id, timerType });
}}
options={[
{ value: TimerType.CountDown, label: 'Count down' },
{ value: TimerType.CountUp, label: 'Count up' },
]}
/>
</div>
</div>
</div>
<div className={style.column}> <div className={style.column}>
<Editor.Title> <Editor.Title>
Custom Fields Custom Fields
@@ -335,6 +335,7 @@ export default function RundownEvent({
eventIndex={eventIndex} eventIndex={eventIndex}
endAction={endAction} endAction={endAction}
timerType={timerType} timerType={timerType}
groupTimerType={parentGroup?.useGroupTimer ? parentGroup.timerType : undefined}
title={title} title={title}
note={note} note={note}
delay={delay} delay={delay}
@@ -9,6 +9,7 @@ import {
IoPlayForward, IoPlayForward,
IoPlaySkipForward, IoPlaySkipForward,
IoTime, IoTime,
IoTimerOutline,
} from 'react-icons/io5'; } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu'; import { LuArrowDownToLine } from 'react-icons/lu';
@@ -35,6 +36,7 @@ interface RundownEventInnerProps {
eventIndex: number; eventIndex: number;
endAction: EndAction; endAction: EndAction;
timerType: TimerType; timerType: TimerType;
groupTimerType?: TimerType;
title: string; title: string;
note: string; note: string;
delay: number; delay: number;
@@ -62,6 +64,7 @@ function RundownEventInner({
countToEnd, countToEnd,
endAction, endAction,
timerType, timerType,
groupTimerType,
title, title,
note, note,
delay, delay,
@@ -150,6 +153,14 @@ function RundownEventInner({
{loaded && <EventBlockProgressBar />} {loaded && <EventBlockProgressBar />}
</div> </div>
<div className={style.eventStatus} tabIndex={-1}> <div className={style.eventStatus} tabIndex={-1}>
{groupTimerType && (
<Tooltip
text={`Timer display controlled by group (${groupTimerType === TimerType.CountUp ? 'count up' : 'count down'})`}
render={<span />}
>
<IoTimerOutline className={cx([style.statusIcon, style.active])} />
</Tooltip>
)}
<Tooltip text={`Time type: ${timerType}`} render={<span />}> <Tooltip text={`Time type: ${timerType}`} render={<span />}>
<TimerIcon type={timerType} className={style.statusIcon} /> <TimerIcon type={timerType} className={style.statusIcon} />
</Tooltip> </Tooltip>
@@ -55,6 +55,14 @@
gap: 0.5rem; gap: 0.5rem;
} }
.timerIndicator {
display: grid;
flex: 0 0 1.5rem;
place-items: center;
color: $active-indicator;
font-size: 1rem;
}
.metaRow { .metaRow {
display: flex; display: flex;
gap: $block-clearance; // same as RundownEvent.eventTimers gap: $block-clearance; // same as RundownEvent.eventTimers
@@ -1,6 +1,6 @@
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { EntryId, OntimeGroup } from 'ontime-types'; import { EntryId, OntimeGroup, TimerType } from 'ontime-types';
import { MILLIS_PER_MINUTE } from 'ontime-utils'; import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { MouseEvent, useCallback, useRef } from 'react'; import { MouseEvent, useCallback, useRef } from 'react';
import { import {
@@ -9,6 +9,7 @@ import {
IoDuplicateOutline, IoDuplicateOutline,
IoFolderOpenOutline, IoFolderOpenOutline,
IoReorderTwo, IoReorderTwo,
IoTimerOutline,
IoTrash, IoTrash,
IoLockClosed, IoLockClosed,
} from 'react-icons/io5'; } from 'react-icons/io5';
@@ -174,6 +175,14 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
<div className={style.header}> <div className={style.header}>
<div className={style.titleRow}> <div className={style.titleRow}>
<TitleEditor title={data.title} entryId={data.id} placeholder='Group title' /> <TitleEditor title={data.title} entryId={data.id} placeholder='Group title' />
{data.useGroupTimer && (
<Tooltip
text={`Group timer (${data.timerType === TimerType.CountUp ? 'count up' : 'count down'})`}
render={<span className={style.timerIndicator} />}
>
<IoTimerOutline />
</Tooltip>
)}
<IconButton aria-label='Collapse' variant='subtle-white' onClick={() => onCollapse(!collapsed, data.id)}> <IconButton aria-label='Collapse' variant='subtle-white' onClick={() => onCollapse(!collapsed, data.id)}>
{collapsed ? <IoChevronUp /> : <IoChevronDown />} {collapsed ? <IoChevronUp /> : <IoChevronDown />}
</IconButton> </IconButton>
@@ -0,0 +1,13 @@
.timerOverrideCell {
display: grid;
grid-template-columns: minmax(0, 1fr) 1.5rem;
align-items: center;
gap: 0.25rem;
}
.timerOverrideIndicator {
display: grid;
place-items: center;
color: $active-indicator;
font-size: 1rem;
}
@@ -1,8 +1,18 @@
import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types'; import {
CustomFields,
TimeStrategy,
TimerType,
URLPreset,
isOntimeDelay,
isOntimeEvent,
isOntimeGroup,
} from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { IoTimerOutline } from 'react-icons/io5';
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator'; import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata'; import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { formatDuration, formatTime } from '../../../../common/utils/time'; import { formatDuration, formatTime } from '../../../../common/utils/time';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
@@ -17,6 +27,8 @@ import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell'; import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput'; import TimeInput from './TimeInput';
import style from './cuesheetColsFactory.module.scss';
function getColumnLabel(column: CuesheetCellContext['column']): string { function getColumnLabel(column: CuesheetCellContext['column']): string {
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id; return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
} }
@@ -193,17 +205,38 @@ function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
} }
const canWrite = column.columnDef.meta?.canWrite; const canWrite = column.columnDef.meta?.canWrite;
if (!canWrite) { const content = canWrite ? (
return <GhostedText>{initialValue}</GhostedText>;
}
return (
<SingleLineCell <SingleLineCell
initialValue={initialValue as string} initialValue={initialValue as string}
fieldId={column.id} fieldId={column.id}
fieldLabel={getColumnLabel(column)} fieldLabel={getColumnLabel(column)}
handleUpdate={update} handleUpdate={update}
/> />
) : (
<GhostedText>{initialValue}</GhostedText>
);
if (column.id !== 'title') {
return content;
}
const isGroupOverride = isOntimeGroup(row.original) && row.original.useGroupTimer;
const isEventOverride = isOntimeEvent(row.original) && row.original.groupUsesTimer;
if (!isGroupOverride && !isEventOverride) {
return content;
}
const timerType = isOntimeGroup(row.original) ? row.original.timerType : row.original.groupTimerType;
const direction = timerType === TimerType.CountUp ? 'count up' : 'count down';
const tooltip = isGroupOverride ? `Group timer (${direction})` : `Timer display controlled by group (${direction})`;
return (
<div className={style.timerOverrideCell}>
{content}
<Tooltip text={tooltip} render={<span className={style.timerOverrideIndicator} />}>
<IoTimerOutline />
</Tooltip>
</div>
); );
} }
@@ -35,6 +35,14 @@
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
.timer-source {
color: $viewer-label-color;
font-size: 0.75rem;
font-weight: 600;
text-align: center;
text-transform: uppercase;
}
.timer { .timer {
opacity: 1; opacity: 1;
font-family: $viewer-font-family; font-family: $viewer-font-family;
@@ -7,6 +7,7 @@ import { cx } from '../../../common/utils/styleUtils';
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils'; import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
import { import {
getEstimatedFontSize, getEstimatedFontSize,
getEventTimerSecondary,
getIsPlaying, getIsPlaying,
getSecondaryDisplay, getSecondaryDisplay,
getShowMessage, getShowMessage,
@@ -23,7 +24,18 @@ interface PipTimerProps {
} }
export function PipTimer({ viewSettings }: PipTimerProps) { export function PipTimer({ viewSettings }: PipTimerProps) {
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket(); const {
eventNow,
message,
time,
eventTimer,
clock,
timerTypeNow,
eventTimerType,
countToEndNow,
usesGroupTimer,
auxTimer,
} = useTimerSocket();
// gather modifiers // gather modifiers
const showOverlay = getShowMessage(message.timer); const showOverlay = getShowMessage(message.timer);
@@ -59,7 +71,9 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
return null; return null;
})(); })();
const secondaryContent = getSecondaryDisplay(message, currentAux, 'min', false, true, false); const secondaryContent = usesGroupTimer
? getEventTimerSecondary(eventTimer, eventTimerType, clock, 'min', false, true)
: getSecondaryDisplay(message, currentAux, 'min', false, true, false);
// gather presentation styles // gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger); const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
@@ -77,6 +91,7 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
</div> </div>
<div className='timer-container'> <div className='timer-container'>
{usesGroupTimer && <div className='timer-source'>Group timer</div>}
<div <div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])} className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }} style={{ fontSize: `${timerFontSize}vw` }}
@@ -96,11 +111,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])} className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current} now={time.current}
complete={totalTime} complete={totalTime}
eventId={eventNow?.id} eventId={usesGroupTimer ? undefined : eventNow?.id}
normalColor={viewSettings.normalColor} normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning} warning={usesGroupTimer ? undefined : eventNow?.timeWarning}
warningColor={viewSettings.warningColor} warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger} danger={usesGroupTimer ? undefined : eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor} dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished} hideOvertime={!showFinished}
/> />
+8
View File
@@ -89,6 +89,14 @@
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
.timer-source {
color: var(--label-color-override, $viewer-label-color);
font-size: $timer-label-size;
font-weight: 600;
text-align: center;
text-transform: uppercase;
}
.end-message { .end-message {
text-align: center; text-align: center;
font-size: 11.5vw; font-size: 11.5vw;
+31 -13
View File
@@ -22,6 +22,7 @@ import { getTimerOptions, useTimerOptions } from './timer.options';
import { import {
getCardData, getCardData,
getEstimatedFontSize, getEstimatedFontSize,
getEventTimerSecondary,
getIsPlaying, getIsPlaying,
getSecondaryDisplay, getSecondaryDisplay,
getShowClock, getShowClock,
@@ -52,7 +53,19 @@ export default function TimerLoader() {
} }
function Timer({ customFields, projectData, isMirrored, settings, viewSettings, entries }: TimerData) { function Timer({ customFields, projectData, isMirrored, settings, viewSettings, entries }: TimerData) {
const { eventNext, eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket(); const {
eventNext,
eventNow,
message,
time,
eventTimer,
clock,
timerTypeNow,
eventTimerType,
countToEndNow,
usesGroupTimer,
auxTimer,
} = useTimerSocket();
const { const {
hideClock, hideClock,
hideCards, hideCards,
@@ -78,7 +91,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes'); const localisedMinutes = getLocalizedString('common.minutes');
const showSoundPrompt = useTimerSound(time.phase, endSound); const showSoundPrompt = useTimerSound(eventTimer.phase, endSound);
// gather modifiers // gather modifiers
const viewTimerType = timerType ?? timerTypeNow; const viewTimerType = timerType ?? timerTypeNow;
@@ -128,14 +141,18 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
return null; return null;
})(); })();
const secondaryContent = getSecondaryDisplay( const secondaryContent =
message, usesGroupTimer && !hideSecondary
currentAux, ? getEventTimerSecondary(
localisedMinutes, eventTimer,
hideTimerSeconds, eventTimerType,
removeLeadingZeros, clock,
hideSecondary, localisedMinutes,
); hideTimerSeconds,
removeLeadingZeros,
timeformat,
)
: getSecondaryDisplay(message, currentAux, localisedMinutes, hideTimerSeconds, removeLeadingZeros, hideSecondary);
// gather presentation styles // gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger); const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
@@ -176,6 +193,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
{showClock && <TimerAutoTickingClock clockFormat={timeformat} />} {showClock && <TimerAutoTickingClock clockFormat={timeformat} />}
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}> <div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
{usesGroupTimer && <div className='timer-source'>Group timer</div>}
{showEndMessage ? ( {showEndMessage ? (
<FitText mode='multi' min={64} max={256} className='end-message'> <FitText mode='multi' min={64} max={256} className='end-message'>
{freezeMessage} {freezeMessage}
@@ -202,11 +220,11 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])} className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current} now={time.current}
complete={totalTime} complete={totalTime}
eventId={eventNow?.id} eventId={usesGroupTimer ? undefined : eventNow?.id}
normalColor={viewSettings.normalColor} normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning} warning={usesGroupTimer ? undefined : eventNow?.timeWarning}
warningColor={viewSettings.warningColor} warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger} danger={usesGroupTimer ? undefined : eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor} dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished} hideOvertime={!showFinished}
/> />
@@ -1,6 +1,32 @@
import { TimerPhase } from 'ontime-types'; import { TimerPhase, TimerType } from 'ontime-types';
import { shouldPlayEndSound } from '../timer.utils'; import { getEventTimerSecondary, shouldPlayEndSound } from '../timer.utils';
describe('getEventTimerSecondary()', () => {
it('formats the event countdown as a labelled secondary value', () => {
expect(
getEventTimerSecondary({ current: 65_000, elapsed: 5_000 }, TimerType.CountDown, 0, 'min', false, false),
).toBe('Event timer 00:01:05');
});
it('preserves the event count-up display', () => {
expect(getEventTimerSecondary({ current: 55_000, elapsed: 5_000 }, TimerType.CountUp, 0, 'min', false, false)).toBe(
'Event timer 00:00:05',
);
});
it('falls back to remaining time when the event timer is hidden', () => {
expect(getEventTimerSecondary({ current: 5_000, elapsed: 55_000 }, TimerType.None, 0, 'min', false, false)).toBe(
'Event timer 00:00:05',
);
});
it('shows event progress instead of wall-clock time for clock events', () => {
expect(
getEventTimerSecondary({ current: 5_000, elapsed: 55_000 }, TimerType.Clock, 12_000, 'min', false, false),
).toBe('Event timer 00:00:05');
});
});
describe('shouldPlayEndSound()', () => { describe('shouldPlayEndSound()', () => {
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])( test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
+21 -1
View File
@@ -6,11 +6,12 @@ import {
RundownEntries, RundownEntries,
TimerMessage, TimerMessage,
TimerPhase, TimerPhase,
TimerState,
TimerType, TimerType,
} from 'ontime-types'; } from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils'; import { isPlaybackActive } from 'ontime-utils';
import { getFormattedTimer, getPropertyValue } from '../common/viewUtils'; import { getFormattedTimer, getPropertyValue, getTimerByType } from '../common/viewUtils';
/** /**
* Whether a message should be shown * Whether a message should be shown
@@ -144,6 +145,25 @@ export function getSecondaryDisplay(
return; return;
} }
export function getEventTimerSecondary(
timer: Pick<TimerState, 'current' | 'elapsed'>,
timerType: TimerType,
clock: number,
localisedMinutes: string,
removeSeconds: boolean,
removeLeadingZero: boolean,
clockFormat?: string | null,
): string {
const effectiveType = timerType === TimerType.CountUp ? TimerType.CountUp : TimerType.CountDown;
const value = getTimerByType(false, effectiveType, clock, timer);
const display = getFormattedTimer(value, effectiveType, localisedMinutes, {
removeSeconds,
removeLeadingZero,
clockFormat,
});
return `Event timer ${display}`;
}
/** /**
* What should we be showing in the cards? * What should we be showing in the cards?
*/ */
@@ -417,7 +417,7 @@ export function migrateRundown(
timeEnd: null, timeEnd: null,
duration: 0, duration: 0,
isFirstLinked: false, isFirstLinked: false,
}); } as unknown as OntimeEntry);
} else if (entry.type === 'delay') { } else if (entry.type === 'delay') {
append({ id: entry.id, type: SupportedEntry.Delay, duration: entry.duration, parent }); append({ id: entry.id, type: SupportedEntry.Delay, duration: entry.duration, parent });
} }
@@ -1,4 +1,4 @@
import { CustomFields, OntimeEvent, OntimeGroup, Rundown, SupportedEntry } from 'ontime-types'; import { CustomFields, OntimeEvent, OntimeGroup, Rundown, SupportedEntry, TimerType } from 'ontime-types';
import { makeNewRundown } from '../../../models/dataModel.js'; import { makeNewRundown } from '../../../models/dataModel.js';
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js'; import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js';
@@ -276,7 +276,13 @@ describe('parseRundown()', () => {
expect(parsedRundown.order).toStrictEqual(['group']); expect(parsedRundown.order).toStrictEqual(['group']);
expect(parsedRundown.flatOrder).toStrictEqual(['group', '1', '2']); expect(parsedRundown.flatOrder).toStrictEqual(['group', '1', '2']);
expect(parsedRundown.entries).toMatchObject({ expect(parsedRundown.entries).toMatchObject({
group: { id: 'group', type: SupportedEntry.Group, entries: ['1', '2'] }, group: {
id: 'group',
type: SupportedEntry.Group,
entries: ['1', '2'],
useGroupTimer: false,
timerType: TimerType.CountDown,
},
'1': { id: '1', type: SupportedEntry.Event }, '1': { id: '1', type: SupportedEntry.Event },
'2': { id: '2', type: SupportedEntry.Milestone }, '2': { id: '2', type: SupportedEntry.Milestone },
}); });
@@ -25,6 +25,7 @@ import { parseRundown } from '../rundown.parser.js';
import { import {
calculateDayOffset, calculateDayOffset,
cloneEntryData, cloneEntryData,
createGroupPatch,
deleteById, deleteById,
doesInvalidateMetadata, doesInvalidateMetadata,
getIntegerAndFraction, getIntegerAndFraction,
@@ -36,6 +37,25 @@ import {
} from '../rundown.utils.js'; } from '../rundown.utils.js';
describe('test event validator', () => { describe('test event validator', () => {
it('creates groups with the shared timer disabled by default', () => {
expect(createGroup({ id: 'group' })).toMatchObject({
useGroupTimer: false,
timerType: TimerType.CountDown,
});
});
it('limits group timers to count down and count up', () => {
expect(createGroup({ timerType: TimerType.CountUp }).timerType).toBe(TimerType.CountUp);
expect(createGroup({ timerType: TimerType.Clock }).timerType).toBe(TimerType.CountDown);
});
it('rejects non-boolean group timer updates', () => {
const group = createGroup({ useGroupTimer: false });
const updated = createGroupPatch(group, { useGroupTimer: 'true' as never });
expect(updated.useGroupTimer).toBe(false);
});
it('validates a good object', () => { it('validates a good object', () => {
const event = { const event = {
title: 'test', title: 'test',
@@ -33,6 +33,7 @@ import {
makeString, makeString,
maxDuration, maxDuration,
validateEndAction, validateEndAction,
validateGroupTimerType,
validateTimerType, validateTimerType,
validateTimes, validateTimes,
} from 'ontime-utils'; } from 'ontime-utils';
@@ -180,6 +181,9 @@ export function createGroupPatch(originalGroup: OntimeGroup, patchGroup: Partial
note: makeString(patchGroup.note, originalGroup.note), note: makeString(patchGroup.note, originalGroup.note),
entries: patchGroup.entries ?? originalGroup.entries, entries: patchGroup.entries ?? originalGroup.entries,
targetDuration: maybeTargetDuration(), targetDuration: maybeTargetDuration(),
useGroupTimer:
typeof patchGroup.useGroupTimer === 'boolean' ? patchGroup.useGroupTimer : originalGroup.useGroupTimer,
timerType: validateGroupTimerType(patchGroup.timerType, originalGroup.timerType),
colour: makeString(patchGroup.colour, originalGroup.colour), colour: makeString(patchGroup.colour, originalGroup.colour),
revision: originalGroup.revision, revision: originalGroup.revision,
timeStart: originalGroup.timeStart, timeStart: originalGroup.timeStart,
+1
View File
@@ -206,6 +206,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
eventStore.init({ eventStore.init({
clock: state.clock, clock: state.clock,
timer: state.timer, timer: state.timer,
groupTimer: state.groupTimer,
message: { ...runtimeStorePlaceholder.message }, message: { ...runtimeStorePlaceholder.message },
offset: state.offset, offset: state.offset,
rundown: state.rundown, rundown: state.rundown,
+22
View File
@@ -39,6 +39,8 @@ export const stageRundown: Rundown = {
note: '', note: '',
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'], entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -172,6 +174,8 @@ export const stageRundown: Rundown = {
note: '', note: '',
entries: ['0aaa7d'], entries: ['0aaa7d'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8', colour: '#3E75E8',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -219,6 +223,8 @@ export const stageRundown: Rundown = {
note: '', note: '',
entries: ['02afca', '75ce86', 'e10ed9', '07df89'], entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -360,6 +366,8 @@ export const backstageRundown: Rundown = {
note: '', note: '',
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'], entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#A790F5', colour: '#A790F5',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -476,6 +484,8 @@ export const backstageRundown: Rundown = {
note: '', note: '',
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'], entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -588,6 +598,8 @@ export const backstageRundown: Rundown = {
note: '', note: '',
entries: ['bs0301'], entries: ['bs0301'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8', colour: '#3E75E8',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -634,6 +646,8 @@ export const backstageRundown: Rundown = {
note: '', note: '',
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'], entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -768,6 +782,8 @@ export const broadcastRundown: Rundown = {
note: '', note: '',
entries: ['br0101', 'br0102', 'br0103'], entries: ['br0101', 'br0102', 'br0103'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#ED3333', colour: '#ED3333',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -851,6 +867,8 @@ export const broadcastRundown: Rundown = {
note: '', note: '',
entries: ['br0201', 'br0202', 'br0203', 'br0204'], entries: ['br0201', 'br0202', 'br0203', 'br0204'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -959,6 +977,8 @@ export const broadcastRundown: Rundown = {
note: '', note: '',
entries: ['br0301', 'br0302'], entries: ['br0301', 'br0302'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8', colour: '#3E75E8',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -1029,6 +1049,8 @@ export const broadcastRundown: Rundown = {
note: '', note: '',
entries: ['br0401', 'br0402', 'br0403'], entries: ['br0401', 'br0402', 'br0403'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -4,6 +4,7 @@ import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisT
import type { RuntimeState } from '../../stores/runtimeState.js'; import type { RuntimeState } from '../../stores/runtimeState.js';
import { import {
findDayOffset, findDayOffset,
getGroupTimer,
getCurrent, getCurrent,
getElapsed, getElapsed,
getExpectedFinish, getExpectedFinish,
@@ -16,6 +17,91 @@ import {
const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock']; const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock'];
describe('getGroupTimer()', () => {
const makeState = (patch: Partial<RuntimeState> = {}) =>
({
clock: asTimeOfDay(10_000),
groupNow: {
duration: 20_000,
},
rundown: {
actualGroupStart: 5_000,
},
timer: {
addedTime: 4_000,
current: 1_000,
elapsed: 99_000,
phase: TimerPhase.Danger,
playback: Playback.Play,
},
...patch,
}) as RuntimeState;
it('returns null outside a group', () => {
expect(getGroupTimer(makeState({ groupNow: null }))).toBeNull();
});
it('derives elapsed and remaining time from the group start and duration', () => {
expect(getGroupTimer(makeState())).toMatchObject({
addedTime: 0,
current: 15_000,
duration: 20_000,
elapsed: 5_000,
expectedFinish: null,
phase: TimerPhase.Default,
playback: Playback.Play,
secondaryTimer: null,
startedAt: 5_000,
});
});
it('does not inherit event timer progress or added time', () => {
const first = getGroupTimer(makeState());
const second = getGroupTimer(
makeState({
timer: {
...makeState().timer,
addedTime: -8_000,
current: -50_000,
elapsed: 500_000,
},
}),
);
expect(second).toEqual(first);
});
it('keeps running while the loaded event is paused', () => {
const timer = getGroupTimer(makeState({ timer: { ...makeState().timer, playback: Playback.Pause } }));
expect(timer?.playback).toBe(Playback.Play);
});
it('handles a group running across midnight', () => {
const timer = getGroupTimer(
makeState({
clock: asTimeOfDay(1_000),
rundown: { ...makeState().rundown, actualGroupStart: 86_399_000 },
}),
);
expect(timer).toMatchObject({ current: 18_000, elapsed: 2_000 });
});
it('uses the event phase before the group starts and overtime afterwards', () => {
const pending = getGroupTimer(
makeState({
rundown: { ...makeState().rundown, actualGroupStart: null },
timer: { ...makeState().timer, phase: TimerPhase.Pending },
}),
);
const overtime = getGroupTimer(makeState({ clock: asTimeOfDay(30_001) }));
expect(pending).toMatchObject({ current: 20_000, elapsed: null, phase: TimerPhase.Pending });
expect(overtime).toMatchObject({ current: -5_001, elapsed: 25_001, phase: TimerPhase.Overtime });
});
});
describe('getElapsed()', () => { describe('getElapsed()', () => {
it('returns active elapsed time from startedAt without add-time adjustments', () => { it('returns active elapsed time from startedAt without add-time adjustments', () => {
const state = { const state = {
@@ -7,6 +7,7 @@ import {
findPreviousPlayableId, findPreviousPlayableId,
getEventAtIndex, getEventAtIndex,
getShouldClockUpdate, getShouldClockUpdate,
getShouldGroupTimerUpdate,
getShouldOffsetUpdate, getShouldOffsetUpdate,
getShouldTimerUpdate, getShouldTimerUpdate,
isNewSecond, isNewSecond,
@@ -95,6 +96,34 @@ describe('getShouldTimerUpdate()', () => {
}); });
}); });
describe('getShouldGroupTimerUpdate()', () => {
const timer: TimerState = {
addedTime: 0,
current: 10_000,
duration: 10_000,
elapsed: 0,
expectedFinish: null,
phase: TimerPhase.Default,
playback: Playback.Play,
secondaryTimer: null,
startedAt: 0,
};
it('updates when a group timer appears or disappears', () => {
expect(getShouldGroupTimerUpdate(null, timer)).toBe(true);
expect(getShouldGroupTimerUpdate(timer, null)).toBe(true);
});
it('does not repeatedly publish an absent group timer', () => {
expect(getShouldGroupTimerUpdate(null, null)).toBe(false);
});
it('uses normal timer tick semantics while a group timer exists', () => {
expect(getShouldGroupTimerUpdate(timer, { ...timer, current: 9_500 })).toBe(false);
expect(getShouldGroupTimerUpdate(timer, { ...timer, current: 8_999 })).toBe(true);
});
});
describe('getShouldOffsetUpdate()', () => { describe('getShouldOffsetUpdate()', () => {
const baseOffset: Offset = { const baseOffset: Offset = {
absolute: 0, absolute: 0,
@@ -25,7 +25,7 @@ import { logger } from '../../classes/Logger.js';
import { timerConfig } from '../../setup/config.js'; import { timerConfig } from '../../setup/config.js';
import { eventStore } from '../../stores/EventStore.js'; import { eventStore } from '../../stores/EventStore.js';
import * as runtimeState from '../../stores/runtimeState.js'; import * as runtimeState from '../../stores/runtimeState.js';
import type { RuntimeState } from '../../stores/runtimeState.js'; import type { RuntimeState, RuntimeStateSnapshot } from '../../stores/runtimeState.js';
import { EventTimer } from '../EventTimer.js'; import { EventTimer } from '../EventTimer.js';
import { restoreService } from '../restore-service/restore.service.js'; import { restoreService } from '../restore-service/restore.service.js';
import type { RestorePoint } from '../restore-service/restore.type.js'; import type { RestorePoint } from '../restore-service/restore.type.js';
@@ -36,6 +36,7 @@ import {
findPreviousPlayableId, findPreviousPlayableId,
getEventAtIndex, getEventAtIndex,
getShouldClockUpdate, getShouldClockUpdate,
getShouldGroupTimerUpdate,
getShouldOffsetUpdate, getShouldOffsetUpdate,
getShouldTimerUpdate, getShouldTimerUpdate,
isNewSecond, isNewSecond,
@@ -51,11 +52,11 @@ class RuntimeService {
private lastIntegrationTimerValue = -1; private lastIntegrationTimerValue = -1;
/** last known state */ /** last known state */
static previousState: RuntimeState; static previousState: RuntimeStateSnapshot;
constructor(eventTimer: EventTimer) { constructor(eventTimer: EventTimer) {
this.eventTimer = eventTimer; this.eventTimer = eventTimer;
RuntimeService.previousState = {} as RuntimeState; RuntimeService.previousState = {} as RuntimeStateSnapshot;
} }
@broadcastResult @broadcastResult
@@ -710,6 +711,12 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
RuntimeService.previousState.timer = { ...state.timer }; RuntimeService.previousState.timer = { ...state.timer };
} }
const updateGroupTimer = getShouldGroupTimerUpdate(RuntimeService.previousState.groupTimer, state.groupTimer);
if (updateGroupTimer) {
batch.add('groupTimer', state.groupTimer);
RuntimeService.previousState.groupTimer = state.groupTimer ? { ...state.groupTimer } : null;
}
/** /**
* clock has changed by a second or more. * clock has changed by a second or more.
* or the timer updated so we ensure that the timer and clock ticks are in sync * or the timer updated so we ensure that the timer and clock ticks are in sync
@@ -54,6 +54,15 @@ export function getShouldTimerUpdate(previousValue: TimerState | undefined, curr
); );
} }
export function getShouldGroupTimerUpdate(
previousValue: TimerState | null | undefined,
currentValue: TimerState | null,
): boolean {
if (previousValue === undefined) return true;
if (previousValue === null || currentValue === null) return previousValue !== currentValue;
return getShouldTimerUpdate(previousValue, currentValue);
}
/** /**
* Checks whether we should update the offset values * Checks whether we should update the offset values
* - `mode` triggers update * - `mode` triggers update
+32 -1
View File
@@ -1,4 +1,4 @@
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types'; import { Day, MaybeNumber, Playback, TimeOfDay, TimerPhase, TimerState } from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils'; import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import type { RuntimeState } from '../stores/runtimeState.js'; import type { RuntimeState } from '../stores/runtimeState.js';
@@ -111,6 +111,37 @@ export function getElapsed(state: RuntimeState): MaybeNumber {
return Math.max(0, activeElapsed); return Math.max(0, activeElapsed);
} }
/**
* Derives a timer for the active group from wall-clock time.
* Event timer controls such as pause and add time intentionally do not affect it.
*/
export function getGroupTimer(state: RuntimeState): TimerState | null {
if (state.groupNow === null) {
return null;
}
const { actualGroupStart } = state.rundown;
const elapsed = actualGroupStart === null ? null : getTimeSinceStart(state.clock, actualGroupStart);
const current = elapsed === null ? state.groupNow.duration : state.groupNow.duration - elapsed;
let phase = state.timer.phase;
if (actualGroupStart !== null) {
phase = current < 0 ? TimerPhase.Overtime : TimerPhase.Default;
}
return {
addedTime: 0,
current,
duration: state.groupNow.duration,
elapsed,
expectedFinish: null,
phase,
playback: actualGroupStart === null ? state.timer.playback : Playback.Play,
secondaryTimer: null,
startedAt: actualGroupStart,
};
}
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number { function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
if (clock < startedAt) { if (clock < startedAt) {
return clock + dayInMs - startedAt; return clock + dayInMs - startedAt;
+6 -1
View File
@@ -13,6 +13,7 @@ import {
Playback, Playback,
Rundown, Rundown,
RundownState, RundownState,
RuntimeStore,
TimeOfDay, TimeOfDay,
TimerPhase, TimerPhase,
TimerState, TimerState,
@@ -39,6 +40,7 @@ import {
getCurrent, getCurrent,
getElapsed, getElapsed,
getExpectedFinish, getExpectedFinish,
getGroupTimer,
getRuntimeOffset, getRuntimeOffset,
getTimerPhase, getTimerPhase,
hasCrossedMidnight, hasCrossedMidnight,
@@ -104,7 +106,9 @@ const runtimeState: RuntimeState = {
_startDayOffset: null, _startDayOffset: null,
}; };
export function getState(): Readonly<RuntimeState> { export type RuntimeStateSnapshot = RuntimeState & Pick<RuntimeStore, 'groupTimer'>;
export function getState(): Readonly<RuntimeStateSnapshot> {
// create a shallow copy of the state // create a shallow copy of the state
return { return {
...runtimeState, ...runtimeState,
@@ -115,6 +119,7 @@ export function getState(): Readonly<RuntimeState> {
offset: { ...runtimeState.offset }, offset: { ...runtimeState.offset },
rundown: { ...runtimeState.rundown }, rundown: { ...runtimeState.rundown },
timer: { ...runtimeState.timer }, timer: { ...runtimeState.timer },
groupTimer: getGroupTimer(runtimeState),
_timer: { ...runtimeState._timer }, _timer: { ...runtimeState._timer },
_rundown: { ...runtimeState._rundown }, _rundown: { ...runtimeState._rundown },
}; };
@@ -44,6 +44,8 @@ export type OntimeGroup = OntimeBaseEvent & {
note: string; note: string;
entries: EntryId[]; entries: EntryId[];
targetDuration: MaybeNumber; targetDuration: MaybeNumber;
useGroupTimer: boolean;
timerType: TimerType;
colour: string; colour: string;
custom: EntryCustomFields; custom: EntryCustomFields;
// !==== RUNTIME METADATA ====! // // !==== RUNTIME METADATA ====! //
@@ -17,6 +17,7 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
secondaryTimer: null, // change on every update secondaryTimer: null, // change on every update
startedAt: null, // change can only be initiated by user startedAt: null, // change can only be initiated by user
}, },
groupTimer: null,
message: { message: {
timer: { timer: {
text: '', text: '',
@@ -9,6 +9,7 @@ export type RuntimeStore = {
// timer data // timer data
clock: number; clock: number;
timer: TimerState; timer: TimerState;
groupTimer: TimerState | null;
// messages service // messages service
message: MessageState; message: MessageState;
+8 -1
View File
@@ -34,7 +34,14 @@ export {
group as groupDef, group as groupDef,
milestone as milestoneDef, milestone as milestoneDef,
} from './src/rundown-utils/entryDefinitions.js'; } from './src/rundown-utils/entryDefinitions.js';
export { createDelay, createEvent, createGroup, createMilestone, makeString } from './src/rundown-utils/entryUtils.js'; export {
createDelay,
createEvent,
createGroup,
createMilestone,
makeString,
validateGroupTimerType,
} from './src/rundown-utils/entryUtils.js';
// time format utils // time format utils
export { export {
@@ -52,6 +52,8 @@ export const group: Omit<OntimeGroup, 'id'> = {
note: '', note: '',
entries: [], entries: [],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '', colour: '',
custom: {}, custom: {},
// !==== RUNTIME METADATA ====! // // !==== RUNTIME METADATA ====! //
+13 -1
View File
@@ -1,5 +1,5 @@
import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types'; import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
import { SupportedEntry, TimeStrategy } from 'ontime-types'; import { SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
import { generateId } from '../generate-id/generateId.js'; import { generateId } from '../generate-id/generateId.js';
import { validateEndAction, validateTimerType } from '../validate-events/validateEvent.js'; import { validateEndAction, validateTimerType } from '../validate-events/validateEvent.js';
@@ -67,6 +67,8 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
note: patch.note ?? '', note: patch.note ?? '',
entries: patch.entries ?? [], entries: patch.entries ?? [],
targetDuration: patch.targetDuration ?? null, targetDuration: patch.targetDuration ?? null,
useGroupTimer: patch.useGroupTimer === true,
timerType: validateGroupTimerType(patch.timerType),
colour: makeString(patch.colour, ''), colour: makeString(patch.colour, ''),
custom: patch.custom ?? {}, custom: patch.custom ?? {},
revision: 0, revision: 0,
@@ -77,6 +79,16 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
}; };
} }
export function validateGroupTimerType(
value: unknown,
fallback: unknown = TimerType.CountDown,
): TimerType.CountDown | TimerType.CountUp {
if (value === TimerType.CountDown || value === TimerType.CountUp) {
return value;
}
return fallback === TimerType.CountUp ? TimerType.CountUp : TimerType.CountDown;
}
/** /**
* Creates a new milestone from an optional patch * Creates a new milestone from an optional patch
*/ */