Compare commits

..

1 Commits

Author SHA1 Message Date
Carlos Valente d9dd147d4b refactor(server): replace defensive copies with cow patterns 2026-08-26 20:17:34 +02:00
27 changed files with 498 additions and 352 deletions
+12 -17
View File
@@ -22,14 +22,10 @@ export const useRundownEditor = createSelector((state: RuntimeStore) => ({
nextEventId: state.eventNext?.id ?? null,
}));
export const useScreenControl = createSelector((state: RuntimeStore) => ({
export const useTimerViewControl = createSelector((state: RuntimeStore) => ({
blackout: state.message.timer.blackout,
blink: state.message.timer.blink,
isScreenModified:
state.message.timer.visible ||
state.message.timer.blink ||
state.message.timer.blackout ||
state.message.timer.secondarySource !== null,
secondarySource: state.message.timer.secondarySource,
}));
export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
@@ -37,12 +33,17 @@ export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
visible: state.message.timer.visible,
}));
export const useSecondaryMessageInput = createSelector((state: RuntimeStore) => ({
export const useExternalMessageInput = createSelector((state: RuntimeStore) => ({
text: state.message.secondary,
source: state.message.timer.secondarySource,
visible: state.message.timer.secondarySource === 'secondary',
}));
export const useTimerStatus = createSelector((state: RuntimeStore) => ({
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,
}));
@@ -51,16 +52,10 @@ export const setMessage = {
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }),
timerVisible: (payload: boolean) => sendSocket('message', { timer: { visible: payload } }),
secondaryMessage: (payload: string) => sendSocket('message', { secondary: payload }),
// blink and blackout are mutually exclusive stage states, so turning one on turns the other off
timerBlink: (payload: boolean) =>
sendSocket('message', payload ? { timer: { blink: true, blackout: false } } : { timer: { blink: false } }),
timerBlackout: (payload: boolean) =>
sendSocket('message', payload ? { timer: { blackout: true, blink: false } } : { timer: { blackout: false } }),
timerBlink: (payload: boolean) => sendSocket('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => sendSocket('message', { timer: { blackout: payload } }),
timerSecondarySource: (payload: TimerMessage['secondarySource']) =>
sendSocket('message', { timer: { secondarySource: payload } }),
/** returns the stage to a plain timer, keeping whatever the operator has typed */
clearScreen: () =>
sendSocket('message', { timer: { visible: false, blink: false, blackout: false, secondarySource: null } }),
};
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
@@ -2,12 +2,14 @@
display: grid;
grid-template-columns: 1fr auto;
gap: $element-spacing;
margin-top: $element-inner-spacing;
}
&.withSource {
grid-template-columns: auto 1fr auto;
.label {
font-size: $inner-section-text-size;
color: $label-gray;
&.active {
color: $action-text-color;
}
}
.label.active {
color: $action-text-color;
}
@@ -1,6 +1,5 @@
import { PropsWithChildren, ReactNode, useEffect, useRef, useState } from 'react';
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Input from '../../../common/components/input/input/Input';
import { cx } from '../../../common/utils/styleUtils';
@@ -10,15 +9,12 @@ interface InputRowProps {
label: string;
placeholder: string;
text: string;
/** whether this text is currently on the audience screen */
visible: boolean;
changeHandler: (newValue: string) => void;
/** control which picks where the text is shown, rendered before the input */
sourcePicker?: ReactNode;
}
export default function InputRow(props: PropsWithChildren<InputRowProps>) {
const { label, placeholder, text, visible, changeHandler, sourcePicker, children } = props;
const { label, placeholder, text, visible, changeHandler, children } = props;
const [value, setValue] = useState(text);
const inputRef = useRef<HTMLInputElement>(null);
@@ -47,11 +43,10 @@ export default function InputRow(props: PropsWithChildren<InputRowProps>) {
return (
<div>
<Editor.Label className={cx([style.label, visible && style.active])} htmlFor={label}>
<label className={cx([style.label, visible ?? style.active])} htmlFor={label}>
{label}
</Editor.Label>
<div className={cx([style.inputItems, sourcePicker && style.withSource])}>
{sourcePicker}
</label>
<div className={style.inputItems}>
<Input id={label} ref={inputRef} value={value} onChange={handleInputChange} placeholder={placeholder} />
{children}
</div>
@@ -1,17 +0,0 @@
/* the screen state buttons belong with the preview they act on, not with the message inputs.
buttons sit in a column to the right of the stage by default; once the container narrows
too far for that (e.g. an extracted window resized narrow), they drop into a row below it.
the group is sized by its content, not stretched - the preview keeps a fixed shape rather
than growing to whatever height the panel happens to have free */
.screenGroup {
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: $element-spacing;
}
@container (max-width: 22rem) {
.screenGroup {
grid-template-columns: 1fr;
}
}
@@ -1,22 +1,18 @@
import { SecondarySource } from 'ontime-types';
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Select from '../../../common/components/select/Select';
import { setMessage, useSecondaryMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
import {
setMessage,
useExternalMessageInput as useSecondaryMessageInput,
useTimerMessageInput,
} from '../../../common/hooks/useSocket';
import InputRow from './InputRow';
import ScreenControl from './ScreenControl';
import TimerPreview from './TimerPreview';
import style from './MessageControl.module.scss';
import TimerControlsPreview from './TimerViewControl';
export default function MessageControl() {
return (
<>
<div className={style.screenGroup}>
<TimerPreview />
<ScreenControl />
</div>
<TimerControlsPreview />
<TimerMessageInput />
<SecondaryInput />
</>
@@ -28,7 +24,7 @@ function TimerMessageInput() {
return (
<InputRow
label='Timer message'
label='Timer Message'
placeholder='Message shown fullscreen in stage timer'
text={text}
visible={visible}
@@ -36,7 +32,6 @@ function TimerMessageInput() {
>
<IconButton
aria-label='Toggle timer message visibility'
aria-pressed={visible}
onClick={() => setMessage.timerVisible(!visible)}
variant={visible ? 'primary' : 'subtle'}
>
@@ -46,46 +41,31 @@ function TimerMessageInput() {
);
}
/**
* The secondary line of the stage timer shows one of the aux timers or the secondary message.
* The select owns which one, the eye owns whether the line is shown at all.
*/
function SecondaryInput() {
const { text, source } = useSecondaryMessageInput();
const isShowingSecondaryLine = source !== null;
const selectedSource = source ?? 'aux1';
const { text, visible } = useSecondaryMessageInput();
const toggleSecondary = () => {
if (visible) {
setMessage.timerSecondarySource(null);
} else {
setMessage.timerSecondarySource('secondary');
}
};
return (
<InputRow
label='Secondary'
label='Secondary Message'
placeholder='Message shown as secondary text in stage timer'
text={text}
visible={source === 'secondary'}
visible={visible}
changeHandler={(newValue) => setMessage.secondaryMessage(newValue)}
sourcePicker={
<Select
value={selectedSource}
options={[
{ value: 'aux1', label: 'Aux 1' },
{ value: 'aux2', label: 'Aux 2' },
{ value: 'aux3', label: 'Aux 3' },
{ value: 'secondary', label: 'Message' },
]}
onValueChange={(value: SecondarySource | null) => {
if (value === null) return;
setMessage.timerSecondarySource(value);
}}
/>
}
>
<IconButton
aria-label='Toggle secondary visibility'
aria-pressed={isShowingSecondaryLine}
onClick={() => setMessage.timerSecondarySource(isShowingSecondaryLine ? null : selectedSource)}
variant={isShowingSecondaryLine ? 'primary' : 'subtle'}
data-testid='toggle secondary'
aria-label='Toggle secondary message visibility'
onClick={toggleSecondary}
variant={visible ? 'primary' : 'subtle'}
>
{isShowingSecondaryLine ? <IoEye /> : <IoEyeOffOutline />}
{visible ? <IoEye /> : <IoEyeOffOutline />}
</IconButton>
</InputRow>
);
@@ -1,21 +1,8 @@
.growPanel {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
/* the extracted route has no flex parent to grow into */
.extractedPanel {
height: 100%;
}
.contentLayout {
flex: 1;
min-height: 0;
container-type: inline-size;
display: flex;
flex-direction: column;
gap: $section-spacing;
@@ -5,7 +5,6 @@ import ErrorBoundary from '../../../common/components/error-boundary/ErrorBounda
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../../externals';
import MessageControl from './MessageControl';
@@ -17,10 +16,7 @@ function MessageControlExport() {
return (
<ProtectRoute permission='editor'>
<Editor.Panel
className={cx([style.growPanel, isExtracted && style.extractedPanel])}
data-testid='panel-messages-control'
>
<Editor.Panel className={style.growPanel} data-testid='panel-messages-control'>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
@@ -1,14 +0,0 @@
.screenControl {
display: grid;
grid-auto-flow: row;
gap: $element-spacing;
width: 8rem;
}
@container (max-width: 22rem) {
.screenControl {
grid-auto-flow: column;
grid-auto-columns: 1fr;
width: 100%;
}
}
@@ -1,40 +0,0 @@
import Button from '../../../common/components/buttons/Button';
import { setMessage, useScreenControl } from '../../../common/hooks/useSocket';
import style from './ScreenControl.module.scss';
export default function ScreenControl() {
const { blackout, blink, isScreenModified } = useScreenControl();
return (
<div className={style.screenControl}>
<Button
variant={blink ? 'primary' : 'subtle'}
aria-pressed={blink}
fluid
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink
</Button>
<Button
variant={blackout ? 'destructive' : 'subtle'}
aria-pressed={blackout}
fluid
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout'
>
Blackout
</Button>
<Button
variant='subtle'
fluid
disabled={!isScreenModified}
onClick={() => setMessage.clearScreen()}
data-testid='clear screen'
>
Clear screen
</Button>
</div>
);
}
@@ -1,26 +1,49 @@
/* fixed shape, not stretched to whatever height the panel has free - it fills the
available width and keeps a 16:9 proportion, which is also what the font size
estimate in the embedded timer assumes */
.stage {
width: 100%;
aspect-ratio: 16 / 9;
min-width: 0;
.preview {
background-color: $ui-black;
display: grid;
place-content: center;
text-align: center;
position: relative;
/* a query container so that the embedded timer scales to the preview, not to the viewport */
container-type: size;
contain: paint;
overflow: hidden;
border-radius: $component-border-radius-md;
}
/* editor chrome sits above the blackout and message overlays, so it stays reachable and readable */
.stageChrome {
position: absolute;
inset: 0;
z-index: calc($zindex-floating + 2);
pointer-events: none;
.mainContent {
font-size: 1rem;
font-weight: 600;
width: 100%;
color: var(--override-colour, $ui-white);
> * {
pointer-events: auto;
&[data-phase='pending'] {
color: $ontime-roll;
}
&[data-phase='overtime'] {
color: $playback-negative;
}
&[data-phase='none'] {
opacity: $opacity-disabled;
}
}
.secondaryContent {
border-top: 1px solid $white-7;
}
.blackout {
display: none;
}
.eventStatus {
position: absolute;
left: 0;
margin: 0.5rem 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.statusIcon {
color: $gray-1000;
&[data-active='true'] {
color: $active-indicator;
}
}
@@ -1,21 +1,110 @@
import { TimerPhase, TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { useMessagePreview } from '../../../common/hooks/useSocket';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
import { PipTimer } from '../../../views/editor/pip-timer/PipTimer';
import TimerStatus from './TimerStatus';
import style from './TimerPreview.module.scss';
const secondarySourceLabels: Record<string, string> = {
aux1: 'Aux 1',
aux2: 'Aux 2',
aux3: 'Aux 3',
secondary: 'Secondary message',
};
export default function TimerPreview() {
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
const { data } = useViewSettings();
const main = (() => {
if (showTimerMessage) return 'Message';
if (timerType === TimerType.None) return timerPlaceholder;
if (phase === TimerPhase.Pending) return 'Standby to start';
if (phase === TimerPhase.Overtime) return 'Timer Overtime';
if (timerType === TimerType.Clock) return 'Clock';
if (countToEnd) return 'Count to End';
return 'Timer';
})();
const secondary = (() => {
// message is a fullscreen overlay or secondary is not active
if (showTimerMessage || !secondarySource) return null;
// we need to check aux first since it takes priority
return secondarySourceLabels[secondarySource];
})();
const overrideColour = (() => {
// override fallback colours from starter project
if (phase === TimerPhase.Warning) return data.warningColor ?? '#ffa528';
if (phase === TimerPhase.Danger) return data.dangerColor ?? '#ff7300';
return data.normalColor ?? '#FFFC';
})();
const showColourOverride = main == 'Timer';
const contentClasses = cx([blink && style.blink, blackout && style.blackout]);
return (
<div className={style.stage}>
<PipTimer viewSettings={data} />
<div className={style.stageChrome}>
<TimerStatus />
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
<div className={style.preview}>
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
<div className={contentClasses}>
<div
className={style.mainContent}
data-phase={showColourOverride && phase}
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
>
{main}
</div>
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
</div>
<div className={style.eventStatus}>
<Tooltip
text='Time type: Count down'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountDown}
>
<IoArrowDown />
</Tooltip>
<Tooltip
text='Time type: Count up'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountUp}
>
<IoArrowUp />
</Tooltip>
<Tooltip
text='Time type: Clock'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.Clock}
>
<IoTime />
</Tooltip>
<Tooltip
text='Time type: None'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.None}
>
<IoBan />
</Tooltip>
<Tooltip
text={countToEnd ? 'Count to end' : 'Count duration'}
render={<span />}
className={style.statusIcon}
data-active={countToEnd}
>
<LuArrowDownToLine />
</Tooltip>
</div>
</div>
);
@@ -1,17 +0,0 @@
.timerStatus {
position: absolute;
top: 0;
left: 0;
margin: 0.5rem 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.statusIcon {
color: $gray-1000;
&[data-active='true'] {
color: $active-indicator;
}
}
@@ -1,58 +0,0 @@
import { TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useTimerStatus } from '../../../common/hooks/useSocket';
import style from './TimerStatus.module.scss';
/** Read only summary of how the loaded event drives the stage timer */
export default function TimerStatus() {
const { countToEnd, timerType } = useTimerStatus();
return (
<div className={style.timerStatus}>
<Tooltip
text='Time type: Count down'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountDown}
>
<IoArrowDown />
</Tooltip>
<Tooltip
text='Time type: Count up'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountUp}
>
<IoArrowUp />
</Tooltip>
<Tooltip
text='Time type: Clock'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.Clock}
>
<IoTime />
</Tooltip>
<Tooltip
text='Time type: None'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.None}
>
<IoBan />
</Tooltip>
<Tooltip
text={countToEnd ? 'Count to end' : 'Count duration'}
render={<span />}
className={style.statusIcon}
data-active={countToEnd}
>
<LuArrowDownToLine />
</Tooltip>
</div>
);
}
@@ -0,0 +1,11 @@
.previewContainer {
display: grid;
gap: $element-spacing;
grid-template-columns: 3fr 2fr;
}
.options {
display: flex;
flex-direction: column;
gap: $element-spacing;
}
@@ -0,0 +1,92 @@
import { SecondarySource } from 'ontime-types';
import { useEffect, useState } from 'react';
import Button from '../../../common/components/buttons/Button';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Select from '../../../common/components/select/Select';
import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket';
import TimerPreview from './TimerPreview';
import style from './TimerViewControl.module.scss';
export default function TimerControlsPreview() {
const { blackout, blink } = useTimerViewControl();
return (
<div className={style.previewContainer}>
<TimerPreview />
<div className={style.options}>
<SecondarySourceControl />
<Editor.Separator orientation='horizontal' />
<Button
variant={blink ? 'primary' : 'subtle'}
fluid
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink
</Button>
<Button
variant={blackout ? 'primary' : 'subtle'}
fluid
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout'
>
Blackout screen
</Button>
</div>
</div>
);
}
function SecondarySourceControl() {
const { secondarySource } = useTimerViewControl();
const [value, setValue] = useState<SecondarySource>('aux1');
// sync secondary source with external changes
useEffect(() => {
if (secondarySource !== null) {
setValue(secondarySource);
}
}, [secondarySource]);
const toggleSecondary = () => {
if (secondarySource === value) {
setMessage.timerSecondarySource(null);
} else {
setMessage.timerSecondarySource(value);
}
};
return (
<>
<Select
value={value}
options={[
{ value: 'aux1', label: 'Aux 1' },
{ value: 'aux2', label: 'Aux 2' },
{ value: 'aux3', label: 'Aux 3' },
{ value: 'secondary', label: 'Secondary message' },
]}
onValueChange={(value: SecondarySource | null) => {
if (value === null) return;
// we can only update the remote if it is enabled
if (secondarySource !== null) {
setMessage.timerSecondarySource(value);
}
setValue(value);
}}
/>
<Button
variant={secondarySource !== null ? 'primary' : 'subtle'}
fluid
onClick={toggleSecondary}
data-testid='toggle secondary'
>
Show secondary
</Button>
</>
);
}
@@ -6,9 +6,8 @@
padding: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%;
height: 100%; /* fill the pip window or the editor preview frame */
position: relative; /* anchor for the blackout and message overlays */
width: 100%; /* restrict the page width to viewport */
height: 100vh;
transition: opacity 0.5s ease-in-out;
font-family: $viewer-font-family;
@@ -20,8 +19,8 @@
flex-direction: column;
&--finished {
outline: clamp(4px, 1cqw, 16px) solid $timer-finished-color;
outline-offset: calc(clamp(4px, 1cqw, 16px) * -1);
outline: clamp(4px, 1vw, 16px) solid $timer-finished-color;
outline-offset: calc(clamp(4px, 1vw, 16px) * -1);
transition: $viewer-transition-time;
}
@@ -108,24 +107,10 @@
/* =================== OVERLAY ===================*/
.blackout {
position: absolute;
inset: 0;
z-index: 0;
background-color: #000;
opacity: 0;
transition: opacity $viewer-transition-time;
&--active {
z-index: calc($zindex-floating + 1);
opacity: 1;
}
}
.message-overlay {
position: absolute;
position: fixed;
inset: 0;
padding: 2cqw;
padding: 2vw;
background: $viewer-background-color;
opacity: 0;
transition: opacity $viewer-transition-time;
@@ -41,10 +41,9 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
// gather timer data
const totalTime = getTotalTime(time.duration, time.addedTime);
const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow);
// match the defaults of the timer view, which is what the preview is standing in for
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
removeSeconds: false,
removeLeadingZero: true,
removeLeadingZero: false,
});
const currentAux = (() => {
@@ -64,27 +63,23 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
// gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
// the estimate is tuned for a 16:9 screen, so cap it by height for containers wider than that
const timerFontSize = getEstimatedFontSize(display, secondaryContent);
const timerFontRule = `min(${timerFontSize}cqw, ${((timerFontSize * 16) / 9).toFixed(2)}cqh)`;
const userStyles = {
...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }),
};
return (
<div className={cx(['pip-timer', showFinished && 'pip-timer--finished'])} style={userStyles}>
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
<div className={cx(['message-overlay', showOverlay && 'message-overlay--active'])}>
<FitText mode='multi' min={12} max={256} className={cx(['message', message.timer.blink && 'blink'])}>
{message.timer.text}
</FitText>
</div>
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
<div className='timer-container'>
<div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: timerFontRule }}
style={{ fontSize: `${timerFontSize}vw` }}
data-phase={time.phase}
>
{display}
@@ -1,5 +1,7 @@
import { deepEqual } from 'fast-equals';
import {
EndAction,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
@@ -8,13 +10,21 @@ import {
TimerType,
Trigger,
} from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, createEvent } from 'ontime-utils';
import {
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
createDelay,
createEvent,
createGroup,
createMilestone,
} from 'ontime-utils';
import { assertType } from 'vitest';
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
import { parseRundown } from '../rundown.parser.js';
import {
calculateDayOffset,
cloneEntryData,
deleteById,
doesInvalidateMetadata,
getIntegerAndFraction,
@@ -715,3 +725,68 @@ describe('eventDurationMatchGroupTarget()', () => {
expect(result).toStrictEqual(null);
});
});
describe('cloneEntryData()', () => {
const trigger: Trigger = {
id: 'trigger-1',
title: 'Go on air',
trigger: TimerLifeCycle.onStart,
automationId: 'automation-1',
};
// the real factories, so these are complete entries exactly as the rundown holds them
const entries: [string, OntimeEntry][] = [
['event', createEvent({ custom: { sponsor: 'a value' }, triggers: [trigger] }, 'cue-1') as OntimeEvent],
['group', createGroup({ id: 'group-1', entries: ['a', 'b'], custom: { sponsor: 'a value' } })],
['milestone', createMilestone({ id: 'milestone-1', custom: { sponsor: 'a value' } })],
['delay', createDelay({ id: 'delay-1', duration: 10 })],
];
/**
* Fails if any nested object or array in the clone is the same reference as the source,
* so a field added later that needs a copy of its own is caught here without the clone
* having to enumerate fields.
*/
function expectNoSharedReferences(clone: unknown, source: unknown, path: string) {
if (typeof source !== 'object' || source === null) return;
expect(clone, `${path} is shared with the source`).not.toBe(source);
const cloneRecord = clone as Record<string, unknown>;
const sourceRecord = source as Record<string, unknown>;
for (const key of Object.keys(sourceRecord)) {
expectNoSharedReferences(cloneRecord[key], sourceRecord[key], `${path}.${key}`);
}
}
/** the two halves of the structuredClone contract: same value, no shared references */
it.each(entries)('clones a %s to the same value structuredClone would produce', (_type, entry) => {
expect(cloneEntryData(entry)).toStrictEqual(structuredClone(entry));
});
it.each(entries)('shares no nested object or array with the source %s', (_type, entry) => {
expectNoSharedReferences(cloneEntryData(entry), entry, 'entry');
});
/**
* Regression: normalising an absent container to an empty one makes deepEqual report a
* change on every comparison, which would have the runtime re-broadcast and re-save the
* restore point on every tick. See PR #2178.
*/
it.each([
['event', makeOntimeEvent({ id: 'partial' })],
['group', makeOntimeGroup({ id: 'partial', entries: undefined })],
])('gives a partial %s exactly the keys structuredClone would, so it stays deep-equal', (_type, entry) => {
const clone = cloneEntryData(entry);
// asserting on keys, not values: `toBeUndefined()` cannot tell an absent key from an own
// key holding undefined, and it is key presence that decides the deepEqual below
expect(Object.keys(clone).sort()).toEqual(Object.keys(structuredClone(entry)).sort());
// this is the comparison runtime.service.ts uses to decide whether to re-broadcast an
// entry; if the clone gains a key, every tick looks like a change
expect(deepEqual(clone, entry)).toBe(true);
});
it('throws on an entry type it does not know how to clone', () => {
expect(() => cloneEntryData({ id: 'x', type: 'unknown' } as unknown as OntimeEvent)).toThrow(
'Unsupported entry type for cloning',
);
});
});
+38 -14
View File
@@ -27,6 +27,7 @@ import {
isPlayableEvent,
} from 'ontime-types';
import { addToRundown, createGroup, customFieldLabelToKey, getInsertAfterId, insertAtIndex } from 'ontime-utils';
import type { DeepReadonly } from 'ts-essentials';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { consoleError } from '../../utils/console.js';
@@ -34,6 +35,7 @@ import { ProcessedRundownMetadata, makeRundownMetadata } from './rundown.parser.
import type { RundownMetadata } from './rundown.types.js';
import {
applyPatchToEntry,
cloneRundown,
cloneSimpleRundownEntry,
deleteById,
doesInvalidateMetadata,
@@ -43,9 +45,14 @@ import {
} from './rundown.utils.js';
/**
* The currently loaded rundown in cache
* The currently loaded rundown in cache.
*
* Reassigned - never mutated in place - when a different rundown is loaded: the persistence
* layer stores this object by reference, so repurposing it for another rundown would rewrite
* the previously loaded rundown's stored record. Mutating it in place while it represents the
* same rundown (ie. from commit) is intended, and is what keeps the stored record current.
*/
const cachedRundown: Rundown = {
let cachedRundown: Rundown = {
id: '',
title: '',
order: [],
@@ -79,9 +86,16 @@ export const getRundownMetadata = (): Readonly<RundownMetadata> => rundownMetada
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId];
type Transaction = {
/**
* @param R the type callers see for `rundown` - a plain, mutable `Rundown` when the
* transaction was opened with `mutableRundown: true`, otherwise a `DeepReadonly<Rundown>`
* so that accidentally mutating an entry (or an order array) on a non-mutable transaction
* - which would silently corrupt the live cache without going through commit() - is a
* compile-time error instead of a runtime bug.
*/
type Transaction<R> = {
customFields: CustomFields;
rundown: Rundown;
rundown: R;
commit: (shouldProcess?: boolean) => Promise<{
rundown: Readonly<Rundown>;
@@ -102,11 +116,17 @@ type TransactionOptions = {
rundownId?: string;
};
export function createTransaction(options: TransactionOptions): Transaction {
export function createTransaction(options: TransactionOptions & { mutableRundown: true }): Transaction<Rundown>;
export function createTransaction(
options: TransactionOptions & { mutableRundown?: false },
): Transaction<DeepReadonly<Rundown>>;
export function createTransaction(
options: TransactionOptions,
): Transaction<Rundown> | Transaction<DeepReadonly<Rundown>> {
const targetId = options.rundownId ?? cachedRundown.id;
const isLoaded = targetId === cachedRundown.id;
const sourceRundown: Rundown = isLoaded ? cachedRundown : (getDataProvider().getRundown(targetId) as Rundown);
const rundown = options.mutableRundown ? structuredClone(sourceRundown) : sourceRundown;
const rundown = options.mutableRundown ? cloneRundown(sourceRundown) : sourceRundown;
const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields;
/**
@@ -707,21 +727,25 @@ export const customFieldMutation = {
* Expose function to add an initial rundown to the system
*/
export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Readonly<CustomFields>) {
const rundown = structuredClone(initialRundown);
const rundown = cloneRundown(initialRundown);
const customFields = structuredClone(initialCustomFields);
const processedData = processRundown(rundown, customFields, { mutate: true });
// update the cache values
cachedRundown.id = rundown.id;
cachedRundown.title = rundown.title;
projectCustomFields = customFields;
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
cachedRundown.entries = entries;
cachedRundown.order = order;
cachedRundown.flatOrder = metadata.flatEntryOrder;
cachedRundown.revision = rundown.revision;
// a fresh object, so that the record already stored for a previously loaded rundown keeps
// pointing at that rundown's data - see the note on cachedRundown
cachedRundown = {
id: rundown.id,
title: rundown.title,
entries,
order,
flatOrder: metadata.flatEntryOrder,
revision: rundown.revision,
};
rundownMetadata = metadata;
// defer writing to the database
@@ -33,7 +33,7 @@ import {
import { makeNewRundown } from '../../models/dataModel.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js';
import { RundownMetadata } from './rundown.types.js';
import { calculateDayOffset, cleanupCustomFields } from './rundown.utils.js';
import { calculateDayOffset, cleanupCustomFields, cloneEntryData } from './rundown.utils.js';
/**
* Parse a rundowns object along with the project custom fields
@@ -234,7 +234,7 @@ export function makeRundownMetadata(customFields: CustomFields, options?: { muta
};
function process<T extends OntimeEntry>(entry: T, childOfGroup: EntryId | null): T {
return processEntry(rundownMeta, customFields, mutate ? entry : structuredClone(entry), childOfGroup);
return processEntry(rundownMeta, customFields, mutate ? entry : cloneEntryData(entry), childOfGroup);
}
function getMetadata(): ProcessedRundownMetadata {
@@ -39,6 +39,7 @@ import {
import { parseRundown } from './rundown.parser.js';
import type { RundownMetadata } from './rundown.types.js';
import {
cloneRundown,
generateEvent,
getFirstInsertId,
getIntegerAndFraction,
@@ -626,7 +627,7 @@ export async function editCustomField(
// ... reassign references in the background rundowns
for (const rundownId of Object.keys(projectRundowns)) {
if (rundownId !== rundown.id) {
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
const backgroundRundown = cloneRundown(projectRundowns[rundownId]);
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
await updateBackgroundRundown(rundownId, backgroundRundown);
}
@@ -666,7 +667,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
// remove references in the background rundowns
for (const rundownId of Object.keys(projectRundowns)) {
if (rundownId !== rundown.id) {
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
const backgroundRundown = cloneRundown(projectRundowns[rundownId]);
customFieldMutation.removeUsages(backgroundRundown, key);
await updateBackgroundRundown(rundownId, backgroundRundown);
}
@@ -846,7 +847,7 @@ export async function duplicateExistingRundown(id: string) {
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(id);
const duplicatedRundown: Rundown = structuredClone(rundown);
const duplicatedRundown: Rundown = cloneRundown(rundown);
duplicatedRundown.id = generateId();
duplicatedRundown.title = `Copy of ${rundown.title}`;
duplicatedRundown.revision = 0;
@@ -329,7 +329,7 @@ export function mergeRundownPreservingFields(
const structure = isOntimeGroup(incomingEntry)
? { entries: incomingEntry.entries }
: { parent: incomingEntry.parent };
entries[id] = structuredClone({ ...merged, ...structure });
entries[id] = cloneEntryData({ ...merged, ...structure });
}
return {
@@ -499,6 +499,61 @@ export function cloneSimpleRundownEntry(entry: OntimeEntry, newId: EntryId): Ont
throw new Error(`Unsupported entry type for cloning: ${entry}`);
}
/**
* Fast, shape-aware clone of a single entry, preserving its identity (id, revision, etc).
* Drop-in replacement for `structuredClone(entry)`
*/
export function cloneEntryData<T extends OntimeEntry>(entry: T): T {
switch (entry.type) {
case SupportedEntry.Event: {
const clone: OntimeEvent = { ...entry };
if (clone.custom) clone.custom = { ...clone.custom };
if (clone.triggers) clone.triggers = clone.triggers.map((trigger) => ({ ...trigger }));
return clone as T;
}
case SupportedEntry.Group: {
const clone: OntimeGroup = { ...entry };
if (clone.custom) clone.custom = { ...clone.custom };
if (clone.entries) clone.entries = clone.entries.slice();
return clone as T;
}
case SupportedEntry.Milestone: {
const clone: OntimeMilestone = { ...entry };
if (clone.custom) clone.custom = { ...clone.custom };
return clone as T;
}
case SupportedEntry.Delay:
return { ...entry } as T;
default: {
// exhaustiveness guard: a new member of `SupportedEntry` is named in the error here
const unhandled: never = entry;
throw new Error(`Unsupported entry type for cloning: ${(unhandled as OntimeEntry).type}`);
}
}
}
/**
* Fast, shape-aware clone of a whole rundown.
* Drop-in replacement for `structuredClone(rundown)`: every entry (and its nested
* `custom` / `triggers` / `entries` containers) gets its own copy, so callers can mutate
* the result freely without touching the source - same contract as structuredClone,
* at a fraction of the cost since we skip the generic serialization algorithm.
*/
export function cloneRundown(rundown: Readonly<Rundown>): Rundown {
const entries: RundownEntries = {};
for (const id in rundown.entries) {
entries[id] = cloneEntryData(rundown.entries[id]);
}
return {
id: rundown.id,
title: rundown.title,
revision: rundown.revision,
order: rundown.order.slice(),
flatOrder: rundown.flatOrder.slice(),
entries,
};
}
/**
* Utility for calculating if the current events should have a day offset
* @param current the current event under test
@@ -464,9 +464,8 @@ export async function upload(sheetId: string, options: ImportMap) {
sheetOrder.forEach((entryId, index) => {
const isGroupEnd = entryId.startsWith('group-end-');
const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId;
const entry = isGroupEnd
? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup)
: structuredClone(rundown.entries[id]);
// cellRequestFromEvent only reads the entry to build a cell request, no clone is needed
const entry = isGroupEnd ? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup) : rundown.entries[id];
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
});
} catch (e) {
@@ -101,8 +101,16 @@ function getCustomFields(): Readonly<CustomFields> {
return db.data.customFields;
}
/**
* Stores a rundown, replacing any existing entry for the same key.
* Takes ownership of `newData` and stores it by reference - the caller must not mutate it
* afterward. Every call site either hands over a freshly-built object it never touches again,
* or (for the loaded rundown) the cache's own long-lived object, which is already the single
* source of truth for that data - aliasing it here costs nothing and avoids a second full
* deep copy of the rundown on every commit.
*/
async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise<ProjectRundowns> {
db.data.rundowns[rundownKey] = structuredClone(newData);
db.data.rundowns[rundownKey] = newData;
await persist();
return db.data.rundowns;
}
@@ -4,12 +4,17 @@ import { DatabaseModel } from 'ontime-types';
* Merges a partial ontime project into a given ontime project
*/
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
const deepExisting = structuredClone(existing);
const deepNewData = structuredClone(newData);
// rundowns are merged separately below by reference (only the top-level map is copied,
// same as the other properties here) - deep-cloning them here would be wasted work,
// since a project's rundowns are by far the largest part of this object
const { rundowns: existingRundowns, ...existingRest } = existing;
const { rundowns: newRundowns = {}, ...newDataRest } = newData;
const deepExisting = structuredClone(existingRest);
const deepNewData = structuredClone(newDataRest);
// destructure each property to simplify merging not provided ie: ...{} has no effect
const {
rundowns = {},
project = {},
settings = {},
viewSettings = {},
@@ -19,7 +24,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseMode
} = deepNewData;
return {
rundowns: { ...existing.rundowns, ...rundowns },
rundowns: { ...existingRundowns, ...newRundowns },
project: { ...deepExisting.project, ...project },
settings: { ...deepExisting.settings, ...settings },
viewSettings: { ...deepExisting.viewSettings, ...viewSettings },
@@ -20,6 +20,7 @@ import { triggerAutomations } from '../../api-data/automation/automation.service
import { triggerReportEntry } from '../../api-data/report/report.service.js';
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
import { cloneEntryData } from '../../api-data/rundown/rundown.utils.js';
import { logger } from '../../classes/Logger.js';
import { timerConfig } from '../../setup/config.js';
import { eventStore } from '../../stores/EventStore.js';
@@ -754,7 +755,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
}
// at this point we know that either the id or the contents has changed
batch.add(key, currentEntry as RuntimeStore[K]); // we know that there is the necessary overlap in the types to cast this
RuntimeService.previousState[key] = structuredClone(currentEntry);
RuntimeService.previousState[key] = currentEntry ? cloneEntryData(currentEntry) : null;
return true;
}
@@ -19,29 +19,3 @@ test('message control sends messages to screens', async ({ context }) => {
await expect(featurePage.getByText('TIME NOW')).toBeVisible();
});
test('message control drives the stage screen state', async ({ context }) => {
const editorPage = await context.newPage();
const featurePage = await context.newPage();
await editorPage.goto('/messagecontrol');
await featurePage.goto('/timer');
await featurePage.waitForLoadState('load', { timeout: 5000 });
// the secondary line defaults to an aux timer, the select is what puts our text on screen
await editorPage.getByPlaceholder('Message shown as secondary text in stage timer').fill('testing secondary');
await editorPage.getByRole('combobox').click();
await editorPage.getByRole('option', { name: 'Message' }).click();
await expect(featurePage.getByText('testing secondary')).toBeVisible();
await editorPage.getByTestId('toggle timer blackout').click();
await expect(featurePage.locator('.blackout')).toHaveClass(/blackout--active/);
// clearing returns the screen to normal, but keeps what the operator typed
await editorPage.getByTestId('clear screen').click();
await expect(featurePage.locator('.blackout')).not.toHaveClass(/blackout--active/);
await expect(featurePage.getByText('testing secondary')).toHaveCount(0);
await expect(editorPage.getByPlaceholder('Message shown as secondary text in stage timer')).toHaveValue(
'testing secondary',
);
});