feat: allow naming aux timers

This commit is contained in:
Carlos Valente
2026-08-25 21:45:58 +02:00
parent b9683f00dc
commit be99fbdcb9
36 changed files with 482 additions and 16 deletions
+19
View File
@@ -95,6 +95,22 @@ export const useAuxTimersTime = createSelector((state: RuntimeStore) => {
};
});
export const useAuxTimersName = createSelector((state: RuntimeStore) => {
return {
aux1: state.auxtimer1.name,
aux2: state.auxtimer2.name,
aux3: state.auxtimer3.name,
};
});
export const useAuxTimersActive = createSelector((state: RuntimeStore) => {
return (
state.auxtimer1.playback === SimplePlayback.Start ||
state.auxtimer2.playback === SimplePlayback.Start ||
state.auxtimer3.playback === SimplePlayback.Start
);
});
export const useAuxTimerTime = (index: number) =>
createSelector((state: RuntimeStore) => {
if (index === 1) return state.auxtimer1.current;
@@ -108,15 +124,18 @@ export const useAuxTimerControl = (index: number) =>
return {
playback: state.auxtimer1.playback,
direction: state.auxtimer1.direction,
name: state.auxtimer1.name,
};
if (index === 2)
return {
playback: state.auxtimer2.playback,
direction: state.auxtimer2.direction,
name: state.auxtimer2.name,
};
return {
playback: state.auxtimer3.playback,
direction: state.auxtimer3.direction,
name: state.auxtimer3.name,
};
})();
@@ -6,4 +6,5 @@ export const ontimePlaceholderSettings: Settings = {
operatorKey: null,
timeFormat: '24',
language: 'en',
auxTimerNames: ['', '', ''],
};
@@ -13,6 +13,7 @@ type EditorSettingsStore = {
defaultTimerType: TimerType;
defaultEndAction: EndAction;
inheritGroupColour: boolean;
auxTimersCollapsed: boolean;
setDefaultDuration: (defaultDuration: string) => void;
setLinkPrevious: (linkPrevious: boolean) => void;
setInheritGroupColour: (inheritGroupColour: boolean) => void;
@@ -21,6 +22,7 @@ type EditorSettingsStore = {
setDangerTime: (dangerTime: string) => void;
setDefaultTimerType: (defaultTimerType: TimerType) => void;
setDefaultEndAction: (defaultEndAction: EndAction) => void;
setAuxTimersCollapsed: (auxTimersCollapsed: boolean) => void;
};
export const editorSettingsDefaults = {
@@ -32,6 +34,7 @@ export const editorSettingsDefaults = {
timerType: TimerType.CountDown,
endAction: EndAction.None,
inheritGroupColour: false,
auxTimersCollapsed: false,
};
enum EditorSettingsKeys {
@@ -43,6 +46,7 @@ enum EditorSettingsKeys {
DefaultTimerType = 'ontime-default-timer-type',
DefaultEndAction = 'ontime-default-end-action',
InheritGroupColour = 'ontime-inherit-group-colour',
AuxTimersCollapsed = 'ontime-aux-timers-collapsed',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => {
@@ -67,6 +71,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
EditorSettingsKeys.InheritGroupColour,
editorSettingsDefaults.inheritGroupColour,
),
auxTimersCollapsed: booleanFromLocalStorage(
EditorSettingsKeys.AuxTimersCollapsed,
editorSettingsDefaults.auxTimersCollapsed,
),
setDefaultDuration: (defaultDuration) =>
set(() => {
@@ -110,5 +118,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.InheritGroupColour, String(inheritGroupColour));
return { inheritGroupColour };
}),
setAuxTimersCollapsed: (auxTimersCollapsed) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.AuxTimersCollapsed, String(auxTimersCollapsed));
return { auxTimersCollapsed };
}),
};
});
@@ -0,0 +1,10 @@
export function getAuxTimerLabel(name: string | undefined, fallback: string): string {
const custom = name?.trim();
return custom ? custom : fallback;
}
/** Combines the aux timer's index with its custom name, eg. "Aux 1: Speaker" */
export function getAuxTimerIndexedLabel(name: string | undefined, index: number): string {
const custom = name?.trim();
return custom ? `Aux ${index}: ${custom}` : `Aux ${index}`;
}
@@ -0,0 +1,95 @@
import { Settings } from 'ontime-types';
import { auxTimerNameMaxLength } from 'ontime-utils';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { postSettings } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import useSettings from '../../../../common/hooks-query/useSettings';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
export default function AuxTimerSettings() {
const { data, status, refetch } = useSettings();
const {
handleSubmit,
register,
reset,
setError,
formState: { isSubmitting, isDirty, errors },
} = useForm<Settings>({
defaultValues: data,
resetOptions: {
keepDirtyValues: true,
},
});
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const onSubmit = async (formData: Settings) => {
try {
await postSettings(formData);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
} finally {
await refetch();
}
};
const onReset = () => {
reset(data);
};
const isLoading = status === 'pending';
return (
<Panel.Section
as='form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
id='aux-timer-settings'
>
<Panel.Card>
<Panel.SubHeader>
Aux timers
<Panel.InlineElements>
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
Revert to saved
</Button>
<Button type='submit' loading={isSubmitting} disabled={!isDirty} variant='primary'>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Info>Give the aux timers custom names. Names are shown across the editor controls and views.</Info>
<Panel.Loader isLoading={isLoading} />
<Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Aux timer 1' description='Custom name for aux timer 1' />
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 1' {...register('auxTimerNames.0')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Aux timer 2' description='Custom name for aux timer 2' />
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 2' {...register('auxTimerNames.1')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Aux timer 3' description='Custom name for aux timer 3' />
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 3' {...register('auxTimerNames.2')} />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -3,6 +3,7 @@ import { isDocker } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import CustomViews from '../manage-panel/CustomViews';
import AuxTimerSettings from './AuxTimerSettings';
import GeneralSettings from './GeneralSettings';
import McpSection from './McpSection';
import ProjectData from './ProjectData';
@@ -12,6 +13,7 @@ import ViewSettings from './ViewSettings';
export default function SettingsPanel({ location }: PanelBaseProps) {
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
const auxTimersRef = useScrollIntoView<HTMLDivElement>('aux-timers', location);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
const mcpRef = useScrollIntoView<HTMLDivElement>('mcp', location);
@@ -26,6 +28,9 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
<div ref={generalRef}>
<GeneralSettings />
</div>
<div ref={auxTimersRef}>
<AuxTimerSettings />
</div>
<div ref={viewRef}>
<ViewSettings />
</div>
@@ -24,6 +24,7 @@ const staticOptions = [
label: 'General settings',
keywords: ['pin', 'password', 'lock', 'language', 'time format', 'timezone'],
},
{ id: 'settings__aux-timers', label: 'Aux timers', keywords: ['aux', 'auxiliary'] },
{
id: 'settings__view',
label: 'View settings',
@@ -3,6 +3,34 @@
margin: 0 auto;
}
.auxHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 1rem;
}
.label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: $inner-section-text-size;
color: $label-gray;
}
.auxHeaderButtons {
display: flex;
align-items: center;
gap: 0.25rem;
}
.activeIndicator {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: $active-indicator;
}
.auxTimers {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
@@ -1,4 +1,10 @@
import { usePlaybackControl } from '../../../common/hooks/useSocket';
import { IoChevronDown, IoChevronUp, IoSettingsOutline } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useAuxTimersActive, usePlaybackControl } from '../../../common/hooks/useSocket';
import { useEditorSettings } from '../../../common/stores/editorSettings';
import useAppSettingsNavigation from '../../app-settings/useAppSettingsNavigation';
import AddTime from './add-time/AddTime';
import { AuxTimer } from './aux-timer/AuxTimer';
import PlaybackButtons from './playback-buttons/PlaybackButtons';
@@ -8,6 +14,9 @@ import style from './PlaybackControl.module.scss';
export default function PlaybackControl() {
const data = usePlaybackControl();
const { setLocation } = useAppSettingsNavigation();
const { auxTimersCollapsed, setAuxTimersCollapsed } = useEditorSettings();
const isAuxTimerActive = useAuxTimersActive();
return (
<div className={style.mainContainer}>
@@ -20,11 +29,42 @@ export default function PlaybackControl() {
selectedEventIndex={data.selectedEventIndex}
timerPhase={data.timerPhase}
/>
<div className={style.auxTimers}>
<AuxTimer index={1} />
<AuxTimer index={2} />
<AuxTimer index={3} />
<div className={style.auxHeader}>
<span className={style.label}>
Aux timers
{auxTimersCollapsed && isAuxTimerActive && <span className={style.activeIndicator} />}
</span>
<div className={style.auxHeaderButtons}>
<Tooltip
text='Name aux timers'
render={
<IconButton
size='small'
variant='subtle-white'
aria-label='Name aux timers'
onClick={() => setLocation('settings__aux-timers')}
/>
}
>
<IoSettingsOutline />
</Tooltip>
<IconButton
size='small'
variant='subtle-white'
aria-label={auxTimersCollapsed ? 'Expand aux timers' : 'Collapse aux timers'}
onClick={() => setAuxTimersCollapsed(!auxTimersCollapsed)}
>
{auxTimersCollapsed ? <IoChevronUp /> : <IoChevronDown />}
</IconButton>
</div>
</div>
{!auxTimersCollapsed && (
<div className={style.auxTimers}>
<AuxTimer index={1} />
<AuxTimer index={2} />
<AuxTimer index={3} />
</div>
)}
</div>
);
}
@@ -1,8 +1,18 @@
.label {
display: block;
margin-top: 1rem;
// aux timers sit in a 3 column grid, without this a long name would grow its column
// instead of shrinking to it, breaking the equal column layout
min-width: 0;
}
.labelText {
display: block;
font-size: $inner-section-text-size;
color: $label-gray;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.controls {
@@ -3,7 +3,9 @@ import { millisToString, parseUserTime } from 'ontime-utils';
import { IoArrowDown, IoArrowUp, IoPause, IoPlay, IoStop } from 'react-icons/io5';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { setAuxTimer, useAuxTimerControl, useAuxTimerTime } from '../../../../common/hooks/useSocket';
import { getAuxTimerIndexedLabel } from '../../../../common/utils/auxTimerUtils';
import TapButton from '../tap-button/TapButton';
import style from './AuxTimer.module.scss';
@@ -13,10 +15,12 @@ interface AuxTimerProps {
}
export function AuxTimer({ index }: AuxTimerProps) {
const { playback, direction } = useAuxTimerControl(index);
const { playback, direction, name } = useAuxTimerControl(index);
const { stop, setDirection } = setAuxTimer;
const label = getAuxTimerIndexedLabel(name, index);
const toggleDirection = () => {
const newDirection = direction === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown;
setDirection(index, newDirection);
@@ -27,10 +31,12 @@ export function AuxTimer({ index }: AuxTimerProps) {
return (
<label className={style.label}>
Aux Timer {index}
<Tooltip text={label} render={<span />} className={style.labelText}>
{label}
</Tooltip>
<div className={style.controls}>
<div className={style.input}>
<AuxTimerInput index={index} isActive={isActive} />
<AuxTimerInput index={index} isActive={isActive} placeholder={`Aux ${index}`} />
<TapButton onClick={toggleDirection} aspect='tight' disabled={isActive}>
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid={`aux-timer-direction-${index}`} />}
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid={`aux-timer-direction-${index}`} />}
@@ -50,9 +56,10 @@ export function AuxTimer({ index }: AuxTimerProps) {
interface AuxTimerInputProps {
index: number;
isActive: boolean;
placeholder: string;
}
function AuxTimerInput({ index, isActive }: AuxTimerInputProps) {
function AuxTimerInput({ index, isActive, placeholder }: AuxTimerInputProps) {
const newTimeInMs = useAuxTimerTime(index);
const { setDuration } = setAuxTimer;
@@ -70,7 +77,7 @@ function AuxTimerInput({ index, isActive }: AuxTimerInputProps) {
}
return (
<TimeInput submitHandler={handleTimeUpdate} name={`aux${index}`} time={newTimeInMs} placeholder={`Aux ${index}`} />
<TimeInput submitHandler={handleTimeUpdate} name={`aux${index}`} time={newTimeInMs} placeholder={placeholder} />
);
}
@@ -1,7 +1,8 @@
import { Playback, TimerPhase, ViewSettings } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { useAuxTimersTime, useStudioTimersSocket } from '../../common/hooks/useSocket';
import { useAuxTimersName, useAuxTimersTime, useStudioTimersSocket } from '../../common/hooks/useSocket';
import { getAuxTimerLabel } from '../../common/utils/auxTimerUtils';
import { getOffsetState } from '../../common/utils/offset';
import { cx } from '../../common/utils/styleUtils';
import { useTranslation } from '../../translation/TranslationProvider';
@@ -121,22 +122,23 @@ export default function StudioTimers({ viewSettings }: StudioTimersProps) {
function StudioTimersAux() {
const auxTimer = useAuxTimersTime();
const auxName = useAuxTimersName();
return (
<div className='card' id='card-aux'>
<div className='card__row'>
<div>
<div className='label'>Aux 1</div>
<div className='label'>{getAuxTimerLabel(auxName.aux1, 'Aux 1')}</div>
<div className='extra'>{millisToString(auxTimer.aux1)}</div>
</div>
<div>
<div className='label center'>Aux 2</div>
<div className='label center'>{getAuxTimerLabel(auxName.aux2, 'Aux 2')}</div>
<div className='extra center'>{millisToString(auxTimer.aux2)}</div>
</div>
<div>
<div className='label right'>Aux 3</div>
<div className='label right'>{getAuxTimerLabel(auxName.aux3, 'Aux 3')}</div>
<div className='extra right'>{millisToString(auxTimer.aux3)}</div>
</div>
</div>