Compare commits

..

1 Commits

Author SHA1 Message Date
Carlos Valente 9d2c86d6be feat: count to group completion 2026-09-05 08:36:57 +02:00
44 changed files with 735 additions and 265 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,26 +0,0 @@
import { getRememberedDimensions, rememberDimensions } from '../imageDimensions';
/** stand-in for a loaded HTMLImageElement */
function makeImage(naturalWidth: number, naturalHeight: number) {
return { naturalWidth, naturalHeight } as HTMLImageElement;
}
test('We remember the size of an image, so that we can reserve its space when it comes back', () => {
expect(getRememberedDimensions('http://ontime.local/unseen.png')).toBe(null);
rememberDimensions('http://ontime.local/image.png', makeImage(1920, 1080));
expect(getRememberedDimensions('http://ontime.local/image.png')).toMatchObject({ width: 1920, height: 1080 });
// an image which failed to load has no size to offer
rememberDimensions('http://ontime.local/broken.png', makeImage(0, 0));
expect(getRememberedDimensions('http://ontime.local/broken.png')).toBe(null);
});
test('We keep the most recently seen images, older entries are forgotten', () => {
for (let i = 0; i < 600; i++) {
rememberDimensions(`http://ontime.local/${i}.png`, makeImage(100, 50));
}
expect(getRememberedDimensions('http://ontime.local/0.png')).toBe(null);
expect(getRememberedDimensions('http://ontime.local/599.png')).toMatchObject({ width: 100, height: 50 });
});
@@ -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 });
});
});
@@ -1,49 +0,0 @@
/**
* Images in the cuesheet live inside a virtualised table:
* rows are unmounted when they leave the viewport and mounted again when they come back.
* A re-mounted image has no dimensions until it is available,
* which makes the row change height and the table shift under the user.
*
* We remember the size of the images we have already seen
* so that we can reserve the space they will take.
* This only holds two numbers per image: we leave the image data itself to the browser cache,
* which knows better than us when memory should be released.
*/
export interface ImageDimensions {
width: number;
height: number;
}
/** how many sizes we remember, this is only a few bytes per entry */
const maxSize = 500;
const dimensions = new Map<string, ImageDimensions>();
/**
* @returns the size of a previously loaded image, if we have seen it before
*/
export function getRememberedDimensions(src: string): ImageDimensions | null {
return dimensions.get(src) ?? null;
}
/**
* Records the size of a loaded image
*/
export function rememberDimensions(src: string, image: HTMLImageElement) {
if (image.naturalHeight === 0) {
return;
}
// the map iteration order is our LRU queue, re-adding the entry marks it as recently used
dimensions.delete(src);
dimensions.set(src, { width: image.naturalWidth, height: image.naturalHeight });
while (dimensions.size > maxSize) {
const oldest = dimensions.keys().next();
if (oldest.done) {
return;
}
dimensions.delete(oldest.value);
}
}
@@ -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>
@@ -1,18 +0,0 @@
import { isValidImageSource } from '../cuesheet-table/cuesheet-table-elements/EditableImage';
test('An image is referenced by link, anything else is rejected', () => {
const testCases = [
{ value: 'https://example.com/image.png', isValid: true },
{ value: 'http://example.com/image.png', isValid: true },
// a file is local to the machine running ontime, it would not resolve for the clients we serve
{ value: '/user/image.png', isValid: false },
{ value: 'file:///Users/me/image.png', isValid: false },
{ value: 'C:\\images\\image.png', isValid: false },
// values which do not describe a location we can reach
{ value: 'www.example.com/image.png', isValid: false },
{ value: 'https://', isValid: false },
{ value: 'some text', isValid: false },
];
testCases.forEach((t) => expect(isValidImageSource(t.value)).toBe(t.isValid));
});
@@ -5,19 +5,6 @@
&:not(:read-only):hover::placeholder {
opacity: 1;
}
&[data-invalid] {
outline: 1px solid $red-500;
}
}
/** feedback on a value we cannot use, either rejected or failed to load */
.message {
display: block;
padding: 0.25rem 0;
color: $red-500;
font-size: calc(1rem - 3px);
line-height: 1.2;
}
.imageCell {
@@ -1,54 +1,27 @@
import { memo, useState } from 'react';
import { memo } from 'react';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import { getRememberedDimensions, rememberDimensions } from '../../../../common/utils/imageDimensions';
import style from './EditableImage.module.scss';
interface EditableImageProps {
initialValue: string;
fieldLabel: string;
readOnly?: boolean;
updateValue: (newValue: string) => void;
}
export default memo(EditableImage);
/**
* Images are referenced by link: anything local to the machine running ontime
* would not resolve for the clients we serve the cuesheet to
*/
export function isValidImageSource(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
function EditableImage({ initialValue, fieldLabel, readOnly, updateValue }: EditableImageProps) {
const [isRejected, setIsRejected] = useState(false);
/** we keep track of the source itself, so that the state follows the value being shown */
const [failedSource, setFailedSource] = useState<string | null>(null);
const [loadedSource, setLoadedSource] = useState<string | null>(null);
function EditableImage({ initialValue, readOnly, updateValue }: EditableImageProps) {
const handleUpdate = (newValue: string) => {
const value = newValue.trim();
if (value === initialValue) {
setIsRejected(false);
if (newValue === initialValue) {
return;
}
if (value !== '' && !isValidImageSource(value)) {
setIsRejected(true);
if (newValue !== '' && !newValue.startsWith('http')) {
return;
}
setIsRejected(false);
updateValue(value);
updateValue(newValue);
};
const openInNewTab = () => {
@@ -63,42 +36,22 @@ function EditableImage({ initialValue, fieldLabel, readOnly, updateValue }: Edit
if (!initialValue) {
return (
<>
<Input
variant='ghosted'
className={style.imageInput}
fluid
placeholder='Paste image URL'
data-invalid={isRejected || undefined}
onChange={() => setIsRejected(false)}
onBlur={(event) => handleUpdate(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleUpdate(event.currentTarget.value);
}
}}
/>
{isRejected && <span className={style.message}>Images are referenced by link (https://...)</span>}
</>
<Input
variant='ghosted'
className={style.imageInput}
fluid
placeholder='Paste image URL'
onBlur={(event) => handleUpdate(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleUpdate(event.currentTarget.value);
}
}}
defaultValue={initialValue}
/>
);
}
/**
* The cuesheet is virtualised: rows are unmounted once they leave the viewport.
* When the row comes back, we reserve the space the image took
* so that the table does not shift while the browser makes it available.
* The reservation is given in CSS so that it follows the column being resized,
* the same way the image itself does once it is shown.
*/
const knownDimensions = getRememberedDimensions(initialValue);
const isLoaded = loadedSource === initialValue;
const reservedSpace = knownDimensions
? {
aspectRatio: knownDimensions.width / knownDimensions.height,
width: `min(100%, ${knownDimensions.width}px)`,
}
: undefined;
return (
<div className={style.imageCell}>
{!readOnly && (
@@ -109,22 +62,7 @@ function EditableImage({ initialValue, fieldLabel, readOnly, updateValue }: Edit
</Button>
</div>
)}
{failedSource === initialValue ? (
<span className={style.message}>Could not load image</span>
) : (
<img
src={initialValue}
alt={fieldLabel}
className={style.image}
onLoad={(event) => {
rememberDimensions(initialValue, event.currentTarget);
setLoadedSource(initialValue);
}}
onError={() => setFailedSource(initialValue)}
/** until the image is available, we reserve the space it took the last time we saw it */
style={isLoaded ? undefined : reservedSpace}
/>
)}
{Boolean(initialValue) && <img loading='lazy' src={initialValue} className={style.image} />}
</div>
);
}
@@ -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;
}
@@ -175,14 +187,7 @@ function LazyImage({ row, column, table }: CuesheetCellContext) {
const canWrite = column.columnDef.meta?.canWrite;
const initialValue = event.custom[column.id];
return (
<EditableImage
initialValue={initialValue}
fieldLabel={getColumnLabel(column)}
updateValue={update}
readOnly={!canWrite}
/>
);
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
}
function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
@@ -200,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?
*/
@@ -417,7 +417,7 @@ export function migrateRundown(
timeEnd: null,
duration: 0,
isFirstLinked: false,
});
} as unknown as OntimeEntry);
} else if (entry.type === 'delay') {
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 { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js';
@@ -276,7 +276,13 @@ describe('parseRundown()', () => {
expect(parsedRundown.order).toStrictEqual(['group']);
expect(parsedRundown.flatOrder).toStrictEqual(['group', '1', '2']);
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 },
'2': { id: '2', type: SupportedEntry.Milestone },
});
@@ -25,6 +25,7 @@ import { parseRundown } from '../rundown.parser.js';
import {
calculateDayOffset,
cloneEntryData,
createGroupPatch,
deleteById,
doesInvalidateMetadata,
getIntegerAndFraction,
@@ -36,6 +37,25 @@ import {
} from '../rundown.utils.js';
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', () => {
const event = {
title: 'test',
@@ -33,6 +33,7 @@ import {
makeString,
maxDuration,
validateEndAction,
validateGroupTimerType,
validateTimerType,
validateTimes,
} from 'ontime-utils';
@@ -180,6 +181,9 @@ export function createGroupPatch(originalGroup: OntimeGroup, patchGroup: Partial
note: makeString(patchGroup.note, originalGroup.note),
entries: patchGroup.entries ?? originalGroup.entries,
targetDuration: maybeTargetDuration(),
useGroupTimer:
typeof patchGroup.useGroupTimer === 'boolean' ? patchGroup.useGroupTimer : originalGroup.useGroupTimer,
timerType: validateGroupTimerType(patchGroup.timerType, originalGroup.timerType),
colour: makeString(patchGroup.colour, originalGroup.colour),
revision: originalGroup.revision,
timeStart: originalGroup.timeStart,
+1
View File
@@ -206,6 +206,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
eventStore.init({
clock: state.clock,
timer: state.timer,
groupTimer: state.groupTimer,
message: { ...runtimeStorePlaceholder.message },
offset: state.offset,
rundown: state.rundown,
+22
View File
@@ -39,6 +39,8 @@ export const stageRundown: Rundown = {
note: '',
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -172,6 +174,8 @@ export const stageRundown: Rundown = {
note: '',
entries: ['0aaa7d'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -219,6 +223,8 @@ export const stageRundown: Rundown = {
note: '',
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -360,6 +366,8 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#A790F5',
custom: {},
revision: 0,
@@ -476,6 +484,8 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -588,6 +598,8 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0301'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -634,6 +646,8 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -768,6 +782,8 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0101', 'br0102', 'br0103'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#ED3333',
custom: {},
revision: 0,
@@ -851,6 +867,8 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0201', 'br0202', 'br0203', 'br0204'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -959,6 +977,8 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0301', 'br0302'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -1029,6 +1049,8 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0401', 'br0402', 'br0403'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
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 {
findDayOffset,
getGroupTimer,
getCurrent,
getElapsed,
getExpectedFinish,
@@ -16,6 +17,91 @@ import {
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()', () => {
it('returns active elapsed time from startedAt without add-time adjustments', () => {
const state = {
@@ -7,6 +7,7 @@ import {
findPreviousPlayableId,
getEventAtIndex,
getShouldClockUpdate,
getShouldGroupTimerUpdate,
getShouldOffsetUpdate,
getShouldTimerUpdate,
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()', () => {
const baseOffset: Offset = {
absolute: 0,
@@ -25,7 +25,7 @@ import { logger } from '../../classes/Logger.js';
import { timerConfig } from '../../setup/config.js';
import { eventStore } from '../../stores/EventStore.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 { restoreService } from '../restore-service/restore.service.js';
import type { RestorePoint } from '../restore-service/restore.type.js';
@@ -36,6 +36,7 @@ import {
findPreviousPlayableId,
getEventAtIndex,
getShouldClockUpdate,
getShouldGroupTimerUpdate,
getShouldOffsetUpdate,
getShouldTimerUpdate,
isNewSecond,
@@ -51,11 +52,11 @@ class RuntimeService {
private lastIntegrationTimerValue = -1;
/** last known state */
static previousState: RuntimeState;
static previousState: RuntimeStateSnapshot;
constructor(eventTimer: EventTimer) {
this.eventTimer = eventTimer;
RuntimeService.previousState = {} as RuntimeState;
RuntimeService.previousState = {} as RuntimeStateSnapshot;
}
@broadcastResult
@@ -710,6 +711,12 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
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.
* 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
* - `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 type { RuntimeState } from '../stores/runtimeState.js';
@@ -111,6 +111,37 @@ export function getElapsed(state: RuntimeState): MaybeNumber {
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 {
if (clock < startedAt) {
return clock + dayInMs - startedAt;
+6 -1
View File
@@ -13,6 +13,7 @@ import {
Playback,
Rundown,
RundownState,
RuntimeStore,
TimeOfDay,
TimerPhase,
TimerState,
@@ -39,6 +40,7 @@ import {
getCurrent,
getElapsed,
getExpectedFinish,
getGroupTimer,
getRuntimeOffset,
getTimerPhase,
hasCrossedMidnight,
@@ -104,7 +106,9 @@ const runtimeState: RuntimeState = {
_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
return {
...runtimeState,
@@ -115,6 +119,7 @@ export function getState(): Readonly<RuntimeState> {
offset: { ...runtimeState.offset },
rundown: { ...runtimeState.rundown },
timer: { ...runtimeState.timer },
groupTimer: getGroupTimer(runtimeState),
_timer: { ...runtimeState._timer },
_rundown: { ...runtimeState._rundown },
};
@@ -44,6 +44,8 @@ export type OntimeGroup = OntimeBaseEvent & {
note: string;
entries: EntryId[];
targetDuration: MaybeNumber;
useGroupTimer: boolean;
timerType: TimerType;
colour: string;
custom: EntryCustomFields;
// !==== RUNTIME METADATA ====! //
@@ -17,6 +17,7 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
secondaryTimer: null, // change on every update
startedAt: null, // change can only be initiated by user
},
groupTimer: null,
message: {
timer: {
text: '',
@@ -9,6 +9,7 @@ export type RuntimeStore = {
// timer data
clock: number;
timer: TimerState;
groupTimer: TimerState | null;
// messages service
message: MessageState;
+8 -1
View File
@@ -34,7 +34,14 @@ export {
group as groupDef,
milestone as milestoneDef,
} 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
export {
@@ -52,6 +52,8 @@ export const group: Omit<OntimeGroup, 'id'> = {
note: '',
entries: [],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '',
custom: {},
// !==== RUNTIME METADATA ====! //
+13 -1
View File
@@ -1,5 +1,5 @@
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 { validateEndAction, validateTimerType } from '../validate-events/validateEvent.js';
@@ -67,6 +67,8 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
note: patch.note ?? '',
entries: patch.entries ?? [],
targetDuration: patch.targetDuration ?? null,
useGroupTimer: patch.useGroupTimer === true,
timerType: validateGroupTimerType(patch.timerType),
colour: makeString(patch.colour, ''),
custom: patch.custom ?? {},
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
*/