* chore: upgrade relevant deps

* fix: electron app to tray

* chore: cleanup dictionary

* feat: handle several messages in a event

* ux: disable irrelevant buttons in browser

* feat: parse and validate subscriptions

* feat: create UI for OSC Integration

* fix: cleanup logger behaviour

* feat: allow OSC settings to be changed at runtime
This commit is contained in:
Carlos Valente
2023-03-23 08:33:22 +01:00
committed by GitHub
parent 73533600a0
commit e937af62b1
36 changed files with 925 additions and 765 deletions
@@ -4,13 +4,24 @@
.headerNotes {
font-size: $text-body-size;
width: 100%;
padding: 0 16px;
padding: 0 $section-spacing;
color: $modal-note-color;
margin-bottom: $section-spacing;
a {
display: block;
color: $blue-500;
transition-property: color;
transition-duration: $transition-time-action;
&:hover {
color: $ontime-color;
}
}
}
.divider {
margin: 8px 0;
margin: $element-spacing 0;
border: 0;
border-top: 1px solid $gray-100;
}
@@ -21,21 +32,29 @@
height: 100%
}
.splitSection {
@mixin sectionSpacing {
border-radius: $component-border-radius-md;
padding: $element-spacing;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 3px;
padding: 8px;
&:hover {
background-color: $blue-50;
}
}
.splitSection {
@include sectionSpacing;
justify-content: space-between;
}
.entryRow {
@include sectionSpacing;
gap: $element-inner-spacing;
}
.sectionTitle {
font-size: 14px;
font-size: $inner-section-text-size;
display: block;
&.main {
@@ -44,7 +63,7 @@
}
@mixin subsection {
font-size: 14px;
font-size: $inner-section-text-size;
display: block;
}
@@ -59,19 +78,23 @@
}
.buttonSection {
margin-top: 16px;
padding-top: 16px;
margin-top: $section-spacing;
padding-top: $section-spacing;
padding-left: -24px;
border-top: 1px solid $gray-100;
display: flex;
gap: 16px;
}
.buttonSection {
button:first-of-type {
margin-right: auto;
}
gap: $section-spacing;
}
.spacer {
flex-grow: 1;
}
.shiftRight {
align-self: flex-end;
margin-right: 8px;
}
.showPointer {
cursor: pointer;
}
@@ -1,23 +0,0 @@
import { Button, ModalFooter } from '@chakra-ui/react';
import styles from './Modal.module.scss';
export default function ModalSubmitFooter() {
return (
<ModalFooter className={styles.buttonSection}>
<Button variant='ghosted' paddingLeft={0} color='#6c6c6c'>
Revert to saved
</Button>
<Button colorScheme='gray'>Cancel</Button>
<Button
variant='ontime-filled'
type='submit'
// disabled={isSubmitting}
isLoading={false}
padding='0 2.5em'
>
Save
</Button>
</ModalFooter>
);
}
@@ -1,14 +1,5 @@
import { PropsWithChildren } from 'react';
import {
Button,
Modal,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import styles from './Modal.module.scss';
import { Modal, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
interface ModalWrapperProps {
isOpen: boolean;
@@ -2,8 +2,8 @@ import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
import ModalWrapper from '../ModalWrapper';
import OscIntegrationSettings from './OscIntegrationSettings';
import OscSettingsModal from './OscSettingsModal';
import OscIntegration from './OscIntegration';
import OscSettings from './OscSettings';
import styles from '../Modal.module.scss';
@@ -12,26 +12,30 @@ interface IntegrationModalProps {
onClose: () => void;
}
const oscDocsUrl = 'https://cpvalente.gitbook.io/ontime/control-and-feedback/osc';
export default function IntegrationModal(props: IntegrationModalProps) {
const { isOpen, onClose } = props;
return (
<ModalWrapper title='Integration Settings' isOpen={isOpen} onClose={onClose}>
<div className={styles.headerNotes}>
Manage settings related to protocol integrations. <br />
Changes take effect on app restart.
Manage settings related to protocol integrations
<a href={oscDocsUrl} target='_blank' rel='noreferrer'>
Read the docs
</a>
</div>
<Tabs variant='ontime' size='sm' isLazy>
<TabList>
<Tab>OSC</Tab>
<Tab>Old OSC</Tab>
<Tab>OSC Integration</Tab>
</TabList>
<TabPanels>
<TabPanel>
<OscIntegrationSettings />
<OscSettings />
</TabPanel>
<TabPanel>
<OscSettingsModal />
<OscIntegration />
</TabPanel>
</TabPanels>
</Tabs>
@@ -0,0 +1,37 @@
import { Button, ModalFooter } from '@chakra-ui/react';
import styles from '../Modal.module.scss';
interface OntimeModalFooterProps {
formId: string;
handleRevert: () => void;
isDirty: boolean;
isValid: boolean;
isSubmitting: boolean;
}
export default function OntimeModalFooter(props: OntimeModalFooterProps) {
const { formId, handleRevert, isDirty, isValid, isSubmitting } = props;
const disableRevert = !isDirty;
const disableSubmit = isSubmitting || !isDirty || !isValid;
return (
<ModalFooter className={styles.buttonSection} paddingInlineStart={0} paddingInlineEnd={0} paddingBottom={0}>
<Button isDisabled={disableRevert} variant='ontime-ghost-on-light' size='sm' onClick={handleRevert}>
Revert to saved
</Button>
<Button
type='submit'
form={formId}
isLoading={isSubmitting}
isDisabled={disableSubmit}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
Save
</Button>
</ModalFooter>
);
}
@@ -0,0 +1,123 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { ModalBody } from '@chakra-ui/react';
import type { OSCSettings, OscSubscription } from 'ontime-types';
import { TimerLifeCycle } from 'ontime-types';
import { generateId } from 'ontime-utils';
import useOscSettings, { useOscSettingsMutation } from '../../../common/hooks-query/useOscSettings';
import { oscPlaceholderSettings, PlaceholderSettings } from '../../../common/models/OscSettings';
import { useEmitLog } from '../../../common/stores/logger';
import OntimeModalFooter from './OntimeModalFooter';
import OscSubscriptionRow from './OscSubscriptionRow';
import styles from '../Modal.module.scss';
type OntimeCycle = keyof typeof TimerLifeCycle;
const sectionText: { [key in TimerLifeCycle]: { title: string; subtitle: string } } = {
onLoad: {
title: 'On Load',
subtitle: 'Triggers when a timer is loaded',
},
onStart: {
title: 'On Start',
subtitle: 'Triggers when a timer starts',
},
onPause: {
title: 'On Pause',
subtitle: 'Triggers when a running timer is paused',
},
onStop: {
title: 'On Stop',
subtitle: 'Triggers when a running timer is stopped',
},
onUpdate: {
title: 'On Update',
subtitle: 'Triggers when timers are updated (at least once a second, can be more)',
},
onFinish: {
title: 'On Finish',
subtitle: 'Triggers when a running reaches 0',
},
};
export default function OscIntegration() {
const { data } = useOscSettings();
const { mutateAsync } = useOscSettingsMutation();
const { emitError } = useEmitLog();
const {
handleSubmit,
register,
formState: { isSubmitting, isDirty, isValid },
} = useForm<PlaceholderSettings>({
defaultValues: data,
values: data,
});
const resetForm = () => data?.subscriptions || oscPlaceholderSettings.subscriptions;
const [subscriptionState, setSubscription] = useState<OscSubscription>(() => resetForm());
const [showSection, setShowSection] = useState<OntimeCycle>(TimerLifeCycle.onLoad);
const deleteSubscriptionEntry = (cycle: OntimeCycle, id: string) => {
setSubscription((prev) => {
const newData = { ...prev };
newData[cycle] = [...prev[cycle].filter((el) => el.id !== id)];
return newData;
});
};
const addNewSubscriptionEntry = async (cycle: OntimeCycle) => {
setSubscription((prev) => {
const newData = { ...prev };
newData[cycle] = [...prev[cycle], { id: generateId(), message: '', enabled: false }];
return newData;
});
};
const onSubmit = async (values: OSCSettings | PlaceholderSettings) => {
try {
// @ts-expect-error -- we know of the type mismatch, not pertinent here
await mutateAsync(values);
} catch (error) {
emitError(`Error setting OSC: ${error}`);
}
};
const subscriptionKeys = Object.keys(subscriptionState);
return (
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='oscSubscriptions'>
<ModalBody>
{subscriptionKeys.map((cycle, idx) => {
return (
<>
<OscSubscriptionRow
key={cycle}
cycle={cycle as TimerLifeCycle}
title={sectionText[cycle as TimerLifeCycle].title}
subtitle={sectionText[cycle as TimerLifeCycle].subtitle}
visible={showSection === cycle}
setShowSection={setShowSection}
subscriptionOptions={subscriptionState[cycle as TimerLifeCycle]}
handleDelete={deleteSubscriptionEntry}
handleAddNew={addNewSubscriptionEntry}
register={register}
/>
{idx < subscriptionKeys.length - 1 && <hr className={styles.divider} />}
</>
);
})}
</ModalBody>
<OntimeModalFooter
formId='oscSubscriptions'
handleRevert={resetForm}
isDirty={isDirty}
isValid={isValid}
isSubmitting={isSubmitting}
/>
</form>
);
}
@@ -1,16 +1,18 @@
import { useForm } from 'react-hook-form';
import { Button, FormControl, Input, ModalBody, ModalFooter, Switch } from '@chakra-ui/react';
import { FormControl, Input, ModalBody, Switch } from '@chakra-ui/react';
import { postOSC } from '../../../common/api/ontimeApi';
import useOscSettings from '../../../common/hooks-query/useOscSettings';
import useOscSettings, { useOscSettingsMutation } from '../../../common/hooks-query/useOscSettings';
import { PlaceholderSettings } from '../../../common/models/OscSettings';
import { useEmitLog } from '../../../common/stores/logger';
import { isIPAddress, isOnlyNumbers } from '../../../common/utils/regex';
import OntimeModalFooter from './OntimeModalFooter';
import styles from '../Modal.module.scss';
export default function OscIntegrationSettings() {
export default function OscSettings() {
const { data } = useOscSettings();
const { mutateAsync } = useOscSettingsMutation();
const { emitError } = useEmitLog();
const {
handleSubmit,
@@ -23,8 +25,6 @@ export default function OscIntegrationSettings() {
values: data,
});
const disableSubmit = isSubmitting || !isDirty || !isValid;
const onSubmit = async (values: PlaceholderSettings) => {
const numericPortIn = Number(values.portIn);
const numericPortOut = Number(values.portOut);
@@ -41,17 +41,20 @@ export default function OscIntegrationSettings() {
};
try {
await postOSC(parsedValues);
await mutateAsync(parsedValues);
} catch (error) {
emitError(`Error setting OSC: ${error}`);
}
};
const resetForm = () => reset(data);
const resetForm = () => {
// @ts-expect-error -- we know the types dont match
reset(data);
};
return (
<>
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='test'>
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='oscSettings'>
<ModalBody>
<div className={styles.splitSection}>
<div>
@@ -77,6 +80,7 @@ export default function OscIntegrationSettings() {
size='sm'
textAlign='right'
maxLength={5}
variant='ontime-filled-on-light'
{...register('portIn', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port in incorrect range (1024 - 65535)' },
@@ -116,6 +120,7 @@ export default function OscIntegrationSettings() {
width='140px'
size='sm'
textAlign='right'
variant='ontime-filled-on-light'
{...register('targetIP', {
required: { value: true, message: 'Required field' },
pattern: {
@@ -142,6 +147,7 @@ export default function OscIntegrationSettings() {
size='sm'
textAlign='right'
maxLength={5}
variant='ontime-filled-on-light'
{...register('portOut', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port in incorrect range (1024 - 65535)' },
@@ -154,27 +160,14 @@ export default function OscIntegrationSettings() {
/>
</FormControl>
</ModalBody>
{/*<ModalSubmitFooter />*/}
</form>
<ModalFooter className={styles.buttonSection}>
<Button variant='ontime-ghost-on-light' size='sm' onClick={resetForm}>
Revert to saved
</Button>
<Button variant='ontime-subtle-on-light' size='sm'>
Cancel
</Button>
<Button
variant='ontime-filled'
type='submit'
form='test'
disabled={disableSubmit}
isLoading={isSubmitting}
padding='0 2em'
size='sm'
>
Save
</Button>
</ModalFooter>
<OntimeModalFooter
formId='oscSettings'
handleRevert={resetForm}
isDirty={isDirty}
isValid={isValid}
isSubmitting={isSubmitting}
/>
</>
);
}
@@ -1,291 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { FormControl, FormLabel, Input, ModalBody } from '@chakra-ui/react';
import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline';
import { useEmitLog } from '@/common/stores/logger';
import { postOSC } from '../../../common/api/ontimeApi';
import EnableBtn from '../../../common/components/buttons/EnableBtn';
import useOscSettings from '../../../common/hooks-query/useOscSettings';
import { oscPlaceholderSettings } from '../../../common/models/OscSettings';
import { inputProps, portInputProps } from '../modalHelper';
import SubmitContainer from '../SubmitContainer';
import style from '../Modals.module.scss';
// currently defined endpoints
// temporary
const oscCycleEndpoints = [
{
title: 'On Event Start',
message: '/ontime/eventNumber',
value: '8 | int',
},
{
title: 'On Update',
message: '/ontime/time',
value: '10:12:12 | string',
},
{
title: 'On Update',
message: '/ontime/overtime',
value: '0-1 | int',
},
{
title: 'On Update',
message: '/ontime/title',
value: 'Title of running event | string',
},
{
title: 'On Finish',
message: '/ontime/finished',
value: '-',
},
];
const oscTriggerEndpoints = [
{
title: 'On Start',
message: '/ontime/play',
value: '-',
},
{
title: 'On Pause',
message: '/ontime/pause',
value: '-',
},
{
title: 'On Previous',
message: '/ontime/prev',
value: '-',
},
{
title: 'On Next',
message: '/ontime/next',
value: '-',
},
{
title: 'On Reload',
message: '/ontime/reload',
value: '-',
},
{
title: 'On Stop',
message: '/ontime/stop',
value: '-',
},
];
export default function OscSettingsModal() {
const { data, status, refetch } = useOscSettings();
const { emitError } = useEmitLog();
const [formData, setFormData] = useState(oscPlaceholderSettings);
const [changed, setChanged] = useState(false);
const [submitting, setSubmitting] = useState(false);
/**
* Set formdata from server state
*/
useEffect(() => {
if (data == null) return;
if (changed) return;
setFormData({ ...data });
}, [changed, data]);
/**
* Validate and submit data
*/
const submitHandler = useCallback(
async (event) => {
event.preventDefault();
setSubmitting(true);
const f = formData;
const e = { status: false, message: '' };
// Validate fields
if (f.port < 1024 || f.port > 65535) {
// Port in incorrect range
e.status = true;
e.message += 'OSC IN Port in incorrect range (1024 - 65535)';
} else if (f.portOut < 1024 || f.portOut > 65535) {
// Port in incorrect range
e.status = true;
e.message += 'OSC OUT Port in incorrect range (1024 - 65535)';
} else if (f.port === f.portOut) {
// Cant use the same port
e.status = true;
e.message += 'OSC IN and OUT Ports cant be the same';
}
// set fields with error
if (e.status) {
emitError(`Invalid Input: ${e.message}`);
} else {
try {
await postOSC(formData);
} catch (error) {
emitError(`Error setting OSC: ${error}`);
} finally {
await refetch();
setChanged(false);
}
}
setSubmitting(false);
},
[emitError, formData, refetch],
);
/**
* Reverts local state equals to server state
*/
const revert = useCallback(async () => {
setChanged(false);
await refetch();
}, [refetch]);
/**
* Handles change of input field in local state
* @param {string} field - object parameter to update
* @param {(string | number | boolean)} value - new object parameter value
*/
const handleChange = useCallback(
(field, value) => {
const temp = { ...formData };
temp[field] = value;
setFormData(temp);
setChanged(true);
},
[formData],
);
return (
<ModalBody className={style.modalBody}>
<p className={style.notes}>
Options related to Open Sound Control
<br />
🔥 Changes take effect after app restart 🔥
</p>
<form onSubmit={submitHandler}>
<div className={style.modalFields}>
<div className={style.hSeparator}>OSC Input (Control ontime over OSC)</div>
<div className={style.modalInline}>
<FormControl id='oscInEnabled'>
<FormLabel htmlFor='oscInEnabled'>
OSC Enable
<span className={style.labelNote}>
<br />
Enable / Disable control
</span>
</FormLabel>
<EnableBtn
active={formData.enabled}
text={formData.enabled ? 'OSC IN Enabled' : 'OSC IN Disabled'}
actionHandler={() => handleChange('enabled', !formData.enabled)}
/>
</FormControl>
<FormControl id='portIn'>
<FormLabel htmlFor='portIn'>
OSC In Port
<span className={style.labelNote}>
<br />
Port - Default 8888
</span>
</FormLabel>
<Input
{...portInputProps}
name='port'
placeholder='8888'
value={formData.port}
onChange={(event) => handleChange('port', parseInt(event.target.value, 10))}
style={{ width: '6em' }}
/>
</FormControl>
</div>
<div className={style.hSeparator}>OSC Output (feedback)</div>
<div className={style.modalInline}>
<FormControl id='targetIP'>
<FormLabel htmlFor='targetIP'>
OSC Out Target IP
<span className={style.labelNote}>
<br />
Default 127.0.0.1
</span>
</FormLabel>
<Input
{...inputProps}
size='sm'
name='targetIP'
placeholder='127.0.0.1'
autoComplete='off'
value={formData.targetIP}
onChange={(event) => handleChange('targetIP', event.target.value)}
isDisabled={submitting}
style={{ width: '12em', textAlign: 'right' }}
/>
</FormControl>
<FormControl id='portOut'>
<FormLabel htmlFor='portOut'>
OSC Out Port
<span className={style.labelNote}>
<br />
Default 9999
</span>
</FormLabel>
<Input
{...portInputProps}
name='portOut'
placeholder='9999'
value={formData.portOut}
onChange={(event) => handleChange('portOut', parseInt(event.target.value, 10))}
style={{ width: '6em', textAlign: 'left' }}
/>
</FormControl>
</div>
<div className={style.blockNotes}>
<span className={style.inlineFlex}>
<IoInformationCircleOutline color='#2b6cb0' fontSize='2em' />
OSC Feedback messages
</span>
<span>
In future OSC feedback will be user defined. <br />
For now this is the list of OSC messages sent from ontime
</span>
<table>
<tbody>
<tr>
<td className={style.labelNote} style={{ width: '30%' }}>
Cycle
</td>
<td className={style.labelNote}>Message</td>
<td className={style.labelNote}>Value (example | type)</td>
</tr>
{oscCycleEndpoints.map((e) => (
<tr key={e.message}>
<td>{e.title}</td>
<td>{e.message}</td>
<td>{e.value}</td>
</tr>
))}
<tr>
<td className={style.labelNote} style={{ width: '30%' }}>
Trigger
</td>
<td className={style.labelNote}>Message</td>
<td className={style.labelNote}>Value (example | type)</td>
</tr>
{oscTriggerEndpoints.map((e) => (
<tr key={e.message}>
<td>{e.title}</td>
<td>{e.message}</td>
<td>{e.value}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<SubmitContainer revert={revert} submitting={submitting} changed={changed} status={status} />
</form>
</ModalBody>
);
}
@@ -0,0 +1,74 @@
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { OscSubscriptionOptions, TimerLifeCycle } from 'ontime-types';
import style from '../../../common/components/collapse-bar/CollapseBar.module.scss';
import styles from '../Modal.module.scss';
interface OscSubscriptionRowProps {
cycle: TimerLifeCycle;
title: string;
subtitle: string;
visible: boolean;
setShowSection: (cycle: TimerLifeCycle) => void;
subscriptionOptions: OscSubscriptionOptions[];
handleDelete: (cycle: TimerLifeCycle, id: string) => void;
handleAddNew: (cycle: TimerLifeCycle) => void;
register: object;
}
export default function OscSubscriptionRow(props: OscSubscriptionRowProps) {
const { cycle, title, subtitle, visible, setShowSection, subscriptionOptions, handleDelete, handleAddNew, register } =
props;
const hasTooManyOptions = subscriptionOptions.length >= 3;
const headerStyle = `${styles.splitSection} ${visible ? '' : styles.showPointer}`;
const registerPrefix = `subscriptions.${cycle}`;
return (
<>
<div className={headerStyle} onClick={() => setShowSection(cycle)}>
<div>
<span className={`${styles.sectionTitle} ${styles.main}`}>{title}</span>
{visible && <span className={styles.sectionSubtitle}>{subtitle}</span>}
</div>
<FiChevronUp className={visible ? style.moreCollapsed : style.moreExpanded} />
</div>
{visible && (
<>
{subscriptionOptions.map((option, idx) => (
<div key={option.id} className={styles.entryRow}>
<input type='hidden' {...register(`${registerPrefix}[${idx}].id`)} value={option.id} />
<Switch size='sm' {...register(`${registerPrefix}[${idx}].enabled`)} />
<Input
placeholder='OSC Message'
size='xs'
variant='ontime-filled-on-light'
{...register(`${registerPrefix}[${idx}].message`)}
/>
<IconButton
icon={<IoRemove />}
onClick={() => handleDelete(cycle, option.id)}
aria-label='delete'
size='xs'
colorScheme='red'
/>
</div>
))}
<Button
onClick={() => handleAddNew(cycle)}
className={styles.shiftRight}
isDisabled={hasTooManyOptions}
size='xs'
colorScheme='blue'
variant='outline'
padding='0 2em'
>
Add new
</Button>
</>
)}
</>
);
}