From 95536902f50be424938e6bd317a3f7fd53399ebd Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:13:37 +0100 Subject: [PATCH] feat: Improve automation flow and add ontime playback actions (#2025) (#2024) * fix: prevent add filter from submitting form * refactor: event clarifies whether automations exist but are disabled * refactor: improve readability of automation form * refactor: add affordance for field warnings * refactor: improve visibility of automation off state * refactor: apply warning styles to other feature toggles * Adding playback actions to automations (#2024) * adding intial automation actions * cleaning up * fixing formatting * ran oxfmt * switching action names to playback- to match the dropdown strings * fixing flicker that was caused by scroll arrows gettting unmounted --------- Co-authored-by: Cameron Slipp --- .../components/select/Select.module.scss | 5 + .../src/common/components/select/Select.tsx | 4 +- .../src/common/components/tag/Tag.module.scss | 12 +- apps/client/src/common/components/tag/Tag.tsx | 7 +- .../panel-utils/PanelUtils.module.scss | 10 ++ .../app-settings/panel-utils/PanelUtils.tsx | 26 +++- .../automations-panel/AutomationForm.tsx | 2 +- .../automations-panel/AutomationPanel.tsx | 8 +- .../AutomationSettingsForm.tsx | 40 ++++-- .../automations-panel/AutomationsList.tsx | 11 +- .../automations-panel/OntimeActionForm.tsx | 5 + .../panel/automations-panel/TriggersList.tsx | 7 +- .../panel/settings-panel/ViewSettings.tsx | 19 ++- apps/client/src/features/rundown/Rundown.tsx | 17 ++- .../src/features/rundown/RundownEntry.tsx | 3 + .../composite/EventEditorTriggers.module.scss | 86 +++++++++++-- .../composite/EventEditorTriggers.tsx | 119 +++++++++++------- .../rundown-event/RundownEvent.module.scss | 4 + .../rundown/rundown-event/RundownEvent.tsx | 3 + .../rundown-event/RundownEventInner.tsx | 21 +++- .../automation/clients/ontime.client.ts | 11 ++ .../src/definitions/core/Automation.type.ts | 15 ++- 22 files changed, 346 insertions(+), 89 deletions(-) diff --git a/apps/client/src/common/components/select/Select.module.scss b/apps/client/src/common/components/select/Select.module.scss index 26bf9f129..9f261e89a 100644 --- a/apps/client/src/common/components/select/Select.module.scss +++ b/apps/client/src/common/components/select/Select.module.scss @@ -125,6 +125,11 @@ font-size: 0.5rem; display: grid; place-content: center; + visibility: hidden; + + &[data-visible] { + visibility: visible; + } &::before { content: ''; diff --git a/apps/client/src/common/components/select/Select.tsx b/apps/client/src/common/components/select/Select.tsx index 9c3e2f419..696d06ea0 100644 --- a/apps/client/src/common/components/select/Select.tsx +++ b/apps/client/src/common/components/select/Select.tsx @@ -29,7 +29,7 @@ export default function Select({ options, fluid, size = 'medium', ...selectRo - + @@ -43,7 +43,7 @@ export default function Select({ options, fluid, size = 'medium', ...selectRo ))} - + diff --git a/apps/client/src/common/components/tag/Tag.module.scss b/apps/client/src/common/components/tag/Tag.module.scss index 7f0d1e631..9aea48fb5 100644 --- a/apps/client/src/common/components/tag/Tag.module.scss +++ b/apps/client/src/common/components/tag/Tag.module.scss @@ -1,9 +1,17 @@ .tag { font-size: calc(1rem - 3px); letter-spacing: 0.5px; - background-color: $gray-900; - color: $ui-white; border-radius: 2px; padding: 0 0.25rem; white-space: nowrap; } + +.default { + background-color: $gray-900; + color: $ui-white; +} + +.warning { + background-color: $orange-1300; + color: $orange-300; +} diff --git a/apps/client/src/common/components/tag/Tag.tsx b/apps/client/src/common/components/tag/Tag.tsx index 80bf57b7e..5213aa298 100644 --- a/apps/client/src/common/components/tag/Tag.tsx +++ b/apps/client/src/common/components/tag/Tag.tsx @@ -1,11 +1,14 @@ import { PropsWithChildren } from 'react'; +import { cx } from '../../utils/styleUtils'; + import style from './Tag.module.scss'; interface TagProps { className?: string; + variant?: 'default' | 'warning'; } -export default function Tag({ className, children }: PropsWithChildren) { - return {children}; +export default function Tag({ className, variant = 'default', children }: PropsWithChildren) { + return {children}; } diff --git a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss index 0db42c1f0..fbb3dda96 100644 --- a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss +++ b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss @@ -140,11 +140,21 @@ $inner-padding: 1rem; font-size: 1rem; } +.fieldHeading { + display: flex; + align-items: center; + gap: 0.5rem; +} + .fieldDescription { font-size: calc(1rem - 2px); color: $gray-400; } +.warningText { + color: $orange-500; +} + .fieldError { font-size: calc(1rem - 2px); color: $red-500; diff --git a/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx b/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx index 1a8bdd574..a47e52dab 100644 --- a/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx +++ b/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx @@ -85,18 +85,34 @@ export function ListItem({ children }: { children: ReactNode }) { return
  • {children}
  • ; } -export function Field({ title, description, error }: { title: string; description: string; error?: string }) { +export function Field({ + title, + description, + error, + descriptionTone = 'default', +}: { + title: ReactNode; + description: ReactNode; + error?: string; + descriptionTone?: 'default' | 'warning'; +}) { return (
    - {title} +
    {title}
    {error && {error}} - {!error && description && {description}} + {!error && description && {description}}
    ); } -export function Description({ children }: { children: ReactNode }) { - return
    {children}
    ; +export function Description({ + children, + tone = 'default', +}: { + children: ReactNode; + tone?: 'default' | 'warning'; +}) { + return
    {children}
    ; } export function Highlight({ children }: { children: ReactNode }) { diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx index 6fc74e8d3..1ce3da620 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx @@ -282,7 +282,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr ); })}
    -
    diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx index 7ec7d591c..be25f9fb3 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx @@ -13,6 +13,8 @@ export default function AutomationPanel({ location }: PanelBaseProps) { const automationsRef = useScrollIntoView('automations', location); const isLoading = status === 'pending'; + const automationState = isLoading ? undefined : data.enabledAutomations; + const oscInputState = isLoading ? undefined : data.enabledOscIn; return ( <> @@ -24,13 +26,15 @@ export default function AutomationPanel({ location }: PanelBaseProps) { enabledAutomations={data.enabledAutomations} enabledOscIn={data.enabledOscIn} oscPortIn={data.oscPortIn} + automationState={automationState} + oscInputState={oscInputState} />
    - +
    - +
    diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx index 5e64cdb95..bfe98b73b 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx @@ -7,6 +7,7 @@ import Info from '../../../../common/components/info/Info'; import Input from '../../../../common/components/input/input/Input'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import Switch from '../../../../common/components/switch/Switch'; +import Tag from '../../../../common/components/tag/Tag'; import { preventEscape } from '../../../../common/utils/keyEvent'; import { isOnlyNumbers } from '../../../../common/utils/regex'; import { isOntimeCloud } from '../../../../externals'; @@ -18,12 +19,16 @@ interface AutomationSettingsProps { enabledAutomations: boolean; enabledOscIn: boolean; oscPortIn: number; + automationState?: boolean; + oscInputState?: boolean; } export default function AutomationSettingsForm({ enabledAutomations, enabledOscIn, oscPortIn, + automationState, + oscInputState, }: AutomationSettingsProps) { const { handleSubmit, @@ -56,6 +61,8 @@ export default function AutomationSettingsForm({ }; const canSubmit = !isSubmitting && isDirty && isValid; + const automationsEnabled = watch('enabledAutomations'); + const oscInputEnabled = watch('enabledOscIn'); return ( @@ -102,33 +109,52 @@ export default function AutomationSettingsForm({ + Enable automations + {automationState === false && OFF} + + } + description={ + automationState === false + ? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.' + : 'Allow Ontime to send messages on lifecycle triggers' + } + descriptionTone={automationState === false ? 'warning' : 'default'} error={errors.enabledAutomations?.message} /> setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true }) } /> - OSC Input {isOntimeCloud && For security reasons OSC integrations are not available in the cloud service.} + OSC input + {oscInputState === false && OFF} + + } + description={ + oscInputState === false + ? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.' + : 'Allow control of Ontime through OSC' + } + descriptionTone={oscInputState === false ? 'warning' : 'default'} error={errors.enabledOscIn?.message} /> setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true }) } diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx index 5c0e848e2..7da9aa484 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx @@ -5,6 +5,7 @@ import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5'; import { deleteAutomation } from '../../../../common/api/automation'; import { maybeAxiosError } from '../../../../common/api/utils'; import Button from '../../../../common/components/buttons/Button'; +import Info from '../../../../common/components/info/Info'; import IconButton from '../../../../common/components/buttons/IconButton'; import Tag from '../../../../common/components/tag/Tag'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; @@ -20,10 +21,11 @@ const automationPlaceholder: AutomationDTO = { interface AutomationsListProps { automations: NormalisedAutomation; + enabledAutomations?: boolean; } export default function AutomationsList(props: AutomationsListProps) { - const { automations } = props; + const { automations, enabledAutomations } = props; const { refetch } = useAutomationSettings(); const [automationFormData, setAutomationFormData] = useState(null); const [deleteError, setDeleteError] = useState(null); @@ -56,6 +58,13 @@ export default function AutomationsList(props: AutomationsListProps) { + {enabledAutomations === false && ( + + Automations are disabled. You can still manage automation definitions here, but they will not run until + enabled. + + )} + {automationFormData !== null && ( setAutomationFormData(null)} /> )} 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 3318789f2..ab874a965 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 @@ -66,6 +66,11 @@ export default function OntimeActionForm({ { value: 'aux2-set', label: 'Aux 2: set' }, { value: 'aux3-set', label: 'Aux 3: set' }, + { value: 'playback-start', label: 'Playback: start' }, + { value: 'playback-stop', label: 'Playback: stop' }, + { value: 'playback-pause', label: 'Playback: pause' }, + { value: 'playback-roll', label: 'Playback: roll' }, + { value: 'message-set', label: 'Primary Message: set' }, { value: 'message-secondary', label: 'Secondary Message: source' }, ]} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx b/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx index 80712cfef..b77bf8633 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx @@ -5,6 +5,7 @@ import { IoAdd } from 'react-icons/io5'; import { deleteTrigger } from '../../../../common/api/automation'; import { maybeAxiosError } from '../../../../common/api/utils'; import Button from '../../../../common/components/buttons/Button'; +import Info from '../../../../common/components/info/Info'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import * as Panel from '../../panel-utils/PanelUtils'; import { checkDuplicates } from './automationUtils'; @@ -14,10 +15,11 @@ import TriggersListItem from './TriggersListItem'; interface TriggersListProps { triggers: Trigger[]; automations: NormalisedAutomation; + enabledAutomations?: boolean; } export default function TriggersList(props: TriggersListProps) { - const { triggers, automations } = props; + const { triggers, automations, enabledAutomations } = props; const [showForm, setShowForm] = useState(false); const { refetch } = useAutomationSettings(); const [deleteError, setDeleteError] = useState(null); @@ -52,6 +54,9 @@ export default function TriggersList(props: TriggersListProps) { + {enabledAutomations === false && ( + Automations are disabled. You can still manage triggers here, but they will not run until enabled. + )} {duplicates && ( You have created multiple links between the same trigger and automation which can performance issues. diff --git a/apps/client/src/features/app-settings/panel/settings-panel/ViewSettings.tsx b/apps/client/src/features/app-settings/panel/settings-panel/ViewSettings.tsx index 38f641443..0a4773243 100644 --- a/apps/client/src/features/app-settings/panel/settings-panel/ViewSettings.tsx +++ b/apps/client/src/features/app-settings/panel/settings-panel/ViewSettings.tsx @@ -9,6 +9,7 @@ import Info from '../../../../common/components/info/Info'; import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import Switch from '../../../../common/components/switch/Switch'; +import Tag from '../../../../common/components/tag/Tag'; import useViewSettings from '../../../../common/hooks-query/useViewSettings'; import { preventEscape } from '../../../../common/utils/keyEvent'; import * as Panel from '../../panel-utils/PanelUtils'; @@ -55,6 +56,8 @@ export default function ViewSettings() { reset(data); }; + const overrideStylesEnabled = watch('overrideStyles'); + if (!control) { return null; } @@ -91,12 +94,22 @@ export default function ViewSettings() { + Override CSS styles + {overrideStylesEnabled && ON} + + } + description={ + overrideStylesEnabled + ? 'CSS override is ON. Ontime views will use the custom override stylesheet.' + : 'Enables overriding view styles with custom stylesheet' + } + descriptionTone={overrideStylesEnabled ? 'warning' : 'default'} /> setValue('overrideStyles', value, { shouldDirty: true })} /> - {validationError !== undefined ? ( - }> - - - ) : ( - - )} +
    +
    + {validationError ? : } + {validationLabel} +
    + +
    ); } @@ -119,11 +140,11 @@ function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) { interface ExistingEventTriggersProps { eventId: string; triggers: Trigger[]; + automations: NormalisedAutomation; } -function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps) { +function ExistingEventTriggers({ eventId, triggers, automations }: ExistingEventTriggersProps) { const { updateEntry } = useEntryActionsContext(); - const { data: automationSettings } = useAutomationSettings(); const handleDelete = useCallback( (triggerId: string) => { @@ -144,16 +165,22 @@ function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps }); return ( -
    +
    {Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => ( {triggerGroup.map((trigger) => { const { id, automationId } = trigger; - const automationTitle = automationSettings.automations[automationId]?.title ?? ''; + const automationTitle = automations[automationId]?.title ?? ''; return (
    - {triggerLifeCycle} - {automationTitle} +
    +
    Lifecycle
    +
    {triggerLifeCycle}
    +
    +
    +
    Automation
    +
    {automationTitle}
    +
    handleDelete(id)}> diff --git a/apps/client/src/features/rundown/rundown-event/RundownEvent.module.scss b/apps/client/src/features/rundown/rundown-event/RundownEvent.module.scss index 1dd04a4e4..84397cfaf 100644 --- a/apps/client/src/features/rundown/rundown-event/RundownEvent.module.scss +++ b/apps/client/src/features/rundown/rundown-event/RundownEvent.module.scss @@ -231,6 +231,10 @@ $skip-opacity: 0.2; color: var(--status-color-active-override, $active-indicator); } + .statusIcon.warning { + color: $orange-500; + } + .statusIcon.disabled { color: $gray-1000; } diff --git a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx index 506ee374a..714e97c64 100644 --- a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx +++ b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx @@ -43,6 +43,7 @@ interface RundownEventProps { title: string; note: string; delay: number; + automationsEnabled?: boolean; colour: string; isPast: boolean; isNext: boolean; @@ -76,6 +77,7 @@ export default function RundownEvent({ title, note, delay, + automationsEnabled, colour, isPast, isNext, @@ -302,6 +304,7 @@ export default function RundownEvent({ title={title} note={note} delay={delay} + automationsEnabled={automationsEnabled} isNext={isNext} skip={skip} loaded={loaded} diff --git a/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx b/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx index 74ee1673e..90f545a83 100644 --- a/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx +++ b/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx @@ -38,6 +38,7 @@ interface RundownEventInnerProps { title: string; note: string; delay: number; + automationsEnabled?: boolean; isNext: boolean; skip: boolean; loaded: boolean; @@ -64,6 +65,7 @@ function RundownEventInner({ title, note, delay, + automationsEnabled, isNext, skip = false, loaded, @@ -79,6 +81,21 @@ function RundownEventInner({ const eventIsPlaying = playback === Playback.Play; const eventIsPaused = playback === Playback.Pause; + const automationTooltip = (() => { + if (!hasTriggers) { + return 'Event has no triggers'; + } + + if (automationsEnabled !== false) { + return 'Event has triggers'; + } + + return 'Event has triggers, but automations are disabled'; + })(); + const automationIconClasses = cx([ + style.statusIcon, + hasTriggers ? (automationsEnabled === false ? style.warning : style.active) : style.disabled, + ]); const playBtnStyles = { _hover: {} }; if (!skip && eventIsPlaying) { @@ -142,8 +159,8 @@ function RundownEventInner({ }> - }> - + }> +
    diff --git a/apps/server/src/api-data/automation/clients/ontime.client.ts b/apps/server/src/api-data/automation/clients/ontime.client.ts index 51a9a8e3d..537b31931 100644 --- a/apps/server/src/api-data/automation/clients/ontime.client.ts +++ b/apps/server/src/api-data/automation/clients/ontime.client.ts @@ -4,6 +4,7 @@ import { parseUserTime } from 'ontime-utils'; import { logger } from '../../../classes/Logger.js'; import { auxTimerService } from '../../../services/aux-timer-service/AuxTimerService.js'; import * as messageService from '../../../services/message-service/message.service.js'; +import { runtimeService } from '../../../services/runtime-service/runtime.service.js'; export function toOntimeAction(action: OntimeAction) { const actionType = action.action; @@ -40,6 +41,16 @@ export function toOntimeAction(action: OntimeAction) { return auxTimerService.setTime(time, 3); } + // Playback actions + case 'playback-start': + return runtimeService.start(); + case 'playback-stop': + return runtimeService.stop(); + case 'playback-pause': + return runtimeService.pause(); + case 'playback-roll': + return runtimeService.roll(); + // Message actions case 'message-set': { messageService.patch({ diff --git a/packages/types/src/definitions/core/Automation.type.ts b/packages/types/src/definitions/core/Automation.type.ts index 8e49b8aa0..69979d93a 100644 --- a/packages/types/src/definitions/core/Automation.type.ts +++ b/packages/types/src/definitions/core/Automation.type.ts @@ -68,15 +68,24 @@ const ontimeAuxTriggerAction = [ const ontimeAuxSetAction = ['aux1-set', 'aux2-set', 'aux3-set'] as const; +const ontimePlaybackAction = ['playback-start', 'playback-stop', 'playback-pause', 'playback-roll'] as const; + type OntimeAuxTriggerAction = (typeof ontimeAuxTriggerAction)[number]; type OntimeAuxSetAction = (typeof ontimeAuxSetAction)[number]; +type OntimePlaybackAction = (typeof ontimePlaybackAction)[number]; type OntimeMessageSet = 'message-set'; type OntimeMessageSecondary = 'message-secondary'; -export type OntimeActionKey = OntimeAuxTriggerAction | OntimeAuxSetAction | OntimeMessageSet | OntimeMessageSecondary; +export type OntimeActionKey = + | OntimeAuxTriggerAction + | OntimePlaybackAction + | OntimeAuxSetAction + | OntimeMessageSet + | OntimeMessageSecondary; export const ontimeActionKeyValues = [ ...ontimeAuxTriggerAction, + ...ontimePlaybackAction, ...ontimeAuxSetAction, 'message-set', 'message-secondary', @@ -87,6 +96,10 @@ export type OntimeAction = type: 'ontime'; action: OntimeAuxTriggerAction; } + | { + type: 'ontime'; + action: OntimePlaybackAction; + } | { type: 'ontime'; action: OntimeAuxSetAction;