feat: count to group completion

This commit is contained in:
Carlos Valente
2026-09-05 08:36:57 +02:00
parent 9ec12f4927
commit 9d2c86d6be
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 { sendSocket } from '../utils/socket';
import { resolveTimerDisplay } from './useSocket.utils';
const createSelector =
<T>(selector: (state: RuntimeStore) => T) =>
@@ -38,15 +39,19 @@ export const useExternalMessageInput = createSelector((state: RuntimeStore) => (
visible: state.message.timer.secondarySource === 'secondary',
}));
export const useMessagePreview = createSelector((state: RuntimeStore) => ({
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
phase: state.timer.phase,
secondarySource: state.message.timer.secondarySource,
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null,
countToEnd: state.eventNow?.countToEnd ?? false,
}));
export const useMessagePreview = createSelector((state: RuntimeStore) => {
const timerDisplay = resolveTimerDisplay(state);
return {
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
phase: timerDisplay.time.phase,
secondarySource: state.message.timer.secondarySource,
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: timerDisplay.timerType,
countToEnd: timerDisplay.countToEnd,
usesGroupTimer: timerDisplay.usesGroupTimer,
};
});
export const setMessage = {
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }),
@@ -230,20 +235,26 @@ export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
/* ======================= View specific subscriptions ======================= */
export const useTimerSocket = createSelector((state: RuntimeStore) => ({
eventNext: state.eventNext,
eventNow: state.eventNow,
message: state.message,
time: state.timer,
clock: state.clock,
timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown,
countToEndNow: state.eventNow?.countToEnd ?? false,
auxTimer: {
aux1: state.auxtimer1.current,
aux2: state.auxtimer2.current,
aux3: state.auxtimer3.current,
},
}));
export const useTimerSocket = createSelector((state: RuntimeStore) => {
const timerDisplay = resolveTimerDisplay(state);
return {
eventNext: state.eventNext,
eventNow: state.eventNow,
message: state.message,
time: timerDisplay.time,
eventTimer: timerDisplay.eventTimer,
clock: state.clock,
timerTypeNow: timerDisplay.timerType,
eventTimerType: timerDisplay.eventTimerType,
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) => ({
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()', () => {
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,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
PlayableEvent,
Rundown,
TimerType,
isOntimeEvent,
isOntimeGroup,
isPlayableEvent,
@@ -29,7 +31,11 @@ export type RundownMetadata = {
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';
@@ -65,10 +71,23 @@ export function getFlatRundownMetadata(
): ExtendedEntry[] {
const { process } = initRundownMetadata(selectedEventId);
const flatRundown: ExtendedEntry[] = [];
let activeGroup: OntimeGroup | null = null;
for (const id of data.flatOrder) {
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);
}
@@ -27,6 +27,13 @@
border-top: 1px solid $white-7;
}
.timerSource {
color: $active-indicator;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.blackout {
display: none;
}
@@ -1,5 +1,5 @@
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 { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
@@ -20,7 +20,8 @@ const secondarySourceLabels: Record<string, string> = {
};
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 main = (() => {
@@ -35,7 +36,9 @@ export default function TimerPreview() {
const secondary = (() => {
// 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
return secondarySourceLabels[secondarySource];
@@ -55,6 +58,7 @@ export default function TimerPreview() {
<div className={style.preview}>
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
<div className={contentClasses}>
{usesGroupTimer && <div className={style.timerSource}>Group timer</div>}
<div
className={style.mainContent}
data-phase={showColourOverride && phase}
@@ -65,6 +69,14 @@ export default function TimerPreview() {
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
</div>
<div className={style.eventStatus}>
<Tooltip
text='Timer display controlled by group'
render={<span />}
className={style.statusIcon}
data-active={usesGroupTimer}
>
<IoTimerOutline />
</Tooltip>
<Tooltip
text='Time type: Count down'
render={<span />}
@@ -32,6 +32,12 @@
gap: 1rem;
}
.timerDisplaySettings {
display: flex;
flex-direction: column;
gap: 1rem;
}
.column {
display: flex;
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 { useCallback } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
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 useCustomFields from '../../../common/hooks-query/useCustomFields';
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} />
</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}>
<Editor.Title>
Custom Fields
@@ -335,6 +335,7 @@ export default function RundownEvent({
eventIndex={eventIndex}
endAction={endAction}
timerType={timerType}
groupTimerType={parentGroup?.useGroupTimer ? parentGroup.timerType : undefined}
title={title}
note={note}
delay={delay}
@@ -9,6 +9,7 @@ import {
IoPlayForward,
IoPlaySkipForward,
IoTime,
IoTimerOutline,
} from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
@@ -35,6 +36,7 @@ interface RundownEventInnerProps {
eventIndex: number;
endAction: EndAction;
timerType: TimerType;
groupTimerType?: TimerType;
title: string;
note: string;
delay: number;
@@ -62,6 +64,7 @@ function RundownEventInner({
countToEnd,
endAction,
timerType,
groupTimerType,
title,
note,
delay,
@@ -150,6 +153,14 @@ function RundownEventInner({
{loaded && <EventBlockProgressBar />}
</div>
<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 />}>
<TimerIcon type={timerType} className={style.statusIcon} />
</Tooltip>
@@ -55,6 +55,14 @@
gap: 0.5rem;
}
.timerIndicator {
display: grid;
flex: 0 0 1.5rem;
place-items: center;
color: $active-indicator;
font-size: 1rem;
}
.metaRow {
display: flex;
gap: $block-clearance; // same as RundownEvent.eventTimers
@@ -1,6 +1,6 @@
import { useSortable } from '@dnd-kit/sortable';
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 { MouseEvent, useCallback, useRef } from 'react';
import {
@@ -9,6 +9,7 @@ import {
IoDuplicateOutline,
IoFolderOpenOutline,
IoReorderTwo,
IoTimerOutline,
IoTrash,
IoLockClosed,
} from 'react-icons/io5';
@@ -174,6 +175,14 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
<div className={style.header}>
<div className={style.titleRow}>
<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)}>
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
</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 { useCallback } from 'react';
import { IoTimerOutline } from 'react-icons/io5';
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { formatDuration, formatTime } from '../../../../common/utils/time';
import { AppMode } from '../../../../ontimeConfig';
@@ -17,6 +27,8 @@ import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput';
import style from './cuesheetColsFactory.module.scss';
function getColumnLabel(column: CuesheetCellContext['column']): string {
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;
if (!canWrite) {
return <GhostedText>{initialValue}</GhostedText>;
}
return (
const content = canWrite ? (
<SingleLineCell
initialValue={initialValue as string}
fieldId={column.id}
fieldLabel={getColumnLabel(column)}
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%;
overflow: hidden;
.timer-source {
color: $viewer-label-color;
font-size: 0.75rem;
font-weight: 600;
text-align: center;
text-transform: uppercase;
}
.timer {
opacity: 1;
font-family: $viewer-font-family;
@@ -7,6 +7,7 @@ import { cx } from '../../../common/utils/styleUtils';
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
import {
getEstimatedFontSize,
getEventTimerSecondary,
getIsPlaying,
getSecondaryDisplay,
getShowMessage,
@@ -23,7 +24,18 @@ interface 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
const showOverlay = getShowMessage(message.timer);
@@ -59,7 +71,9 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
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
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
@@ -77,6 +91,7 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
</div>
<div className='timer-container'>
{usesGroupTimer && <div className='timer-source'>Group timer</div>}
<div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }}
@@ -96,11 +111,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current}
complete={totalTime}
eventId={eventNow?.id}
eventId={usesGroupTimer ? undefined : eventNow?.id}
normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning}
warning={usesGroupTimer ? undefined : eventNow?.timeWarning}
warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger}
danger={usesGroupTimer ? undefined : eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished}
/>
+8
View File
@@ -89,6 +89,14 @@
width: 100%;
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 {
text-align: center;
font-size: 11.5vw;
+31 -13
View File
@@ -22,6 +22,7 @@ import { getTimerOptions, useTimerOptions } from './timer.options';
import {
getCardData,
getEstimatedFontSize,
getEventTimerSecondary,
getIsPlaying,
getSecondaryDisplay,
getShowClock,
@@ -52,7 +53,19 @@ export default function TimerLoader() {
}
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 {
hideClock,
hideCards,
@@ -78,7 +91,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes');
const showSoundPrompt = useTimerSound(time.phase, endSound);
const showSoundPrompt = useTimerSound(eventTimer.phase, endSound);
// gather modifiers
const viewTimerType = timerType ?? timerTypeNow;
@@ -128,14 +141,18 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
return null;
})();
const secondaryContent = getSecondaryDisplay(
message,
currentAux,
localisedMinutes,
hideTimerSeconds,
removeLeadingZeros,
hideSecondary,
);
const secondaryContent =
usesGroupTimer && !hideSecondary
? getEventTimerSecondary(
eventTimer,
eventTimerType,
clock,
localisedMinutes,
hideTimerSeconds,
removeLeadingZeros,
timeformat,
)
: getSecondaryDisplay(message, currentAux, localisedMinutes, hideTimerSeconds, removeLeadingZeros, hideSecondary);
// gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
@@ -176,6 +193,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
{showClock && <TimerAutoTickingClock clockFormat={timeformat} />}
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
{usesGroupTimer && <div className='timer-source'>Group timer</div>}
{showEndMessage ? (
<FitText mode='multi' min={64} max={256} className='end-message'>
{freezeMessage}
@@ -202,11 +220,11 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current}
complete={totalTime}
eventId={eventNow?.id}
eventId={usesGroupTimer ? undefined : eventNow?.id}
normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning}
warning={usesGroupTimer ? undefined : eventNow?.timeWarning}
warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger}
danger={usesGroupTimer ? undefined : eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor}
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()', () => {
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
+21 -1
View File
@@ -6,11 +6,12 @@ import {
RundownEntries,
TimerMessage,
TimerPhase,
TimerState,
TimerType,
} from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils';
import { getFormattedTimer, getPropertyValue } from '../common/viewUtils';
import { getFormattedTimer, getPropertyValue, getTimerByType } from '../common/viewUtils';
/**
* Whether a message should be shown
@@ -144,6 +145,25 @@ export function getSecondaryDisplay(
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?
*/