feat: implement multiple aux timers

This commit is contained in:
Carlos Valente
2025-06-24 07:20:53 +02:00
committed by Carlos Valente
parent d1c58712ae
commit bf8ed8d017
33 changed files with 748 additions and 297 deletions
@@ -42,7 +42,15 @@
} }
.separator { .separator {
width: 1px;
height: 0.75em;
background-color: $border-color-ondark; background-color: $border-color-ondark;
&.horizontal {
width: 100%;
height: 1px;
}
&.vertical {
width: 1px;
height: 0.75em;
}
} }
@@ -28,6 +28,10 @@ export function Label({ children, className, ...elementProps }: LabelHTMLAttribu
); );
} }
export function Separator({ className, ...elementProps }: HTMLAttributes<HTMLDivElement>) { interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
return <div className={cx([style.separator, className])} {...elementProps} />; orientation?: 'horizontal' | 'vertical';
}
export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) {
return <div className={cx([style.separator, style[orientation], className])} {...elementProps} />;
} }
@@ -1,4 +1,4 @@
import { InputHTMLAttributes } from 'react'; import { forwardRef, InputHTMLAttributes } from 'react';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -9,6 +9,18 @@ interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
height?: 'medium' | 'large'; height?: 'medium' | 'large';
} }
export default function Input({ className, variant = 'subtle', height = 'medium', ...inputProps }: InputProps) { const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
return <input type='text' className={cx([style.input, style[variant], style[height], className])} {...inputProps} />; { className, variant = 'subtle', height = 'medium', ...inputProps },
} ref,
) {
return (
<input
ref={ref}
type='text'
className={cx([style.input, style[variant], style[height], className])}
{...inputProps}
/>
);
});
export default Input;
@@ -1,4 +1,7 @@
.timeInput { .timeInput {
letter-spacing: 1px; width: 100%;
width: 6.5em; max-width: 7.5em;
letter-spacing: 1px;
font-size: 1rem;
font-variant-numeric: tabular-nums;
} }
@@ -1,15 +1,18 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import { millisToString, parseUserTime } from 'ontime-utils'; import { millisToString, parseUserTime } from 'ontime-utils';
import { useEmitLog } from '../../../stores/logger'; import { useEmitLog } from '../../../stores/logger';
import { cx } from '../../../utils/styleUtils';
import Input from '../input/Input';
import style from './TimeInput.module.scss';
interface TimeInputProps<T extends string> { interface TimeInputProps<T extends string> {
id?: T; id?: T;
name: T; name: T;
submitHandler: (field: T, value: string) => void; submitHandler: (field: T, value: string) => void;
time?: number; time?: number;
placeholder: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
align?: 'left' | 'center'; align?: 'left' | 'center';
className?: string; className?: string;
@@ -27,6 +30,9 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
*/ */
const resetValue = useCallback(() => { const resetValue = useCallback(() => {
try { try {
if (typeof time !== 'number' || isNaN(time)) {
throw new Error(`Invalid time value: ${time}`);
}
setValue(millisToString(time)); setValue(millisToString(time));
} catch (error) { } catch (error) {
setValue(millisToString(0)); setValue(millisToString(0));
@@ -121,24 +127,20 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
<Input <Input
id={id} id={id}
disabled={disabled} disabled={disabled}
size='sm'
ref={inputRef} ref={inputRef}
data-testid={`time-input-${name}`} data-testid={`time-input-${name}`}
className={className} className={cx([style.timeInput, className])}
fontSize='1rem'
type='text'
placeholder={placeholder} placeholder={placeholder}
variant='ontime-filled'
onFocus={handleFocus} onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)} onChange={(event) => setValue(event.target.value)}
onBlur={onBlurHandler} onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler} onKeyDown={onKeyDownHandler}
value={value} value={value}
maxLength={8} maxLength={8}
maxWidth='7.5em'
letterSpacing='1px'
autoComplete='off' autoComplete='off'
textAlign={align} style={{
textAlign: align,
}}
/> />
); );
} }
@@ -0,0 +1,128 @@
.select {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
height: 2rem;
background-color: $gray-1200;
font-size: 1rem;
font-weight: 400;
color: $gray-200;
border-radius: $component-border-radius-md;
border: 1px solid transparent;
padding-inline: 0.5rem;
font-size: calc(1rem - 2px);
white-space: nowrap;
&:hover:not(:disabled) {
background-color: $gray-1100;
}
&:active {
background-color: $gray-1000;
}
&[data-popup-open] {
background-color: $gray-1000;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.selectIcon {
color: $gray-200;
}
.popup {
font-size: calc(1rem - 2px);
background-color: $gray-1200;
box-sizing: border-box;
padding: 2px;
border-radius: $component-border-radius-md;
color: $ui-white;
overflow-y: auto;
max-height: 20rem;
border: 1px solid $gray-1000;
&[data-side='start'] {
transition: 50%;
transform: 100%;
opacity: 1;
}
}
.item {
box-sizing: border-box;
outline: 0;
line-height: 1rem;
padding: 0.25rem 0.5rem;
min-width: var(--anchor-width);
display: grid;
gap: 0.25rem;
align-items: center;
grid-template-columns: 0.75rem 1fr;
scroll-margin-block: 1rem;
&[data-highlighted] {
z-index: 0;
position: relative;
background-color: $blue-700;
}
}
.itemIndicator {
grid-column-start: 1;
}
.itemIndicatorIcon {
display: block;
width: 0.75rem;
height: 0.75rem;
}
.itemLabel {
grid-column-start: 2;
}
.scrollArrow {
width: 100%;
background: canvas;
z-index: 1;
text-align: center;
cursor: default;
border-radius: 0.375rem;
height: 1rem;
font-size: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
&::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
left: 0;
}
&[data-direction='up'] {
&::before {
top: -100%;
}
}
&[data-direction='down'] {
bottom: 0;
&::before {
bottom: -100%;
}
}
}
@@ -0,0 +1,53 @@
import { IoCheckmark } from 'react-icons/io5';
import { LuChevronsUpDown } from 'react-icons/lu';
import { Select as BaseSelect } from '@base-ui-components/react/select';
import styles from './Select.module.scss';
interface SelectProps<T extends string | null = string> {
defaultValue?: T;
options: {
value: NonNullable<T>;
label: string;
}[];
placeholder?: string;
value?: T;
onChange?: (value: NonNullable<T>) => void;
}
export default function Select<T extends string | null = string>({
defaultValue,
options,
placeholder,
value,
onChange,
}: SelectProps<T>) {
return (
<BaseSelect.Root defaultValue={defaultValue} onValueChange={onChange} value={value}>
<BaseSelect.Trigger className={styles.select}>
<BaseSelect.Value placeholder={placeholder} />
<BaseSelect.Icon className={styles.selectIcon}>
<LuChevronsUpDown />
</BaseSelect.Icon>
</BaseSelect.Trigger>
<BaseSelect.Portal>
<BaseSelect.Positioner side='bottom' align='start'>
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
<BaseSelect.Popup className={styles.popup}>
{options.map((option) => {
return (
<BaseSelect.Item key={option.value} className={styles.item} value={option.value}>
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
<IoCheckmark className={styles.itemIndicatorIcon} />
</BaseSelect.ItemIndicator>
<BaseSelect.ItemText className={styles.itemLabel}>{option.label}</BaseSelect.ItemText>
</BaseSelect.Item>
);
})}
</BaseSelect.Popup>
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} />
</BaseSelect.Positioner>
</BaseSelect.Portal>
</BaseSelect.Root>
);
}
+38 -13
View File
@@ -40,8 +40,7 @@ export const useMessagePreview = createSelector((state: RuntimeStore) => ({
blink: state.message.timer.blink, blink: state.message.timer.blink,
blackout: state.message.timer.blackout, blackout: state.message.timer.blackout,
phase: state.timer.phase, phase: state.timer.phase,
showAuxTimer: state.message.timer.secondarySource === 'aux', secondarySource: state.message.timer.secondarySource,
showSecondaryMessage: state.message.timer.secondarySource === 'secondary' && Boolean(state.message.secondary),
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text), showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null, timerType: state.eventNow?.timerType ?? null,
countToEnd: state.eventNow?.countToEnd ?? false, countToEnd: state.eventNow?.countToEnd ?? false,
@@ -53,7 +52,7 @@ export const setMessage = {
secondaryMessage: (payload: string) => sendSocket('message', { secondary: payload }), secondaryMessage: (payload: string) => sendSocket('message', { secondary: payload }),
timerBlink: (payload: boolean) => sendSocket('message', { timer: { blink: payload } }), timerBlink: (payload: boolean) => sendSocket('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => sendSocket('message', { timer: { blackout: payload } }), timerBlackout: (payload: boolean) => sendSocket('message', { timer: { blackout: payload } }),
timerSecondary: (payload: TimerMessage['secondarySource']) => timerSecondarySource: (payload: TimerMessage['secondarySource']) =>
sendSocket('message', { timer: { secondarySource: payload } }), sendSocket('message', { timer: { secondarySource: payload } }),
}; };
@@ -86,19 +85,45 @@ export const setPlayback = {
}, },
}; };
export const useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current); export const useAuxTimersTime = createSelector((state: RuntimeStore) => {
return {
aux1: state.auxtimer1.current,
aux2: state.auxtimer2.current,
aux3: state.auxtimer3.current,
};
});
export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({ export const useAuxTimerTime = (index: number) =>
playback: state.auxtimer1.playback, createSelector((state: RuntimeStore) => {
direction: state.auxtimer1.direction, if (index === 1) return state.auxtimer1.current;
})); if (index === 2) return state.auxtimer2.current;
return state.auxtimer3.current;
})();
export const useAuxTimerControl = (index: number) =>
createSelector((state: RuntimeStore) => {
if (index === 1)
return {
playback: state.auxtimer1.playback,
direction: state.auxtimer1.direction,
};
if (index === 2)
return {
playback: state.auxtimer2.playback,
direction: state.auxtimer2.direction,
};
return {
playback: state.auxtimer3.playback,
direction: state.auxtimer3.direction,
};
})();
export const setAuxTimer = { export const setAuxTimer = {
start: () => sendSocket('auxtimer', { '1': SimplePlayback.Start }), start: (index: number) => sendSocket('auxtimer', { [index]: SimplePlayback.Start }),
pause: () => sendSocket('auxtimer', { '1': SimplePlayback.Pause }), pause: (index: number) => sendSocket('auxtimer', { [index]: SimplePlayback.Pause }),
stop: () => sendSocket('auxtimer', { '1': SimplePlayback.Stop }), stop: (index: number) => sendSocket('auxtimer', { [index]: SimplePlayback.Stop }),
setDirection: (direction: SimpleDirection) => sendSocket('auxtimer', { '1': { direction } }), setDirection: (index: number, direction: SimpleDirection) => sendSocket('auxtimer', { [index]: { direction } }),
setDuration: (time: number) => sendSocket('auxtimer', { '1': { duration: time } }), setDuration: (index: number, time: number) => sendSocket('auxtimer', { [index]: { duration: time } }),
}; };
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({ export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
@@ -42,17 +42,25 @@ export default function OntimeActionForm(props: PropsWithChildren<OntimeActionFo
value={selectedAction} value={selectedAction}
onChange={(event) => updateSelectedAction(event.target.value)} onChange={(event) => updateSelectedAction(event.target.value)}
> >
<option value='aux-start'>Auxiliary timer: start</option> <option value='aux-start'>Aux 1: start</option>
<option value='aux-pause'>Auxiliary timer: pause</option> <option value='aux-pause'>Aux 1: pause</option>
<option value='aux-stop'>Auxiliary timer: stop</option> <option value='aux-stop'>Aux 1: stop</option>
<option value='aux-set'>Auxiliary timer: set</option> <option value='aux-set'>Aux 2: set</option>
<option value='aux-start'>Aux 2: start</option>
<option value='aux-pause'>Aux 2: pause</option>
<option value='aux-stop'>Aux 2: stop</option>
<option value='aux-set'>Aux 2: set</option>
<option value='aux-start'>Aux 3: start</option>
<option value='aux-pause'>Aux 3: pause</option>
<option value='aux-stop'>Aux 3: stop</option>
<option value='aux-set'>Aux 3: set</option>
<option value='message-set'>Timer: timer message</option> <option value='message-set'>Timer: timer message</option>
<option value='message-secondary'>Timer: timer secondary</option> <option value='message-secondary'>Timer: timer secondary</option>
</Select> </Select>
<Panel.Error>{rowErrors?.action?.message}</Panel.Error> <Panel.Error>{rowErrors?.action?.message}</Panel.Error>
</label> </label>
{selectedAction === 'aux-set' && ( {selectedAction === 'aux1-set' && (
<label> <label>
New time New time
<Input <Input
@@ -10,7 +10,7 @@
.previewContainer { .previewContainer {
display: grid; display: grid;
gap: $element-spacing; gap: $element-spacing;
grid-template-columns: 2fr 1fr; grid-template-columns: 3fr 2fr;
} }
.preview { .preview {
@@ -39,6 +39,7 @@
.mainContent { .mainContent {
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
width: 100%;
color: var(--override-colour, $ui-white); color: var(--override-colour, $ui-white);
&[data-phase='pending'] { &[data-phase='pending'] {
@@ -72,7 +73,3 @@
color: $active-indicator; color: $active-indicator;
} }
} }
.divider {
border-top: 1px solid $gray-1000;
}
@@ -1,12 +1,11 @@
import { IoEye, IoEyeOffOutline } from 'react-icons/io5'; import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn'; import IconButton from '../../../common/components/buttons/IconButton';
import { import {
setMessage, setMessage,
useExternalMessageInput as useSecondaryMessageInput, useExternalMessageInput as useSecondaryMessageInput,
useTimerMessageInput, useTimerMessageInput,
} from '../../../common/hooks/useSocket'; } from '../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../ontimeConfig';
import InputRow from './InputRow'; import InputRow from './InputRow';
import TimerControlsPreview from './TimerViewControl'; import TimerControlsPreview from './TimerViewControl';
@@ -32,15 +31,13 @@ function TimerMessageInput() {
visible={visible} visible={visible}
changeHandler={(newValue) => setMessage.timerText(newValue)} changeHandler={(newValue) => setMessage.timerText(newValue)}
> >
<TooltipActionBtn <IconButton
clickHandler={() => setMessage.timerVisible(!visible)}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label='Toggle timer message visibility' aria-label='Toggle timer message visibility'
openDelay={tooltipDelayMid} onClick={() => setMessage.timerVisible(!visible)}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />} variant={visible ? 'primary' : 'subtle'}
variant={visible ? 'ontime-filled' : 'ontime-subtle'} >
size='sm' {visible ? <IoEye /> : <IoEyeOffOutline />}
/> </IconButton>
</InputRow> </InputRow>
); );
} }
@@ -50,9 +47,9 @@ function SecondaryInput() {
const toggleSecondary = () => { const toggleSecondary = () => {
if (visible) { if (visible) {
setMessage.timerSecondary(null); setMessage.timerSecondarySource(null);
} else { } else {
setMessage.timerSecondary('secondary'); setMessage.timerSecondarySource('secondary');
} }
}; };
@@ -64,15 +61,13 @@ function SecondaryInput() {
visible={visible} visible={visible}
changeHandler={(newValue) => setMessage.secondaryMessage(newValue)} changeHandler={(newValue) => setMessage.secondaryMessage(newValue)}
> >
<TooltipActionBtn <IconButton
clickHandler={toggleSecondary}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label='Toggle secondary message visibility' aria-label='Toggle secondary message visibility'
openDelay={tooltipDelayMid} onClick={toggleSecondary}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />} variant={visible ? 'primary' : 'subtle'}
variant={visible ? 'ontime-filled' : 'ontime-subtle'} >
size='sm' {visible ? <IoEye /> : <IoEyeOffOutline />}
/> </IconButton>
</InputRow> </InputRow>
); );
} }
@@ -11,12 +11,16 @@ import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './MessageControl.module.scss'; import style from './MessageControl.module.scss';
export default function TimerPreview() { const secondarySourceLabels: Record<string, string> = {
const { blink, blackout, countToEnd, phase, showAuxTimer, showSecondaryMessage, showTimerMessage, timerType } = aux1: 'Aux 1',
useMessagePreview(); aux2: 'Aux 2',
const { data } = useViewSettings(); aux3: 'Aux 3',
secondary: 'Secondary message',
};
const contentClasses = cx([style.previewContent, blink && style.blink, blackout && style.blackout]); export default function TimerPreview() {
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
const { data } = useViewSettings();
const main = (() => { const main = (() => {
if (showTimerMessage) return 'Message'; if (showTimerMessage) return 'Message';
@@ -29,13 +33,11 @@ export default function TimerPreview() {
})(); })();
const secondary = (() => { const secondary = (() => {
// message is a fullscreen overlay // message is a fullscreen overlay or secondary is not active
if (showTimerMessage) return null; if (showTimerMessage || !secondarySource) return null;
// we need to check aux first since it takes priority // we need to check aux first since it takes priority
if (showAuxTimer) return 'Aux Timer'; return secondarySourceLabels[secondarySource];
if (showSecondaryMessage) return 'Secondary message';
return null;
})(); })();
const overrideColour = (() => { const overrideColour = (() => {
@@ -46,6 +48,7 @@ export default function TimerPreview() {
})(); })();
const showColourOverride = main == 'Timer'; const showColourOverride = main == 'Timer';
const contentClasses = cx([blink && style.blink, blackout && style.blackout]);
return ( return (
<div className={style.preview}> <div className={style.preview}>
@@ -1,6 +1,9 @@
import { Button } from '@chakra-ui/react'; import { useEffect, useState } from 'react';
import { SecondarySource } from 'ontime-types'; import { SecondarySource } from 'ontime-types';
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 { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket';
import TimerPreview from './TimerPreview'; import TimerPreview from './TimerPreview';
@@ -8,50 +11,27 @@ import TimerPreview from './TimerPreview';
import style from './MessageControl.module.scss'; import style from './MessageControl.module.scss';
export default function TimerControlsPreview() { export default function TimerControlsPreview() {
const { blackout, blink, secondarySource } = useTimerViewControl(); const { blackout, blink } = useTimerViewControl();
const toggleSecondary = (newValue: SecondarySource) => {
if (secondarySource === newValue) {
setMessage.timerSecondary(null);
} else {
setMessage.timerSecondary(newValue);
}
};
return ( return (
<div className={style.previewContainer}> <div className={style.previewContainer}>
<TimerPreview /> <TimerPreview />
<div className={style.options}> <div className={style.options}>
<Button <SecondarySourceControl />
size='sm'
variant={secondarySource === 'aux' ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => toggleSecondary('aux')}
>
Show Aux timer
</Button>
<Button
size='sm'
variant={secondarySource === 'secondary' ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => toggleSecondary('secondary')}
>
Show secondary
</Button>
<hr className={style.divider} /> <Editor.Separator orientation='horizontal' />
<Button <Button
size='sm' variant={blink ? 'primary' : 'subtle'}
variant={blink ? 'ontime-filled' : 'ontime-subtle'} fluid
onClick={() => setMessage.timerBlink(!blink)} onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink' data-testid='toggle timer blink'
> >
Blink Blink
</Button> </Button>
<Button <Button
size='sm' variant={blackout ? 'primary' : 'subtle'}
className={style.blackoutButton} fluid
variant={blackout ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => setMessage.timerBlackout(!blackout)} onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout' data-testid='toggle timer blackout'
> >
@@ -61,3 +41,55 @@ export default function TimerControlsPreview() {
</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);
}
};
const changeValue = (newValue: SecondarySource) => {
// we can only update the remote if it is enabled
if (secondarySource !== null) {
setMessage.timerSecondarySource(newValue);
}
setValue(newValue);
};
return (
<>
<Select
value={value}
placeholder='Secondary source'
options={[
{ value: 'aux1', label: 'Aux 1' },
{ value: 'aux2', label: 'Aux 2' },
{ value: 'aux3', label: 'Aux 3' },
{ value: 'secondary', label: 'Secondary message' },
]}
onChange={changeValue}
/>
<Button
variant={secondarySource !== null ? 'primary' : 'subtle'}
fluid
onClick={toggleSecondary}
data-testid='toggle secondary'
>
Show secondary
</Button>
</>
);
}
@@ -2,3 +2,8 @@
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
} }
.auxTimers {
display: flex;
gap: 0.5rem;
}
@@ -23,7 +23,11 @@ export default function PlaybackControl() {
selectedEventIndex={data.selectedEventIndex} selectedEventIndex={data.selectedEventIndex}
timerPhase={data.timerPhase} timerPhase={data.timerPhase}
/> />
<AuxTimer /> <div className={style.auxTimers}>
<AuxTimer index={1} />
<AuxTimer index={2} />
<AuxTimer index={3} />
</div>
</div> </div>
); );
} }
@@ -7,6 +7,18 @@
.controls { .controls {
margin-top: 0.25rem; margin-top: 0.25rem;
display: flex; }
gap: 0.5rem;
.input {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.25rem;
}
.twoSides {
margin-top: 0.25rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.25rem;
height: 1.5rem;
} }
@@ -8,71 +8,82 @@ import TapButton from '../tap-button/TapButton';
import style from './AuxTimer.module.scss'; import style from './AuxTimer.module.scss';
export function AuxTimer() { interface AuxTimerProps {
const { playback, direction } = useAuxTimerControl(); index: number;
}
const { start, pause, stop, setDirection } = setAuxTimer; export function AuxTimer({ index }: AuxTimerProps) {
const { playback, direction } = useAuxTimerControl(index);
const { stop, setDirection } = setAuxTimer;
const toggleDirection = () => { const toggleDirection = () => {
const newDirection = direction === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown; const newDirection = direction === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown;
setDirection(newDirection); setDirection(index, newDirection);
}; };
const userCan = { const canStop = playback !== SimplePlayback.Stop;
start: playback !== SimplePlayback.Start, const playbackAction = playback === SimplePlayback.Start ? 'pause' : 'play';
pause: playback === SimplePlayback.Start,
stop: playback !== SimplePlayback.Stop,
};
return ( return (
<label className={style.label}> <label className={style.label}>
Auxiliary Timer Aux Timer {index}
<div className={style.controls}> <div className={style.controls}>
<AuxTimerInput /> <div className={style.input}>
<TapButton onClick={toggleDirection} aspect='tight'> <AuxTimerInput index={index} />
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid='aux-timer-direction' />} <TapButton onClick={toggleDirection} aspect='tight'>
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid='aux-timer-direction' />} {direction === SimpleDirection.CountDown && <IoArrowDown data-testid={`aux-timer-direction-${index}`} />}
</TapButton> {direction === SimpleDirection.CountUp && <IoArrowUp data-testid={`aux-timer-direction-${index}`} />}
</TapButton>
<TapButton </div>
onClick={start} <div className={style.twoSides}>
theme={Playback.Play} <AuxTogglePlay index={index} action={playbackAction} />
active={playback === SimplePlayback.Start} <TapButton onClick={() => stop(index)} theme={Playback.Stop} disabled={!canStop}>
disabled={!userCan.start} <IoStop data-testid={`aux-timer-stop-${index}`} />
> </TapButton>
<IoPlay data-testid='aux-timer-start' /> </div>
</TapButton>
<TapButton
onClick={pause}
theme={Playback.Pause}
active={playback === SimplePlayback.Pause}
disabled={!userCan.pause}
>
<IoPause data-testid='aux-timer-pause' />
</TapButton>
<TapButton onClick={stop} theme={Playback.Stop} disabled={!userCan.stop}>
<IoStop data-testid='aux-timer-stop' />
</TapButton>
</div> </div>
</label> </label>
); );
} }
function AuxTimerInput() { interface AuxTimerInput {
const newTimeInMs = useAuxTimerTime(); index: number;
}
function AuxTimerInput({ index }: AuxTimerProps) {
const newTimeInMs = useAuxTimerTime(index);
const { setDuration } = setAuxTimer; const { setDuration } = setAuxTimer;
const handleTimeUpdate = (_field: string, value: string) => { const handleTimeUpdate = (_field: string, value: string) => {
const newTimeInMs = parseUserTime(value); const newTimeInMs = parseUserTime(value);
setDuration(newTimeInMs); setDuration(index, newTimeInMs);
}; };
return ( return (
<TimeInput<'auxTimer'> <TimeInput submitHandler={handleTimeUpdate} name={`aux${index}`} time={newTimeInMs} placeholder={`Aux ${index}`} />
submitHandler={handleTimeUpdate} );
name='auxTimer' }
time={newTimeInMs}
placeholder='Aux Timer 1' interface AuxTogglePlayProps {
/> index: number;
action: 'play' | 'pause';
}
function AuxTogglePlay({ index, action }: AuxTogglePlayProps) {
const { start, pause } = setAuxTimer;
if (action === 'play') {
return (
<TapButton onClick={() => start(index)} theme={Playback.Play}>
<IoPlay data-testid={`aux-timer-start-${index}`} />
</TapButton>
);
}
return (
<TapButton onClick={() => pause(index)} theme={Playback.Pause}>
<IoPause data-testid={`aux-timer-pause-${index}`} />
</TapButton>
); );
} }
+1 -12
View File
@@ -1,12 +1,4 @@
import { import { MessageState, OntimeEvent, ProjectData, Runtime, Settings, ViewSettings } from 'ontime-types';
MessageState,
OntimeEvent,
ProjectData,
Runtime,
Settings,
SimpleTimerState,
ViewSettings,
} from 'ontime-types';
import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
@@ -22,7 +14,6 @@ import StudioTimers from './StudioTimers';
import './Studio.scss'; import './Studio.scss';
interface StudioProps { interface StudioProps {
auxTimer: SimpleTimerState;
eventNow: OntimeEvent | null; eventNow: OntimeEvent | null;
eventNext: OntimeEvent | null; eventNext: OntimeEvent | null;
general: ProjectData; general: ProjectData;
@@ -36,7 +27,6 @@ interface StudioProps {
} }
export default function Studio({ export default function Studio({
auxTimer,
eventNow, eventNow,
eventNext, eventNext,
general, general,
@@ -70,7 +60,6 @@ export default function Studio({
<StudioTimers <StudioTimers
eventNow={eventNow} eventNow={eventNow}
eventNext={eventNext} eventNext={eventNext}
auxTimer={auxTimer.current}
timerMessage={message.timer.visible ? message.timer.text : ''} timerMessage={message.timer.visible ? message.timer.text : ''}
secondaryMessage={message.secondary} secondaryMessage={message.secondary}
runtime={runtime} runtime={runtime}
+27 -21
View File
@@ -1,6 +1,7 @@
import { OntimeEvent, Playback, Runtime, TimerPhase, TimerState, ViewSettings } from 'ontime-types'; import { OntimeEvent, Playback, Runtime, TimerPhase, TimerState, ViewSettings } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { useAuxTimersTime } from '../../common/hooks/useSocket';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import { useTranslation } from '../../translation/TranslationProvider'; import { useTranslation } from '../../translation/TranslationProvider';
import { getTimerColour } from '../utils/presentation.utils'; import { getTimerColour } from '../utils/presentation.utils';
@@ -14,7 +15,6 @@ interface StudioTimersProps {
time: TimerState; time: TimerState;
eventNow: OntimeEvent | null; eventNow: OntimeEvent | null;
eventNext: OntimeEvent | null; eventNext: OntimeEvent | null;
auxTimer: number;
timerMessage: string; timerMessage: string;
secondaryMessage: string; secondaryMessage: string;
viewSettings: ViewSettings; viewSettings: ViewSettings;
@@ -25,7 +25,6 @@ export default function StudioTimers({
time, time,
eventNow, eventNow,
eventNext, eventNext,
auxTimer,
timerMessage, timerMessage,
secondaryMessage, secondaryMessage,
viewSettings, viewSettings,
@@ -35,7 +34,6 @@ export default function StudioTimers({
const schedule = getFormattedScheduleTimes(runtime); const schedule = getFormattedScheduleTimes(runtime);
const event = getFormattedEventData(eventNow, time); const event = getFormattedEventData(eventNow, time);
const eventNextTitle = eventNext?.title || '-'; const eventNextTitle = eventNext?.title || '-';
const formattedAuxTimer = millisToString(auxTimer);
const formattedTimerMessage = timerMessage || '-'; const formattedTimerMessage = timerMessage || '-';
const formattedSecondaryMessage = secondaryMessage || '-'; const formattedSecondaryMessage = secondaryMessage || '-';
@@ -110,24 +108,7 @@ export default function StudioTimers({
</div> </div>
</div> </div>
<div className='card' id='card-aux'> <StudioTimersAux />
<div className='card__row'>
<div>
<div className='label'>Aux 1</div>
<div className='extra'>{formattedAuxTimer}</div>
</div>
<div>
<div className='label center'>Aux 2</div>
<div className='extra center'>NOT YET</div>
</div>
<div>
<div className='label right'>Aux 3</div>
<div className='extra right'>NOT YET</div>
</div>
</div>
</div>
<div className='card' id='card-timer-message'> <div className='card' id='card-timer-message'>
<div> <div>
@@ -145,3 +126,28 @@ export default function StudioTimers({
</div> </div>
); );
} }
function StudioTimersAux() {
const auxTimer = useAuxTimersTime();
return (
<div className='card' id='card-aux'>
<div className='card__row'>
<div>
<div className='label'>Aux 1</div>
<div className='extra'>{millisToString(auxTimer.aux1)}</div>
</div>
<div>
<div className='label center'>Aux 2</div>
<div className='extra center'>{millisToString(auxTimer.aux2)}</div>
</div>
<div>
<div className='label right'>Aux 3</div>
<div className='extra right'>{millisToString(auxTimer.aux3)}</div>
</div>
</div>
</div>
);
}
+18 -13
View File
@@ -1,18 +1,11 @@
import { import { CustomFields, MessageState, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
CustomFields,
MessageState,
OntimeEvent,
ProjectData,
Settings,
SimpleTimerState,
ViewSettings,
} from 'ontime-types';
import { FitText } from '../../common/components/fit-text/FitText'; import { FitText } from '../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../common/components/multi-part-progress-bar/MultiPartProgressBar'; import MultiPartProgressBar from '../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import TitleCard from '../../common/components/title-card/TitleCard'; import TitleCard from '../../common/components/title-card/TitleCard';
import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useAuxTimersTime } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
@@ -38,7 +31,6 @@ import {
import './Timer.scss'; import './Timer.scss';
interface TimerProps { interface TimerProps {
auxTimer: SimpleTimerState;
customFields: CustomFields; customFields: CustomFields;
eventNext: OntimeEvent | null; eventNext: OntimeEvent | null;
eventNow: OntimeEvent | null; eventNow: OntimeEvent | null;
@@ -51,8 +43,8 @@ interface TimerProps {
} }
export default function Timer(props: TimerProps) { export default function Timer(props: TimerProps) {
const { auxTimer, customFields, eventNow, eventNext, general, isMirrored, message, settings, time, viewSettings } = const { customFields, eventNow, eventNext, general, isMirrored, message, settings, time, viewSettings } = props;
props; const auxTimer = useAuxTimersTime();
const { const {
hideClock, hideClock,
@@ -104,9 +96,22 @@ export default function Timer(props: TimerProps) {
removeLeadingZero: removeLeadingZeros, removeLeadingZero: removeLeadingZeros,
}); });
const currentAux = (() => {
if (message.timer.secondarySource === 'aux1') {
return auxTimer.aux1;
}
if (message.timer.secondarySource === 'aux2') {
return auxTimer.aux2;
}
if (message.timer.secondarySource === 'aux3') {
return auxTimer.aux3;
}
return null;
})();
const secondaryContent = getSecondaryDisplay( const secondaryContent = getSecondaryDisplay(
message, message,
auxTimer.current, currentAux,
localisedMinutes, localisedMinutes,
hideTimerSeconds, hideTimerSeconds,
removeLeadingZeros, removeLeadingZeros,
+5 -1
View File
@@ -116,7 +116,11 @@ export function getSecondaryDisplay(
if (hideSecondary) { if (hideSecondary) {
return; return;
} }
if (message.timer.secondarySource === 'aux') { if (
message.timer.secondarySource === 'aux1' ||
message.timer.secondarySource === 'aux2' ||
message.timer.secondarySource === 'aux3'
) {
return getFormattedTimer(currentAux, TimerType.CountDown, localisedMinutes, { return getFormattedTimer(currentAux, TimerType.CountDown, localisedMinutes, {
removeSeconds, removeSeconds,
removeLeadingZero, removeLeadingZero,
@@ -149,10 +149,10 @@ describe('parseOutput', () => {
parseOutput({ parseOutput({
type: 'ontime', type: 'ontime',
action: 'message-secondary', action: 'message-secondary',
secondarySource: 'aux', secondarySource: 'aux1',
}), }),
).toMatchObject({ ).toMatchObject({
secondarySource: 'aux', secondarySource: 'aux1',
}); });
expect( expect(
parseOutput({ parseOutput({
@@ -189,13 +189,17 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
// we know we have a valid action, deal with special cases // we know we have a valid action, deal with special cases
if (maybeOntimeAction.action === 'aux-set') { if (
maybeOntimeAction.action === 'aux1-set' ||
maybeOntimeAction.action === 'aux2-set' ||
maybeOntimeAction.action === 'aux3-set'
) {
assert.hasKeys(maybeOntimeAction, ['time']); assert.hasKeys(maybeOntimeAction, ['time']);
assert.isString(maybeOntimeAction.time); assert.isString(maybeOntimeAction.time);
return { return {
type: 'ontime', type: 'ontime',
action: 'aux-set', action: maybeOntimeAction.action,
time: parseUserTime(maybeOntimeAction.time), time: parseUserTime(maybeOntimeAction.time),
}; };
} }
@@ -253,7 +257,9 @@ function indeterminateBooleanString(value: string): boolean | undefined {
* Helper function to validate the secondary source * Helper function to validate the secondary source
*/ */
function chooseSecondarySource(value: string): SecondarySource { function chooseSecondarySource(value: string): SecondarySource {
if (value === 'aux') return 'aux'; if (value === 'aux1') return 'aux1';
if (value === 'aux2') return 'aux2';
if (value === 'aux3') return 'aux3';
if (value === 'secondary') return 'secondary'; if (value === 'secondary') return 'secondary';
return null; return null;
} }
@@ -8,18 +8,32 @@ export function toOntimeAction(action: OntimeAction) {
const actionType = action.action; const actionType = action.action;
switch (actionType) { switch (actionType) {
// Aux timer actions // Aux timer actions
case 'aux-start': case 'aux1-start':
auxTimerService.start(); return auxTimerService.start(1);
break; case 'aux1-stop':
case 'aux-stop': return auxTimerService.stop(1);
auxTimerService.stop(); case 'aux1-pause':
break; return auxTimerService.pause(1);
case 'aux-pause': case 'aux1-set': {
auxTimerService.pause(); return auxTimerService.setTime(action.time, 1);
break; }
case 'aux-set': { case 'aux2-start':
auxTimerService.setTime(action.time); return auxTimerService.start(2);
break; case 'aux2-stop':
return auxTimerService.stop(2);
case 'aux2-pause':
return auxTimerService.pause(2);
case 'aux2-set': {
return auxTimerService.setTime(action.time, 2);
}
case 'aux3-start':
return auxTimerService.start(3);
case 'aux3-stop':
return auxTimerService.stop(3);
case 'aux3-pause':
return auxTimerService.pause(3);
case 'aux3-set': {
return auxTimerService.setTime(action.time, 3);
} }
// Message actions // Message actions
@@ -18,7 +18,6 @@ import { validateMessage, validateTimerMessage } from '../services/message-servi
import { runtimeService } from '../services/runtime-service/RuntimeService.js'; import { runtimeService } from '../services/runtime-service/RuntimeService.js';
import { eventStore } from '../stores/EventStore.js'; import { eventStore } from '../stores/EventStore.js';
import * as assert from '../utils/assert.js'; import * as assert from '../utils/assert.js';
import { isEmptyObject } from '../utils/parserUtils.js';
import { parseProperty } from './integration.utils.js'; import { parseProperty } from './integration.utils.js';
import { socket } from '../adapters/WebsocketAdapter.js'; import { socket } from '../adapters/WebsocketAdapter.js';
import { throttle } from '../utils/throttle.js'; import { throttle } from '../utils/throttle.js';
@@ -218,45 +217,61 @@ const actionHandlers: Record<ApiAction, ActionHandler> = {
runtimeService.addTime(time); runtimeService.addTime(time);
return { payload: 'success' }; return { payload: 'success' };
}, },
/* Extra timers */ /**
* Auxiliary timers, payload can be either:
*
* 1. a simple playback command
* {
* "1": "start" | "pause" | "stop"
* }
*
* - or -
*
* 2. a patch object with properties
* {
* "1": {
* duration: "count-down"
* }
* }
*
*/
auxtimer: (payload) => { auxtimer: (payload) => {
assert.isObject(payload); assert.isObject(payload);
if (!('1' in payload)) { const timerIndex = Object.keys(payload).at(0);
if (timerIndex !== '1' && timerIndex !== '2' && timerIndex !== '3') {
throw new Error('Invalid auxtimer index'); throw new Error('Invalid auxtimer index');
} }
const command = payload['1'];
const command = payload[timerIndex as keyof typeof payload] as unknown;
const index = Number(timerIndex);
// 1. handle simple playback commands: start, pause, stop
if (typeof command === 'string') { if (typeof command === 'string') {
if (command === SimplePlayback.Start) { switch (command) {
const reply = auxTimerService.start(); case SimplePlayback.Start:
return { payload: reply }; return { payload: auxTimerService.start(index) };
case SimplePlayback.Pause:
return { payload: auxTimerService.pause(index) };
case SimplePlayback.Stop:
return { payload: auxTimerService.stop(index) };
default:
throw new Error('Invalid command');
} }
if (command === SimplePlayback.Pause) { }
const reply = auxTimerService.pause();
return { payload: reply }; // 2. command can be a patch object: duration, addtime, direction
} if (command && typeof command === 'object') {
if (command === SimplePlayback.Stop) {
const reply = auxTimerService.stop();
return { payload: reply };
}
} else if (command && typeof command === 'object') {
const reply = { payload: {} };
if ('duration' in command) { if ('duration' in command) {
const timeInMs = numberOrError(command.duration); return { payload: auxTimerService.setTime(numberOrError(command.duration), index) };
reply.payload = auxTimerService.setTime(timeInMs);
} }
if ('addtime' in command) { if ('addtime' in command) {
const timeInMs = numberOrError(command.addtime); return { payload: auxTimerService.addTime(numberOrError(command.addtime), index) };
reply.payload = auxTimerService.addTime(timeInMs);
} }
if ('direction' in command) { if ('direction' in command) {
if (command.direction === SimpleDirection.CountUp || command.direction === SimpleDirection.CountDown) { if (command.direction === SimpleDirection.CountUp || command.direction === SimpleDirection.CountDown) {
reply.payload = auxTimerService.setDirection(command.direction); return { payload: auxTimerService.setDirection(command.direction, index) };
} else {
throw new Error('Invalid direction payload');
} }
} throw new Error('Invalid direction payload');
if (!isEmptyObject(reply.payload)) {
return reply;
} }
} }
throw new Error('No matching method provided'); throw new Error('No matching method provided');
+12
View File
@@ -193,6 +193,18 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
playback: SimplePlayback.Stop, playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown, direction: SimpleDirection.CountDown,
}, },
auxtimer2: {
duration: timerConfig.auxTimerDefault,
current: timerConfig.auxTimerDefault,
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
},
auxtimer3: {
duration: timerConfig.auxTimerDefault,
current: timerConfig.auxTimerDefault,
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
},
ping: -1, ping: -1,
}); });
@@ -1,91 +1,165 @@
import { SimpleDirection, SimplePlayback, SimpleTimerState } from 'ontime-types'; import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js'; import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js';
import { eventStore } from '../../stores/EventStore.js'; import { eventStore } from '../../stores/EventStore.js';
import { timerConfig } from '../../setup/config.js'; import { timerConfig } from '../../setup/config.js';
type EmitFn = (state: SimpleTimerState) => void; type AuxTimerStateUpdate = Partial<Pick<RuntimeStore, 'auxtimer1' | 'auxtimer2' | 'auxtimer3'>>;
type EmitFn = (state: AuxTimerStateUpdate) => void;
type GetTimeFn = () => number; type GetTimeFn = () => number;
export class AuxTimerService { export class AuxTimerService {
private timer: SimpleTimer; private aux1: SimpleTimer;
private aux2: SimpleTimer;
private aux3: SimpleTimer;
private interval: NodeJS.Timeout | null = null; private interval: NodeJS.Timeout | null = null;
private emit: EmitFn; protected emit: EmitFn;
private getTime: GetTimeFn; private getTime: GetTimeFn;
constructor(emit: EmitFn, getTime: GetTimeFn) { constructor(emit: EmitFn, getTime: GetTimeFn) {
this.timer = new SimpleTimer(timerConfig.auxTimerDefault); this.aux1 = new SimpleTimer(timerConfig.auxTimerDefault);
this.aux2 = new SimpleTimer(timerConfig.auxTimerDefault);
this.aux3 = new SimpleTimer(timerConfig.auxTimerDefault);
this.emit = emit; this.emit = emit;
this.getTime = getTime; this.getTime = getTime;
} }
/**
* Whether any of the aux timers are currently running
*/
private hasActiveTimers(): boolean {
return (
this.aux1.state.playback === SimplePlayback.Start ||
this.aux2.state.playback === SimplePlayback.Start ||
this.aux3.state.playback === SimplePlayback.Start
);
}
private startInterval() { private startInterval() {
this.interval = setInterval(this.update.bind(this), 500); if (!this.interval) {
this.interval = setInterval(this.update.bind(this), 500);
}
} }
/**
* Utility simplifies guarding against multiple intervals being set
*/
private stopInterval() { private stopInterval() {
if (this.interval) { if (this.interval && !this.hasActiveTimers()) {
clearInterval(this.interval); clearInterval(this.interval);
this.interval = null;
} }
} }
@broadcastReturn @broadcastReturn
setDirection(direction: SimpleDirection) { setDirection(direction: SimpleDirection, index: number) {
return this.timer.setDirection(direction, this.getTime()); if (index === 1) return this.aux1.setDirection(direction, this.getTime());
if (index === 2) return this.aux2.setDirection(direction, this.getTime());
return this.aux3.setDirection(direction, this.getTime());
} }
@broadcastReturn @broadcastReturn
start() { start(index: number) {
this.startInterval(); this.startInterval();
return this.timer.start(this.getTime()); if (index === 1) return this.aux1.start(this.getTime());
if (index === 2) return this.aux2.start(this.getTime());
return this.aux3.start(this.getTime());
} }
@broadcastReturn @broadcastReturn
pause() { pause(index: number) {
this.stopInterval(); // First pause the timer
return this.timer.pause(this.getTime()); let result;
} if (index === 1) result = this.aux1.pause(this.getTime());
else if (index === 2) result = this.aux2.pause(this.getTime());
else result = this.aux3.pause(this.getTime());
@broadcastReturn // Then check if we need to keep the interval running
stop() { if (!this.hasActiveTimers()) {
this.stopInterval(); this.stopInterval();
return this.timer.stop();
}
@broadcastReturn
setTime(duration: number) {
return this.timer.setTime(duration);
}
@broadcastReturn
addTime(millis: number) {
if (this.timer.state.playback === SimplePlayback.Start) {
this.timer.addTime(millis);
return this.timer.update(this.getTime());
} }
return this.timer.addTime(millis);
return result;
} }
@broadcastReturn @broadcastReturn
stop(index: number) {
// First stop the timer
let result;
if (index === 1) result = this.aux1.stop();
else if (index === 2) result = this.aux2.stop();
else result = this.aux3.stop();
// Then check if we need to keep the interval running
if (!this.hasActiveTimers()) {
this.stopInterval();
}
return result;
}
@broadcastReturn
setTime(duration: number, index: number) {
if (index === 1) return this.aux1.setTime(duration);
if (index === 2) return this.aux2.setTime(duration);
return this.aux3.setTime(duration);
}
@broadcastReturn
addTime(millis: number, index: number) {
const aux = index === 1 ? this.aux1 : index === 2 ? this.aux2 : this.aux3;
if (aux.state.playback === SimplePlayback.Start) {
aux.addTime(millis);
return aux.update(this.getTime());
}
return aux.addTime(millis);
}
private update() { private update() {
return this.timer.update(this.getTime()); /**
* The update function affects any running timers,
* so we decide to emit a patch object rather
* than using the decorator which would emit individual updates.
*/
const patch: AuxTimerStateUpdate = {};
const timeNow = this.getTime();
if (this.aux1.state.playback === SimplePlayback.Start) {
patch.auxtimer1 = this.aux1.update(timeNow);
}
if (this.aux2.state.playback === SimplePlayback.Start) {
patch.auxtimer2 = this.aux2.update(timeNow);
}
if (this.aux3.state.playback === SimplePlayback.Start) {
patch.auxtimer3 = this.aux3.update(timeNow);
}
if (Object.keys(patch).length > 0) {
this.emit(patch);
}
} }
} }
function broadcastReturn(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) { function broadcastReturn(_target: object, _propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value; const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) { descriptor.value = function (this: AuxTimerService, ...args: unknown[]) {
const result = originalMethod.apply(this, args); const result = originalMethod.apply(this, args);
// @ts-expect-error -- we can access private properties from the decorator const index = args[args.length - 1] as number;
(this as AuxTimerService).emit(result); this.emit({ [`auxtimer${index}`]: result });
return result; return result;
}; };
return descriptor; return descriptor;
} }
const emit = (state: SimpleTimerState) => eventStore.set('auxtimer1', state); const emit = (state: AuxTimerStateUpdate) => {
for (const [key, value] of Object.entries(state)) {
eventStore.set(key as keyof RuntimeStore, value);
}
};
const timeNow = () => Date.now(); const timeNow = () => Date.now();
export const auxTimerService = new AuxTimerService(emit, timeNow); export const auxTimerService = new AuxTimerService(emit, timeNow);
@@ -33,7 +33,7 @@ export function validateTimerMessage(message: unknown): Partial<TimerMessage> {
* Asserts that the secondary value is one of the permitted values * Asserts that the secondary value is one of the permitted values
*/ */
function assertSecondary(source: unknown): source is TimerMessage['secondarySource'] { function assertSecondary(source: unknown): source is TimerMessage['secondarySource'] {
return source === 'aux' || source === 'secondary' || source === null; return source === 'aux1' || source === 'aux2' || source === 'aux3' || source === 'secondary' || source === null;
} }
/** /**
+14 -13
View File
@@ -2,17 +2,18 @@ import { test, expect } from '@playwright/test';
test('Aux timer buttons', async ({ page }) => { test('Aux timer buttons', async ({ page }) => {
await page.goto('http://localhost:4001/editor'); await page.goto('http://localhost:4001/editor');
await page.getByTestId('time-input-auxTimer').click(); await page.getByTestId('time-input-aux1').click();
await page.getByTestId('time-input-auxTimer').fill('123456'); await page.getByTestId('time-input-aux1').fill('123456');
await page.getByTestId('time-input-auxTimer').press('Enter'); await page.getByTestId('time-input-aux1').press('Enter');
await expect(page.getByTestId('time-input-auxTimer')).toHaveValue('12:34:56'); await expect(page.getByTestId('time-input-aux1')).toHaveValue('12:34:56');
await page.getByTestId('aux-timer-start').click(); await page.getByTestId('aux-timer-start-1').click();
await expect(page.getByTestId('time-input-auxTimer')).toHaveValue('12:34:53', { timeout: 4000 }); await expect(page.getByTestId('time-input-aux1')).toHaveValue('12:34:53', { timeout: 4000 });
await page.getByTestId('aux-timer-pause').click(); await page.getByTestId('aux-timer-pause-1').click();
await expect(page.getByTestId('time-input-auxTimer')).toHaveValue('12:34:53'); await expect(page.getByTestId('time-input-aux1')).toHaveValue('12:34:53');
await page.getByTestId('aux-timer-stop').click(); await page.getByTestId('aux-timer-stop-1').click();
await expect(page.getByTestId('time-input-auxTimer')).toHaveValue('12:34:56'); await expect(page.getByTestId('time-input-aux1')).toHaveValue('12:34:56');
await page.getByTestId('aux-timer-direction').click(); await page.getByTestId('aux-timer-direction-1').click();
await page.getByTestId('aux-timer-start').click(); await page.getByTestId('aux-timer-start-1').click();
await expect(page.getByTestId('time-input-auxTimer')).toHaveValue('12:34:59', { timeout: 4000 }); await expect(page.getByTestId('time-input-aux1')).toHaveValue('12:34:59', { timeout: 4000 });
await page.getByTestId('aux-timer-stop-1').click();
}); });
@@ -57,11 +57,20 @@ export type HTTPOutput = {
export type OntimeAction = export type OntimeAction =
| { | {
type: 'ontime'; type: 'ontime';
action: 'aux-start' | 'aux-stop' | 'aux-pause'; action:
| 'aux1-start'
| 'aux1-stop'
| 'aux1-pause'
| 'aux2-start'
| 'aux2-stop'
| 'aux2-pause'
| 'aux3-start'
| 'aux3-stop'
| 'aux3-pause';
} }
| { | {
type: 'ontime'; type: 'ontime';
action: 'aux-set'; action: 'aux1-set' | 'aux2-set' | 'aux3-set';
time: number; time: number;
} }
| { | {
@@ -1,4 +1,4 @@
export type SecondarySource = 'aux' | 'secondary' | null; export type SecondarySource = 'aux1' | 'aux2' | 'aux3' | 'secondary' | null;
export type TimerMessage = { export type TimerMessage = {
text: string; text: string;
@@ -52,5 +52,17 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
duration: 0, duration: 0,
playback: SimplePlayback.Stop, playback: SimplePlayback.Stop,
}, },
auxtimer2: {
current: 0,
direction: SimpleDirection.CountUp,
duration: 0,
playback: SimplePlayback.Stop,
},
auxtimer3: {
current: 0,
direction: SimpleDirection.CountUp,
duration: 0,
playback: SimplePlayback.Stop,
},
ping: -1, ping: -1,
}; };
@@ -22,6 +22,8 @@ export type RuntimeStore = {
// extra timers // extra timers
auxtimer1: SimpleTimerState; auxtimer1: SimpleTimerState;
auxtimer2: SimpleTimerState;
auxtimer3: SimpleTimerState;
// utils // utils
ping: number; ping: number;