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,17 +42,25 @@ export default function OntimeActionForm(props: PropsWithChildren<OntimeActionFo
value={selectedAction}
onChange={(event) => updateSelectedAction(event.target.value)}
>
<option value='aux-start'>Auxiliary timer: start</option>
<option value='aux-pause'>Auxiliary timer: pause</option>
<option value='aux-stop'>Auxiliary timer: stop</option>
<option value='aux-set'>Auxiliary timer: set</option>
<option value='aux-start'>Aux 1: start</option>
<option value='aux-pause'>Aux 1: pause</option>
<option value='aux-stop'>Aux 1: stop</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-secondary'>Timer: timer secondary</option>
</Select>
<Panel.Error>{rowErrors?.action?.message}</Panel.Error>
</label>
{selectedAction === 'aux-set' && (
{selectedAction === 'aux1-set' && (
<label>
New time
<Input
@@ -10,7 +10,7 @@
.previewContainer {
display: grid;
gap: $element-spacing;
grid-template-columns: 2fr 1fr;
grid-template-columns: 3fr 2fr;
}
.preview {
@@ -39,6 +39,7 @@
.mainContent {
font-size: 1rem;
font-weight: 600;
width: 100%;
color: var(--override-colour, $ui-white);
&[data-phase='pending'] {
@@ -72,7 +73,3 @@
color: $active-indicator;
}
}
.divider {
border-top: 1px solid $gray-1000;
}
@@ -1,12 +1,11 @@
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import IconButton from '../../../common/components/buttons/IconButton';
import {
setMessage,
useExternalMessageInput as useSecondaryMessageInput,
useTimerMessageInput,
} from '../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../ontimeConfig';
import InputRow from './InputRow';
import TimerControlsPreview from './TimerViewControl';
@@ -32,15 +31,13 @@ function TimerMessageInput() {
visible={visible}
changeHandler={(newValue) => setMessage.timerText(newValue)}
>
<TooltipActionBtn
clickHandler={() => setMessage.timerVisible(!visible)}
tooltip={visible ? 'Make invisible' : 'Make visible'}
<IconButton
aria-label='Toggle timer message visibility'
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
onClick={() => setMessage.timerVisible(!visible)}
variant={visible ? 'primary' : 'subtle'}
>
{visible ? <IoEye /> : <IoEyeOffOutline />}
</IconButton>
</InputRow>
);
}
@@ -50,9 +47,9 @@ function SecondaryInput() {
const toggleSecondary = () => {
if (visible) {
setMessage.timerSecondary(null);
setMessage.timerSecondarySource(null);
} else {
setMessage.timerSecondary('secondary');
setMessage.timerSecondarySource('secondary');
}
};
@@ -64,15 +61,13 @@ function SecondaryInput() {
visible={visible}
changeHandler={(newValue) => setMessage.secondaryMessage(newValue)}
>
<TooltipActionBtn
clickHandler={toggleSecondary}
tooltip={visible ? 'Make invisible' : 'Make visible'}
<IconButton
aria-label='Toggle secondary message visibility'
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
onClick={toggleSecondary}
variant={visible ? 'primary' : 'subtle'}
>
{visible ? <IoEye /> : <IoEyeOffOutline />}
</IconButton>
</InputRow>
);
}
@@ -11,12 +11,16 @@ import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './MessageControl.module.scss';
export default function TimerPreview() {
const { blink, blackout, countToEnd, phase, showAuxTimer, showSecondaryMessage, showTimerMessage, timerType } =
useMessagePreview();
const { data } = useViewSettings();
const secondarySourceLabels: Record<string, string> = {
aux1: 'Aux 1',
aux2: 'Aux 2',
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 = (() => {
if (showTimerMessage) return 'Message';
@@ -29,13 +33,11 @@ export default function TimerPreview() {
})();
const secondary = (() => {
// message is a fullscreen overlay
if (showTimerMessage) return null;
// 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
if (showAuxTimer) return 'Aux Timer';
if (showSecondaryMessage) return 'Secondary message';
return null;
return secondarySourceLabels[secondarySource];
})();
const overrideColour = (() => {
@@ -46,6 +48,7 @@ export default function TimerPreview() {
})();
const showColourOverride = main == 'Timer';
const contentClasses = cx([blink && style.blink, blackout && style.blackout]);
return (
<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 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';
@@ -8,50 +11,27 @@ import TimerPreview from './TimerPreview';
import style from './MessageControl.module.scss';
export default function TimerControlsPreview() {
const { blackout, blink, secondarySource } = useTimerViewControl();
const toggleSecondary = (newValue: SecondarySource) => {
if (secondarySource === newValue) {
setMessage.timerSecondary(null);
} else {
setMessage.timerSecondary(newValue);
}
};
const { blackout, blink } = useTimerViewControl();
return (
<div className={style.previewContainer}>
<TimerPreview />
<div className={style.options}>
<Button
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>
<SecondarySourceControl />
<hr className={style.divider} />
<Editor.Separator orientation='horizontal' />
<Button
size='sm'
variant={blink ? 'ontime-filled' : 'ontime-subtle'}
variant={blink ? 'primary' : 'subtle'}
fluid
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink
</Button>
<Button
size='sm'
className={style.blackoutButton}
variant={blackout ? 'ontime-filled' : 'ontime-subtle'}
variant={blackout ? 'primary' : 'subtle'}
fluid
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout'
>
@@ -61,3 +41,55 @@ export default function TimerControlsPreview() {
</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%;
margin: 0 auto;
}
.auxTimers {
display: flex;
gap: 0.5rem;
}
@@ -23,7 +23,11 @@ export default function PlaybackControl() {
selectedEventIndex={data.selectedEventIndex}
timerPhase={data.timerPhase}
/>
<AuxTimer />
<div className={style.auxTimers}>
<AuxTimer index={1} />
<AuxTimer index={2} />
<AuxTimer index={3} />
</div>
</div>
);
}
@@ -7,6 +7,18 @@
.controls {
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';
export function AuxTimer() {
const { playback, direction } = useAuxTimerControl();
interface AuxTimerProps {
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 newDirection = direction === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown;
setDirection(newDirection);
setDirection(index, newDirection);
};
const userCan = {
start: playback !== SimplePlayback.Start,
pause: playback === SimplePlayback.Start,
stop: playback !== SimplePlayback.Stop,
};
const canStop = playback !== SimplePlayback.Stop;
const playbackAction = playback === SimplePlayback.Start ? 'pause' : 'play';
return (
<label className={style.label}>
Auxiliary Timer
Aux Timer {index}
<div className={style.controls}>
<AuxTimerInput />
<TapButton onClick={toggleDirection} aspect='tight'>
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid='aux-timer-direction' />}
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid='aux-timer-direction' />}
</TapButton>
<TapButton
onClick={start}
theme={Playback.Play}
active={playback === SimplePlayback.Start}
disabled={!userCan.start}
>
<IoPlay data-testid='aux-timer-start' />
</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 className={style.input}>
<AuxTimerInput index={index} />
<TapButton onClick={toggleDirection} aspect='tight'>
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid={`aux-timer-direction-${index}`} />}
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid={`aux-timer-direction-${index}`} />}
</TapButton>
</div>
<div className={style.twoSides}>
<AuxTogglePlay index={index} action={playbackAction} />
<TapButton onClick={() => stop(index)} theme={Playback.Stop} disabled={!canStop}>
<IoStop data-testid={`aux-timer-stop-${index}`} />
</TapButton>
</div>
</div>
</label>
);
}
function AuxTimerInput() {
const newTimeInMs = useAuxTimerTime();
interface AuxTimerInput {
index: number;
}
function AuxTimerInput({ index }: AuxTimerProps) {
const newTimeInMs = useAuxTimerTime(index);
const { setDuration } = setAuxTimer;
const handleTimeUpdate = (_field: string, value: string) => {
const newTimeInMs = parseUserTime(value);
setDuration(newTimeInMs);
setDuration(index, newTimeInMs);
};
return (
<TimeInput<'auxTimer'>
submitHandler={handleTimeUpdate}
name='auxTimer'
time={newTimeInMs}
placeholder='Aux Timer 1'
/>
<TimeInput submitHandler={handleTimeUpdate} name={`aux${index}`} time={newTimeInMs} placeholder={`Aux ${index}`} />
);
}
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>
);
}