mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-19 22:24:11 +00:00
feat: implement multiple aux timers
This commit is contained in:
committed by
Carlos Valente
parent
d1c58712ae
commit
bf8ed8d017
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ export function Label({ children, className, ...elementProps }: LabelHTMLAttribu
|
||||
);
|
||||
}
|
||||
|
||||
export function Separator({ className, ...elementProps }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cx([style.separator, className])} {...elementProps} />;
|
||||
interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
|
||||
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';
|
||||
|
||||
@@ -9,6 +9,18 @@ interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
height?: 'medium' | 'large';
|
||||
}
|
||||
|
||||
export default function Input({ className, variant = 'subtle', height = 'medium', ...inputProps }: InputProps) {
|
||||
return <input type='text' className={cx([style.input, style[variant], style[height], className])} {...inputProps} />;
|
||||
}
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ 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 {
|
||||
letter-spacing: 1px;
|
||||
width: 6.5em;
|
||||
width: 100%;
|
||||
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 { 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<T extends string> {
|
||||
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<T extends string>(props: TimeInputProps<T>) {
|
||||
*/
|
||||
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<T extends string>(props: TimeInputProps<T>) {
|
||||
<Input
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
size='sm'
|
||||
ref={inputRef}
|
||||
data-testid={`time-input-${name}`}
|
||||
className={className}
|
||||
fontSize='1rem'
|
||||
type='text'
|
||||
className={cx([style.timeInput, className])}
|
||||
placeholder={placeholder}
|
||||
variant='ontime-filled'
|
||||
onFocus={handleFocus}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={onBlurHandler}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
value={value}
|
||||
maxLength={8}
|
||||
maxWidth='7.5em'
|
||||
letterSpacing='1px'
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
MessageState,
|
||||
OntimeEvent,
|
||||
ProjectData,
|
||||
Runtime,
|
||||
Settings,
|
||||
SimpleTimerState,
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
import { MessageState, OntimeEvent, ProjectData, Runtime, Settings, ViewSettings } from 'ontime-types';
|
||||
|
||||
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
@@ -22,7 +14,6 @@ import StudioTimers from './StudioTimers';
|
||||
import './Studio.scss';
|
||||
|
||||
interface StudioProps {
|
||||
auxTimer: SimpleTimerState;
|
||||
eventNow: OntimeEvent | null;
|
||||
eventNext: OntimeEvent | null;
|
||||
general: ProjectData;
|
||||
@@ -36,7 +27,6 @@ interface StudioProps {
|
||||
}
|
||||
|
||||
export default function Studio({
|
||||
auxTimer,
|
||||
eventNow,
|
||||
eventNext,
|
||||
general,
|
||||
@@ -70,7 +60,6 @@ export default function Studio({
|
||||
<StudioTimers
|
||||
eventNow={eventNow}
|
||||
eventNext={eventNext}
|
||||
auxTimer={auxTimer.current}
|
||||
timerMessage={message.timer.visible ? message.timer.text : ''}
|
||||
secondaryMessage={message.secondary}
|
||||
runtime={runtime}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { OntimeEvent, Playback, Runtime, TimerPhase, TimerState, ViewSettings } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import { useAuxTimersTime } from '../../common/hooks/useSocket';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
import { getTimerColour } from '../utils/presentation.utils';
|
||||
@@ -14,7 +15,6 @@ interface StudioTimersProps {
|
||||
time: TimerState;
|
||||
eventNow: OntimeEvent | null;
|
||||
eventNext: OntimeEvent | null;
|
||||
auxTimer: number;
|
||||
timerMessage: string;
|
||||
secondaryMessage: string;
|
||||
viewSettings: ViewSettings;
|
||||
@@ -25,7 +25,6 @@ export default function StudioTimers({
|
||||
time,
|
||||
eventNow,
|
||||
eventNext,
|
||||
auxTimer,
|
||||
timerMessage,
|
||||
secondaryMessage,
|
||||
viewSettings,
|
||||
@@ -35,7 +34,6 @@ export default function StudioTimers({
|
||||
const schedule = getFormattedScheduleTimes(runtime);
|
||||
const event = getFormattedEventData(eventNow, time);
|
||||
const eventNextTitle = eventNext?.title || '-';
|
||||
const formattedAuxTimer = millisToString(auxTimer);
|
||||
const formattedTimerMessage = timerMessage || '-';
|
||||
const formattedSecondaryMessage = secondaryMessage || '-';
|
||||
|
||||
@@ -110,24 +108,7 @@ export default function StudioTimers({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='card' id='card-aux'>
|
||||
<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>
|
||||
<StudioTimersAux />
|
||||
|
||||
<div className='card' id='card-timer-message'>
|
||||
<div>
|
||||
@@ -145,3 +126,28 @@ export default function StudioTimers({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import {
|
||||
CustomFields,
|
||||
MessageState,
|
||||
OntimeEvent,
|
||||
ProjectData,
|
||||
Settings,
|
||||
SimpleTimerState,
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
import { CustomFields, MessageState, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { FitText } from '../../common/components/fit-text/FitText';
|
||||
import MultiPartProgressBar from '../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||
import TitleCard from '../../common/components/title-card/TitleCard';
|
||||
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useAuxTimersTime } from '../../common/hooks/useSocket';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
@@ -38,7 +31,6 @@ import {
|
||||
import './Timer.scss';
|
||||
|
||||
interface TimerProps {
|
||||
auxTimer: SimpleTimerState;
|
||||
customFields: CustomFields;
|
||||
eventNext: OntimeEvent | null;
|
||||
eventNow: OntimeEvent | null;
|
||||
@@ -51,8 +43,8 @@ interface TimerProps {
|
||||
}
|
||||
|
||||
export default function Timer(props: TimerProps) {
|
||||
const { auxTimer, customFields, eventNow, eventNext, general, isMirrored, message, settings, time, viewSettings } =
|
||||
props;
|
||||
const { customFields, eventNow, eventNext, general, isMirrored, message, settings, time, viewSettings } = props;
|
||||
const auxTimer = useAuxTimersTime();
|
||||
|
||||
const {
|
||||
hideClock,
|
||||
@@ -104,9 +96,22 @@ export default function Timer(props: TimerProps) {
|
||||
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(
|
||||
message,
|
||||
auxTimer.current,
|
||||
currentAux,
|
||||
localisedMinutes,
|
||||
hideTimerSeconds,
|
||||
removeLeadingZeros,
|
||||
|
||||
@@ -116,7 +116,11 @@ export function getSecondaryDisplay(
|
||||
if (hideSecondary) {
|
||||
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, {
|
||||
removeSeconds,
|
||||
removeLeadingZero,
|
||||
|
||||
Reference in New Issue
Block a user