From bf8ed8d017b63426665111562123b1db4a6b9da8 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Tue, 24 Jun 2025 07:20:53 +0200 Subject: [PATCH] feat: implement multiple aux timers --- .../editor-utils/EditorUtils.module.scss | 12 +- .../components/editor-utils/EditorUtils.tsx | 8 +- .../common/components/input/input/Input.tsx | 20 ++- .../input/time-input/TimeInput.module.scss | 7 +- .../components/input/time-input/TimeInput.tsx | 22 +-- .../components/select/Select.module.scss | 128 +++++++++++++++ .../src/common/components/select/Select.tsx | 53 +++++++ apps/client/src/common/hooks/useSocket.ts | 51 ++++-- .../automations-panel/OntimeActionForm.tsx | 18 ++- .../message/MessageControl.module.scss | 7 +- .../control/message/MessageControl.tsx | 35 ++-- .../features/control/message/TimerPreview.tsx | 23 +-- .../control/message/TimerViewControl.tsx | 94 +++++++---- .../playback/PlaybackControl.module.scss | 5 + .../control/playback/PlaybackControl.tsx | 6 +- .../playback/aux-timer/AuxTimer.module.scss | 16 +- .../control/playback/aux-timer/AuxTimer.tsx | 99 +++++++----- apps/client/src/views/studio/Studio.tsx | 13 +- apps/client/src/views/studio/StudioTimers.tsx | 48 +++--- apps/client/src/views/timer/Timer.tsx | 31 ++-- apps/client/src/views/timer/timer.utils.ts | 6 +- .../__tests__/automation.validation.test.ts | 4 +- .../automation/automation.validation.ts | 12 +- .../automation/clients/ontime.client.ts | 38 +++-- .../api-integration/integration.controller.ts | 69 ++++---- apps/server/src/app.ts | 12 ++ .../aux-timer-service/AuxTimerService.ts | 150 +++++++++++++----- .../services/message-service/message.utils.ts | 2 +- e2e/tests/features/208-auxtimer.spec.ts | 27 ++-- .../src/definitions/core/Automation.type.ts | 13 +- .../runtime/MessageControl.type.ts | 2 +- .../src/definitions/runtime/RuntimeStore.ts | 12 ++ .../definitions/runtime/RuntimeStore.type.ts | 2 + 33 files changed, 748 insertions(+), 297 deletions(-) create mode 100644 apps/client/src/common/components/select/Select.module.scss create mode 100644 apps/client/src/common/components/select/Select.tsx diff --git a/apps/client/src/common/components/editor-utils/EditorUtils.module.scss b/apps/client/src/common/components/editor-utils/EditorUtils.module.scss index 8438b2065..0a68e8dd1 100644 --- a/apps/client/src/common/components/editor-utils/EditorUtils.module.scss +++ b/apps/client/src/common/components/editor-utils/EditorUtils.module.scss @@ -42,7 +42,15 @@ } .separator { - width: 1px; - height: 0.75em; background-color: $border-color-ondark; + + &.horizontal { + width: 100%; + height: 1px; + } + + &.vertical { + width: 1px; + height: 0.75em; + } } diff --git a/apps/client/src/common/components/editor-utils/EditorUtils.tsx b/apps/client/src/common/components/editor-utils/EditorUtils.tsx index 6370ac32a..09c686fea 100644 --- a/apps/client/src/common/components/editor-utils/EditorUtils.tsx +++ b/apps/client/src/common/components/editor-utils/EditorUtils.tsx @@ -28,6 +28,10 @@ export function Label({ children, className, ...elementProps }: LabelHTMLAttribu ); } -export function Separator({ className, ...elementProps }: HTMLAttributes) { - return
; +interface SeparatorProps extends HTMLAttributes { + orientation?: 'horizontal' | 'vertical'; +} + +export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) { + return
; } diff --git a/apps/client/src/common/components/input/input/Input.tsx b/apps/client/src/common/components/input/input/Input.tsx index 4cf4224dd..c3f9e1524 100644 --- a/apps/client/src/common/components/input/input/Input.tsx +++ b/apps/client/src/common/components/input/input/Input.tsx @@ -1,4 +1,4 @@ -import { InputHTMLAttributes } from 'react'; +import { forwardRef, InputHTMLAttributes } from 'react'; import { cx } from '../../../utils/styleUtils'; @@ -9,6 +9,18 @@ interface InputProps extends InputHTMLAttributes { height?: 'medium' | 'large'; } -export default function Input({ className, variant = 'subtle', height = 'medium', ...inputProps }: InputProps) { - return ; -} +const Input = forwardRef(function Input( + { className, variant = 'subtle', height = 'medium', ...inputProps }, + ref, +) { + return ( + + ); +}); + +export default Input; diff --git a/apps/client/src/common/components/input/time-input/TimeInput.module.scss b/apps/client/src/common/components/input/time-input/TimeInput.module.scss index 1a30b2cd6..2d4cae896 100644 --- a/apps/client/src/common/components/input/time-input/TimeInput.module.scss +++ b/apps/client/src/common/components/input/time-input/TimeInput.module.scss @@ -1,4 +1,7 @@ .timeInput { - letter-spacing: 1px; - width: 6.5em; + width: 100%; + max-width: 7.5em; + letter-spacing: 1px; + font-size: 1rem; + font-variant-numeric: tabular-nums; } diff --git a/apps/client/src/common/components/input/time-input/TimeInput.tsx b/apps/client/src/common/components/input/time-input/TimeInput.tsx index 034550edd..1dcd16d5b 100644 --- a/apps/client/src/common/components/input/time-input/TimeInput.tsx +++ b/apps/client/src/common/components/input/time-input/TimeInput.tsx @@ -1,15 +1,18 @@ import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; -import { Input } from '@chakra-ui/react'; import { millisToString, parseUserTime } from 'ontime-utils'; import { useEmitLog } from '../../../stores/logger'; +import { cx } from '../../../utils/styleUtils'; +import Input from '../input/Input'; + +import style from './TimeInput.module.scss'; interface TimeInputProps { id?: T; name: T; submitHandler: (field: T, value: string) => void; time?: number; - placeholder: string; + placeholder?: string; disabled?: boolean; align?: 'left' | 'center'; className?: string; @@ -27,6 +30,9 @@ export default function TimeInput(props: TimeInputProps) { */ const resetValue = useCallback(() => { try { + if (typeof time !== 'number' || isNaN(time)) { + throw new Error(`Invalid time value: ${time}`); + } setValue(millisToString(time)); } catch (error) { setValue(millisToString(0)); @@ -121,24 +127,20 @@ export default function TimeInput(props: TimeInputProps) { setValue(event.target.value)} onBlur={onBlurHandler} onKeyDown={onKeyDownHandler} value={value} maxLength={8} - maxWidth='7.5em' - letterSpacing='1px' autoComplete='off' - textAlign={align} + style={{ + textAlign: align, + }} /> ); } diff --git a/apps/client/src/common/components/select/Select.module.scss b/apps/client/src/common/components/select/Select.module.scss new file mode 100644 index 000000000..597191ba8 --- /dev/null +++ b/apps/client/src/common/components/select/Select.module.scss @@ -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%; + } + } +} diff --git a/apps/client/src/common/components/select/Select.tsx b/apps/client/src/common/components/select/Select.tsx new file mode 100644 index 000000000..b9baa8290 --- /dev/null +++ b/apps/client/src/common/components/select/Select.tsx @@ -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 { + defaultValue?: T; + options: { + value: NonNullable; + label: string; + }[]; + placeholder?: string; + value?: T; + onChange?: (value: NonNullable) => void; +} + +export default function Select({ + defaultValue, + options, + placeholder, + value, + onChange, +}: SelectProps) { + return ( + + + + + + + + + + + + {options.map((option) => { + return ( + + + + + {option.label} + + ); + })} + + + + + + ); +} diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index a045c1353..b15390e11 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -40,8 +40,7 @@ export const useMessagePreview = createSelector((state: RuntimeStore) => ({ blink: state.message.timer.blink, blackout: state.message.timer.blackout, phase: state.timer.phase, - showAuxTimer: state.message.timer.secondarySource === 'aux', - showSecondaryMessage: state.message.timer.secondarySource === 'secondary' && Boolean(state.message.secondary), + 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, @@ -53,7 +52,7 @@ export const setMessage = { secondaryMessage: (payload: string) => sendSocket('message', { secondary: payload }), timerBlink: (payload: boolean) => sendSocket('message', { timer: { blink: payload } }), timerBlackout: (payload: boolean) => sendSocket('message', { timer: { blackout: payload } }), - timerSecondary: (payload: TimerMessage['secondarySource']) => + timerSecondarySource: (payload: TimerMessage['secondarySource']) => 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) => ({ - playback: state.auxtimer1.playback, - direction: state.auxtimer1.direction, -})); +export const useAuxTimerTime = (index: number) => + createSelector((state: RuntimeStore) => { + 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 = { - start: () => sendSocket('auxtimer', { '1': SimplePlayback.Start }), - pause: () => sendSocket('auxtimer', { '1': SimplePlayback.Pause }), - stop: () => sendSocket('auxtimer', { '1': SimplePlayback.Stop }), - setDirection: (direction: SimpleDirection) => sendSocket('auxtimer', { '1': { direction } }), - setDuration: (time: number) => sendSocket('auxtimer', { '1': { duration: time } }), + start: (index: number) => sendSocket('auxtimer', { [index]: SimplePlayback.Start }), + pause: (index: number) => sendSocket('auxtimer', { [index]: SimplePlayback.Pause }), + stop: (index: number) => sendSocket('auxtimer', { [index]: SimplePlayback.Stop }), + setDirection: (index: number, direction: SimpleDirection) => sendSocket('auxtimer', { [index]: { direction } }), + setDuration: (index: number, time: number) => sendSocket('auxtimer', { [index]: { duration: time } }), }; export const useSelectedEventId = createSelector((state: RuntimeStore) => ({ diff --git a/apps/client/src/features/app-settings/panel/automations-panel/OntimeActionForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/OntimeActionForm.tsx index 177760e65..21b10403f 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/OntimeActionForm.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/OntimeActionForm.tsx @@ -42,17 +42,25 @@ export default function OntimeActionForm(props: PropsWithChildren updateSelectedAction(event.target.value)} > - - - - + + + + + + + + + + + + {rowErrors?.action?.message} - {selectedAction === 'aux-set' && ( + {selectedAction === 'aux1-set' && (