Ux/54 help (#88)

* ux/54-help tooltips
* ux/54-help refetch after mutation
* ux/54-help broadcast event index
* ux/54-help prevent multiple keypresses when key held
* ux/54-help fat clock
* ux/54-help progress bar user defined
* ux/54-help onAir is button
* ux/54-help OSC: add missing presenter message
* ux/54-help OSC: enable / disable OSC
* ux/54-help handle timezone in excel date import
* ux/54-help tray left click to show app
* ux/54-help create queriable endpoint
* ux/54-help fix memoisation in lower thirds
* ux/54-help revise icons
* ux/54-help clarify text
* ux/54-help forgiving text parsing
* ux/54-help add smart keywords
* ux/54-help version bump
This commit is contained in:
Carlos Valente
2022-01-12 22:41:12 +01:00
committed by GitHub
parent c3f18feaae
commit 48093b8651
90 changed files with 2787 additions and 763 deletions
+22 -1
View File
@@ -9,5 +9,26 @@
"extends": [ "extends": [
"eslint:recommended" "eslint:recommended"
], ],
"rules": {} "rules": {
// disallow certain object properties
// https://eslint.org/docs/rules/no-restricted-properties
"no-restricted-properties": [
"error",
{
"object": "global",
"property": "isNaN",
"message": "Please use Number.isNaN instead"
},
{
"object": "self",
"property": "isNaN",
"message": "Please use Number.isNaN instead"
},
{
"object": "window",
"property": "isNaN",
"message": "Please use Number.isNaN instead"
}
]
}
} }
+7 -1
View File
@@ -56,9 +56,15 @@ jobs:
# App # App
- name: Electron - Install dependencies - name: Electron - Install dependencies
run: yarn install run: yarn install && yarn make && yarn setdb
working-directory: ./server working-directory: ./server
- name: Electron - Run tests - name: Electron - Run tests
run: yarn test run: yarn test
working-directory: ./server working-directory: ./server
- name: Cypress run
uses: cypress-io/github-action@v2
with:
working-directory: ./server
start: yarn cypress
+1
View File
@@ -7,6 +7,7 @@ node_modules/
# testing # testing
coverage/ coverage/
*.mp4
# production # production
build/ build/
+2 -1
View File
@@ -3,5 +3,6 @@
"tabWidth": 2, "tabWidth": 2,
"semi": true, "semi": true,
"singleQuote": true, "singleQuote": true,
"jsxSingleQuote": true "jsxSingleQuote": true,
"printWidth": 100
} }
+1
View File
@@ -8,5 +8,6 @@
"jest/no-mocks-import": "warn", "jest/no-mocks-import": "warn",
"no-useless-concat": "warn", "no-useless-concat": "warn",
"prefer-template": "warn" "prefer-template": "warn"
} }
} }
+1
View File
@@ -48,6 +48,7 @@
}, },
"devDependencies": { "devDependencies": {
"@testing-library/react-hooks": "^7.0.2", "@testing-library/react-hooks": "^7.0.2",
"prop-types": "^15.8.1",
"react-test-renderer": "^17.0.2", "react-test-renderer": "^17.0.2",
"sass": "^1.44.0" "sass": "^1.44.0"
} }
+24 -8
View File
@@ -9,9 +9,14 @@ import { ALIASES } from './app/api/apiConstants';
import { getAliases } from './app/api/ontimeApi'; import { getAliases } from './app/api/ontimeApi';
const Editor = lazy(() => import('features/editors/Editor')); const Editor = lazy(() => import('features/editors/Editor'));
const PresenterView = lazy(() =>
import('features/viewers/presenter/PresenterView') const TimerView = lazy(() =>
import('features/viewers/timer/Timer')
); );
const MinimalTimerView = lazy(() =>
import('features/viewers/timer/MinimalTimer')
);
const StageManager = lazy(() => const StageManager = lazy(() =>
import('features/viewers/backstage/StageManager') import('features/viewers/backstage/StageManager')
); );
@@ -22,7 +27,8 @@ const Lower = lazy(() =>
const Pip = lazy(() => import('features/viewers/production/Pip')); const Pip = lazy(() => import('features/viewers/production/Pip'));
const StudioClock = lazy(() => import('features/viewers/studio/StudioClock')); const StudioClock = lazy(() => import('features/viewers/studio/StudioClock'));
const SPresenter = withSocket(PresenterView); const STimer = withSocket(TimerView);
const SMinimalTimer = withSocket(MinimalTimerView);
const SStageManager = withSocket(StageManager); const SStageManager = withSocket(StageManager);
const SPublic = withSocket(Public); const SPublic = withSocket(Public);
const SLowerThird = withSocket(Lower); const SLowerThird = withSocket(Lower);
@@ -36,6 +42,8 @@ function App() {
// Handle keyboard shortcuts // Handle keyboard shortcuts
const handleKeyPress = useCallback((e) => { const handleKeyPress = useCallback((e) => {
// handle held key
if (e.repeat) return;
// check if the alt key is pressed // check if the alt key is pressed
if (e.altKey) { if (e.altKey) {
if (e.key === 't' || e.key === 'T') { if (e.key === 't' || e.key === 'T') {
@@ -75,11 +83,19 @@ function App() {
<ErrorBoundary> <ErrorBoundary>
<Suspense fallback={null}> <Suspense fallback={null}>
<Routes> <Routes>
<Route path='/' element={<SPresenter />} /> <Route path='/' element={<STimer />} />
<Route path='/speaker' element={<STimer />} />
<Route path='/presenter' element={<STimer />} />
<Route path='/stage' element={<STimer />} />
<Route path='/timer' element={<STimer />} />
<Route path='/minimal' element={<SMinimalTimer />} />
<Route path='/minimalTimer' element={<SMinimalTimer />} />
<Route path='/simpleTimer' element={<SMinimalTimer />} />
<Route path='/sm' element={<SStageManager />} /> <Route path='/sm' element={<SStageManager />} />
<Route path='/speaker' element={<SPresenter />} /> <Route path='/backstage' element={<SStageManager />} />
<Route path='/presenter' element={<SPresenter />} />
<Route path='/stage' element={<SPresenter />} />
<Route path='/public' element={<SPublic />} /> <Route path='/public' element={<SPublic />} />
<Route path='/pip' element={<SPip />} /> <Route path='/pip' element={<SPip />} />
<Route path='/studio' element={<SStudio />} /> <Route path='/studio' element={<SStudio />} />
@@ -95,7 +111,7 @@ function App() {
} }
/> />
{/* Send to default if nothing found */} {/* Send to default if nothing found */}
<Route path='*' element={<SPresenter />} /> <Route path='*' element={<STimer />} />
</Routes> </Routes>
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
+3 -3
View File
@@ -25,7 +25,7 @@ export const oscPlaceholderSettings = {
port: '', port: '',
portOut: '', portOut: '',
targetIP: '', targetIP: '',
enabled: true, enabled: false,
}; };
export const httpPlaceholder = { export const httpPlaceholder = {
@@ -66,7 +66,7 @@ export const ontimeVars = [
}, },
{ {
name: '$presenter', name: '$presenter',
description: 'Current presenter', description: 'Current timer',
}, },
{ {
name: '$subtitle', name: '$subtitle',
@@ -78,7 +78,7 @@ export const ontimeVars = [
}, },
{ {
name: '$next-presenter', name: '$next-presenter',
description: 'Next presenter', description: 'Next timer',
}, },
{ {
name: '$next-subtitle', name: '$next-subtitle',
+13
View File
@@ -0,0 +1,13 @@
/**
* @description Validates two time entries
* @param {number} timeStart
* @param {number} timeEnd
* @returns {{catch: string, value: boolean}}
*/
export const validateTimes = (timeStart, timeEnd) => {
let validate = { value: true, catch: '' };
if (timeStart > timeEnd) {
validate.catch = 'Start time later than end time';
}
return validate;
};
+1 -1
View File
@@ -2,7 +2,7 @@ const dummy = new Date();
export const sampleData = { export const sampleData = {
presenterMessage: { presenterMessage: {
text: 'Only the presenter sees this', text: 'Only the timer sees this',
active: false, active: false,
}, },
publicMessage: { publicMessage: {
@@ -1,6 +1,7 @@
import { IconButton } from '@chakra-ui/button'; import { IconButton } from '@chakra-ui/button';
import { useState } from 'react'; import { useState } from 'react';
import { FiMinus } from 'react-icons/fi'; import { IoRemove } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function DeleteIconBtn(props) { export default function DeleteIconBtn(props) {
const { actionHandler, ...rest } = props; const { actionHandler, ...rest } = props;
@@ -12,15 +13,17 @@ export default function DeleteIconBtn(props) {
}; };
return ( return (
<IconButton <Tooltip label='Delete'>
size={props.size || 'xs'} <IconButton
icon={<FiMinus />} size={props.size || 'xs'}
colorScheme='red' icon={<IoRemove />}
onClick={handleClick} colorScheme='red'
_focus={{ boxShadow: 'none' }} onClick={handleClick}
disabled={loading} _focus={{ boxShadow: 'none' }}
isLoading={loading} disabled={loading}
{...rest} isLoading={loading}
/> {...rest}
/>
</Tooltip>
); );
} }
@@ -0,0 +1,18 @@
import { IoCloseSharp, IoCheckmarkSharp } from 'react-icons/io5';
import { Button } from '@chakra-ui/button';
export default function EnableBtn(props) {
const { active, text, actionHandler } = props;
return (
<Button
size={props.size || 'xs'}
leftIcon={active ? <IoCheckmarkSharp /> : <IoCloseSharp />}
colorScheme='blue'
variant={active ? 'solid' : 'outline'}
onClick={actionHandler}
_focus={{ boxShadow: 'none' }}
>
{text}
</Button>
);
}
@@ -1,18 +1,21 @@
import { IconButton } from '@chakra-ui/button'; import { IconButton } from '@chakra-ui/button';
import { FiSkipForward } from 'react-icons/fi'; import { IoPlaySkipForward } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function NextIconBtn(props) { export default function NextIconBtn(props) {
const { clickhandler, ...rest } = props; const { clickhandler, ...rest } = props;
return ( return (
<IconButton <Tooltip label='Next event' openDelay={500} shouldWrapChildren={props.disabled}>
icon={<FiSkipForward />} <IconButton
colorScheme='whiteAlpha' icon={<IoPlaySkipForward size='22px' />}
backgroundColor='#ffffff11' colorScheme='whiteAlpha'
variant='outline' backgroundColor='#ffffff11'
onClick={clickhandler} variant='outline'
width={90} onClick={clickhandler}
_focus={{ boxShadow: 'none' }} width={90}
{...rest} _focus={{ boxShadow: 'none' }}
/> {...rest}
/>
</Tooltip>
); );
} }
@@ -0,0 +1,22 @@
import { IconButton } from '@chakra-ui/button';
import { IoMicSharp, IoMicOffOutline } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function OnAirIconBtn(props) {
const { actionHandler, active, ...rest } = props;
return (
<Tooltip label={active ? 'Go Off Air' : 'Go On Air'} openDelay={500}>
<IconButton
size={props.size || 'xs'}
icon={active ? <IoMicSharp size='24px' /> : <IoMicOffOutline size='24px' />}
colorScheme='blue'
variant={active ? 'solid' : 'outline'}
onClick={() =>
actionHandler('update', { field: 'isPublic', value: !active })
}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,17 +1,20 @@
import { IconButton } from '@chakra-ui/button'; import { IconButton } from '@chakra-ui/button';
import { FiPause } from 'react-icons/fi'; import { IoPause } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function PauseIconBtn(props) { export default function PauseIconBtn(props) {
const { clickhandler, active, ...rest } = props; const { clickhandler, active, ...rest } = props;
return ( return (
<IconButton <Tooltip label='Pause timer' openDelay={500} shouldWrapChildren={props.disabled}>
icon={<FiPause />} <IconButton
colorScheme='orange' icon={<IoPause size='24px' />}
variant={active ? 'solid' : 'outline'} colorScheme='orange'
onClick={clickhandler} variant={active ? 'solid' : 'outline'}
width={120} onClick={clickhandler}
_focus={{ boxShadow: 'none' }} width={120}
{...rest} _focus={{ boxShadow: 'none' }}
/> {...rest}
/>
</Tooltip>
); );
} }
@@ -1,18 +1,21 @@
import { IconButton } from '@chakra-ui/button'; import { IconButton } from '@chakra-ui/button';
import { FiSkipBack } from 'react-icons/fi'; import { IoPlaySkipBack } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function PrevIconBtn(props) { export default function PrevIconBtn(props) {
const { clickhandler, ...rest } = props; const { clickhandler, ...rest } = props;
return ( return (
<IconButton <Tooltip label='Previous event' openDelay={500} shouldWrapChildren={props.disabled}>
icon={<FiSkipBack />} <IconButton
colorScheme='whiteAlpha' icon={<IoPlaySkipBack size='22px' />}
backgroundColor='#ffffff11' colorScheme='whiteAlpha'
variant='outline' backgroundColor='#ffffff11'
onClick={clickhandler} variant='outline'
width={90} onClick={clickhandler}
_focus={{ boxShadow: 'none' }} width={90}
{...rest} _focus={{ boxShadow: 'none' }}
/> {...rest}
/>
</Tooltip>
); );
} }
@@ -1,18 +1,21 @@
import { IconButton } from '@chakra-ui/button'; import { IconButton } from '@chakra-ui/button';
import { FiRefreshCcw } from 'react-icons/fi'; import { IoReload } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function ReloadIconButton(props) { export default function ReloadIconButton(props) {
const { clickhandler, ...rest } = props; const { clickhandler, ...rest } = props;
return ( return (
<IconButton <Tooltip label='Reload event' openDelay={500} shouldWrapChildren={props.disabled}>
icon={<FiRefreshCcw />} <IconButton
colorScheme='whiteAlpha' icon={<IoReload size='22px' />}
backgroundColor='#ffffff05' colorScheme='whiteAlpha'
variant='outline' backgroundColor='#ffffff05'
onClick={clickhandler} variant='outline'
width={90} onClick={clickhandler}
_focus={{ boxShadow: 'none' }} width={90}
{...rest} _focus={{ boxShadow: 'none' }}
/> {...rest}
/>
</Tooltip>
); );
} }
@@ -1,17 +1,20 @@
import { IconButton } from '@chakra-ui/button'; import { IconButton } from '@chakra-ui/button';
import { FiClock } from 'react-icons/fi'; import { IoTimeOutline } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function RollIconBtn(props) { export default function RollIconBtn(props) {
const { clickhandler, active, ...rest } = props; const { clickhandler, active, ...rest } = props;
return ( return (
<IconButton <Tooltip label='Roll mode' openDelay={500} shouldWrapChildren={props.disabled}>
icon={<FiClock />} <IconButton
colorScheme='blue' icon={<IoTimeOutline size='24px' />}
variant={active ? 'solid' : 'outline'} colorScheme='blue'
onClick={clickhandler} variant={active ? 'solid' : 'outline'}
width={120} onClick={clickhandler}
_focus={{ boxShadow: 'none' }} width={120}
{...rest} _focus={{ boxShadow: 'none' }}
/> {...rest}
/>
</Tooltip>
); );
} }
@@ -1,17 +1,20 @@
import { IconButton } from '@chakra-ui/button'; import { IconButton } from '@chakra-ui/button';
import { FiPlay } from 'react-icons/fi'; import { IoPlay } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function StartIconBtn(props) { export default function StartIconBtn(props) {
const { clickhandler, active, ...rest } = props; const { clickhandler, active, ...rest } = props;
return ( return (
<IconButton <Tooltip label='Start timer' openDelay={500} shouldWrapChildren={props.disabled}>
icon={<FiPlay />} <IconButton
colorScheme='green' icon={<IoPlay size='24px' />}
variant={active ? 'solid' : 'outline'} colorScheme='green'
onClick={clickhandler} variant={active ? 'solid' : 'outline'}
width={120} onClick={clickhandler}
_focus={{ boxShadow: 'none' }} width={120}
{...rest} _focus={{ boxShadow: 'none' }}
/> {...rest}
/>
</Tooltip>
); );
} }
@@ -1,18 +1,20 @@
import { IconButton } from '@chakra-ui/button'; import { IconButton } from '@chakra-ui/button';
import { FiXOctagon } from 'react-icons/fi'; import { IoStop } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function UnloadIconBtn(props) { export default function UnloadIconBtn(props) {
const { clickhandler, ...rest } = props; const { clickhandler, ...rest } = props;
return ( return (
<IconButton <Tooltip label='Unload event' openDelay={500} shouldWrapChildren={props.disabled}>
icon={<FiXOctagon />} <IconButton
colorScheme='red' icon={<IoStop size='22px' />}
backgroundColor='#ff000022' colorScheme='red'
variant='outline' variant='outline'
onClick={clickhandler} onClick={clickhandler}
width={90} width={90}
_focus={{ boxShadow: 'none' }} _focus={{ boxShadow: 'none' }}
{...rest} {...rest}
/> />
</Tooltip>
); );
} }
@@ -1,19 +1,22 @@
import { IconButton } from '@chakra-ui/button'; import { IconButton } from '@chakra-ui/button';
import { FiSun } from 'react-icons/fi'; import { IoSunny } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function VisibleIconBtn(props) { export default function VisibleIconBtn(props) {
const { actionHandler, active, ...rest } = props; const { actionHandler, active, ...rest } = props;
return ( return (
<IconButton <Tooltip label={active ? 'Make invisible' : 'Make visible'} openDelay={500}>
size={props.size || 'xs'} <IconButton
icon={<FiSun />} size={props.size || 'xs'}
colorScheme='blue' icon={<IoSunny size={'18px'}/>}
variant={active ? 'solid' : 'outline'} colorScheme='blue'
onClick={() => variant={active ? 'solid' : 'outline'}
actionHandler('update', { field: 'isPublic', value: !active }) onClick={() =>
} actionHandler('update', { field: 'isPublic', value: !active })
_focus={{ boxShadow: 'none' }} }
{...rest} _focus={{ boxShadow: 'none' }}
/> {...rest}
/>
</Tooltip>
); );
} }
@@ -19,7 +19,6 @@ class ErrorBoundary extends React.Component {
error: error, error: error,
errorInfo: info, errorInfo: info,
}); });
// TODO: Log the error to an error reporting service
this.context.emitError(error.toString()); this.context.emitError(error.toString());
} }
@@ -1,27 +1,33 @@
import EditableTimer from 'common/input/EditableTimer'; import EditableTimer from 'common/input/EditableTimer';
import { useContext } from 'react'; import { useContext } from 'react';
import { LoggingContext } from '../../../app/context/LoggingContext'; import { LoggingContext } from '../../../app/context/LoggingContext';
import { validateTimes } from '../../../app/entryValidator';
import PropTypes from 'prop-types';
export default function EventTimes(props) { export default function EventTimes(props) {
const { actionHandler, delay, timeStart, timeEnd } = props; const { actionHandler, delay, timeStart, timeEnd, previousEnd } = props;
const { emitWarning } = useContext(LoggingContext); const { emitWarning } = useContext(LoggingContext);
const handleValidate = (entry, v) => { const handleValidate = (entry, val) => {
// we dont enforce validation here if (val == null || timeStart == null || timeEnd == null) return true;
if (v == null || timeStart == null || timeEnd == null) return true;
if (timeStart === 0) return true; if (timeStart === 0) return true;
let validate = { value: true, catch: '' }; let start = timeStart;
if (entry === 'timeStart' && v > timeEnd) { let end = timeEnd;
validate.catch = 'Start time later than end time'; if (entry === 'timeStart') {
} else if (entry === 'timeEnd' && v < timeStart) { start = val;
validate.catch = 'End time earlier than start time'; } else if (entry === 'timeEnd') {
end = val;
} else {
return;
} }
if (validate.catch !== '') const valid = validateTimes(start, end);
emitWarning(`Time Input Warning: ${validate.catch}`); // give warning but not enforce validation
return validate.value; if (!valid.value) {
emitWarning(`Time Input Warning: ${valid.catch}`);
}
return valid.value;
}; };
return ( return (
@@ -32,6 +38,7 @@ export default function EventTimes(props) {
actionHandler={actionHandler} actionHandler={actionHandler}
time={timeStart} time={timeStart}
delay={delay} delay={delay}
previousEnd={previousEnd}
/> />
<EditableTimer <EditableTimer
name='timeEnd' name='timeEnd'
@@ -39,7 +46,16 @@ export default function EventTimes(props) {
actionHandler={actionHandler} actionHandler={actionHandler}
time={timeEnd} time={timeEnd}
delay={delay} delay={delay}
previousEnd={previousEnd}
/> />
</> </>
); );
} }
EventTimes.propTypes = {
actionHandler: PropTypes.func.isRequired,
delay: PropTypes.number.isRequired,
timeStart: PropTypes.number.isRequired,
timeEnd: PropTypes.number.isRequired,
previousEnd: PropTypes.number.isRequired,
};
@@ -2,6 +2,8 @@ import EditableTimer from 'common/input/EditableTimer';
import { stringFromMillis } from 'ontime-utils/time'; import { stringFromMillis } from 'ontime-utils/time';
import { useContext } from 'react'; import { useContext } from 'react';
import { LoggingContext } from '../../../app/context/LoggingContext'; import { LoggingContext } from '../../../app/context/LoggingContext';
import { validateTimes } from '../../../app/entryValidator';
import PropTypes from 'prop-types';
const label = { const label = {
fontSize: '0.75em', fontSize: '0.75em',
@@ -9,8 +11,7 @@ const label = {
}; };
const TimesDelayed = (props) => { const TimesDelayed = (props) => {
const { handleValidate, actionHandler, delay, timeStart, timeEnd, duration } = const { handleValidate, actionHandler, delay, timeStart, timeEnd, duration, previousEnd } = props;
props;
const scheduledStart = stringFromMillis(timeStart, false); const scheduledStart = stringFromMillis(timeStart, false);
const scheduledEnd = stringFromMillis(timeEnd, false); const scheduledEnd = stringFromMillis(timeEnd, false);
@@ -26,6 +27,7 @@ const TimesDelayed = (props) => {
actionHandler={actionHandler} actionHandler={actionHandler}
time={timeStart} time={timeStart}
delay={delay} delay={delay}
previousEnd={previousEnd}
/> />
<span style={label}> <span style={label}>
End <span>{scheduledEnd}</span> End <span>{scheduledEnd}</span>
@@ -36,6 +38,7 @@ const TimesDelayed = (props) => {
actionHandler={actionHandler} actionHandler={actionHandler}
time={timeEnd} time={timeEnd}
delay={delay} delay={delay}
previousEnd={previousEnd}
/> />
<span style={label}>Duration</span> <span style={label}>Duration</span>
<EditableTimer <EditableTimer
@@ -44,13 +47,24 @@ const TimesDelayed = (props) => {
actionHandler={actionHandler} actionHandler={actionHandler}
time={duration} time={duration}
delay={0} delay={0}
previousEnd={previousEnd}
/> />
</> </>
); );
}; };
TimesDelayed.propTypes = {
handleValidate: PropTypes.func.isRequired,
actionHandler: PropTypes.func.isRequired,
delay: PropTypes.number.isRequired,
timeStart: PropTypes.number.isRequired,
timeEnd: PropTypes.number.isRequired,
duration: PropTypes.number.isRequired,
previousEnd: PropTypes.number.isRequired,
};
const Times = (props) => { const Times = (props) => {
const { handleValidate, actionHandler, timeStart, timeEnd, duration } = props; const { handleValidate, actionHandler, timeStart, timeEnd, duration, previousEnd } = props;
return ( return (
<> <>
@@ -61,6 +75,7 @@ const Times = (props) => {
actionHandler={actionHandler} actionHandler={actionHandler}
time={timeStart} time={timeStart}
delay={0} delay={0}
previousEnd={previousEnd}
/> />
<span style={label}>End</span> <span style={label}>End</span>
<EditableTimer <EditableTimer
@@ -69,6 +84,7 @@ const Times = (props) => {
actionHandler={actionHandler} actionHandler={actionHandler}
time={timeEnd} time={timeEnd}
delay={0} delay={0}
previousEnd={previousEnd}
/> />
<span style={label}>Duration</span> <span style={label}>Duration</span>
<EditableTimer <EditableTimer
@@ -77,52 +93,73 @@ const Times = (props) => {
actionHandler={actionHandler} actionHandler={actionHandler}
time={duration} time={duration}
delay={0} delay={0}
previousEnd={previousEnd}
/> />
</> </>
); );
}; };
Times.propTypes = {
handleValidate: PropTypes.func.isRequired,
actionHandler: PropTypes.func.isRequired,
timeStart: PropTypes.number.isRequired,
timeEnd: PropTypes.number.isRequired,
duration: PropTypes.number.isRequired,
previousEnd: PropTypes.number.isRequired,
};
export default function EventTimesVertical(props) { export default function EventTimesVertical(props) {
const { delay, timeStart, timeEnd, duration } = props; const { delay, timeStart, timeEnd, duration, previousEnd } = props;
const { emitWarning } = useContext(LoggingContext); const { emitWarning } = useContext(LoggingContext);
const handleValidate = (entry, v) => { const handleValidate = (entry, val) => {
// we dont enforce validation here if (val == null || timeStart == null || timeEnd == null) return true;
if (v == null || timeStart == null || timeEnd == null) return true;
if (timeStart === 0) return true; if (timeStart === 0) return true;
let validate = { value: true, catch: '' }; let start = timeStart;
if (entry === 'timeStart' && v > timeEnd) { let end = timeEnd;
validate.catch = 'Start time later than end time'; if (entry === 'timeStart') {
} else if (entry === 'timeEnd' && v < timeStart) { start = val;
validate.catch = 'End time earlier than start time'; } else if (entry === 'timeEnd') {
end = val;
} else {
return;
} }
if (validate.catch !== '') { const valid = validateTimes(start, end);
emitWarning(`Time Input Warning: ${validate.catch}`); // give warning but not enforce validation
if (!valid.value) {
emitWarning(`Time Input Warning: ${valid.catch}`);
} }
return validate.value; return valid.value;
}; };
return ( return delay != null && delay > 0 ? (
(delay != null) && (delay > 0) ? ( <TimesDelayed
<TimesDelayed handleValidate={handleValidate}
handleValidate={handleValidate} actionHandler={props.actionHandler}
actionHandler={props.actionHandler} delay={delay}
delay={delay} timeStart={timeStart}
timeStart={timeStart} timeEnd={timeEnd}
timeEnd={timeEnd} duration={duration}
duration={duration} previousEnd={previousEnd}
/> />
) : ( ) : (
<Times <Times
handleValidate={handleValidate} handleValidate={handleValidate}
actionHandler={props.actionHandler} actionHandler={props.actionHandler}
timeStart={timeStart} timeStart={timeStart}
timeEnd={timeEnd} timeEnd={timeEnd}
duration={duration} duration={duration}
/> previousEnd={previousEnd}
) />
) );
} }
EventTimesVertical.propTypes = {
delay: PropTypes.number.isRequired,
timeStart: PropTypes.number.isRequired,
timeEnd: PropTypes.number.isRequired,
duration: PropTypes.number.isRequired,
previousEnd: PropTypes.number.isRequired,
};
+16 -7
View File
@@ -16,6 +16,8 @@ export default function NavLogo(props) {
// Handle keyboard shortcuts // Handle keyboard shortcuts
const handleKeyPress = useCallback((e) => { const handleKeyPress = useCallback((e) => {
// handle held key
if (e.repeat) return;
// Space bar // Space bar
if (e.keyCode === 32) { if (e.keyCode === 32) {
setShowNav((s) => !s); setShowNav((s) => !s);
@@ -54,44 +56,51 @@ export default function NavLogo(props) {
className={showNav ? style.nav : style.navHidden} className={showNav ? style.nav : style.navHidden}
> >
<Link <Link
to='/presenter' to='/timer'
className={style.navItem} className={style.navItem}
tabIndex={1} tabIndex={1}
> >
Presenter Timer
</Link>
<Link
to='/minimal'
className={style.navItem}
tabIndex={2}
>
Minimal Timer
</Link> </Link>
<Link <Link
to='/sm' to='/sm'
className={style.navItem} className={style.navItem}
tabIndex={2} tabIndex={3}
> >
Backstage Backstage
</Link> </Link>
<Link <Link
to='/public' to='/public'
className={style.navItem} className={style.navItem}
tabIndex={3} tabIndex={4}
> >
Public Public
</Link> </Link>
<Link <Link
to='/lower' to='/lower'
className={style.navItem} className={style.navItem}
tabIndex={4} tabIndex={5}
> >
Lower Thirds Lower Thirds
</Link> </Link>
<Link <Link
to='/pip' to='/pip'
className={style.navItem} className={style.navItem}
tabIndex={4} tabIndex={6}
> >
PIP PIP
</Link> </Link>
<Link <Link
to='/studio' to='/studio'
className={style.navItem} className={style.navItem}
tabIndex={5} tabIndex={7}
> >
Studio Clock Studio Clock
</Link> </Link>
@@ -1,10 +0,0 @@
import styles from './SmallTimer.module.css';
export default function SmallTimer({ label, time }) {
return (
<div className={styles.SmallTimer}>
<div className={styles.label}>{label}</div>
<div className={styles.timer}>{time}</div>
</div>
);
}
@@ -1,23 +0,0 @@
.smallTimer {
display: flex;
flex-direction: column;
}
.label,
.timer {
font-size: 1vw;
text-align: center;
}
.label {
font-size: 1.5vw;
color: #888;
margin-bottom: 0;
}
.timer {
font-family: 'Open Sans', sans-serif;
font-size: 4vw;
letter-spacing: 0.125em;
}
-22
View File
@@ -1,22 +0,0 @@
import { FormErrorMessage } from '@chakra-ui/form-control';
import { FormLabel } from '@chakra-ui/form-control';
import { FormControl } from '@chakra-ui/form-control';
import { Input } from '@chakra-ui/input';
import { Field } from 'formik';
export default function ChakraInput(props) {
const { label, name, ...rest } = props;
return (
<Field name={name}>
{({ field, form }) => {
return (
<FormControl isInvalid={form.errors[name] && form.touched[name]}>
<FormLabel htmlFor={name}>{label}</FormLabel>
<Input id={name} {...rest} {...field} />
<FormErrorMessage>{form.errors[name]}</FormErrorMessage>
</FormControl>
);
}}
</Field>
);
}
+30 -10
View File
@@ -1,15 +1,13 @@
import { Editable, EditableInput, EditablePreview } from '@chakra-ui/editable'; import { Editable, EditableInput, EditablePreview } from '@chakra-ui/editable';
import { useContext, useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
import { import { forgivingStringToMillis } from '../utils/dateConfig';
isTimeString,
timeStringToMillis,
} from '../utils/dateConfig';
import { stringFromMillis } from 'ontime-utils/time'; import { stringFromMillis } from 'ontime-utils/time';
import style from './EditableTimer.module.css'; import style from './EditableTimer.module.css';
import { LoggingContext } from '../../app/context/LoggingContext'; import { LoggingContext } from '../../app/context/LoggingContext';
import PropTypes from 'prop-types';
export default function EditableTimer(props) { export default function EditableTimer(props) {
const { name, actionHandler, time, delay, validate } = props; const { name, actionHandler, time, delay, validate, previousEnd } = props;
const { emitError } = useContext(LoggingContext); const { emitError } = useContext(LoggingContext);
const [value, setValue] = useState(''); const [value, setValue] = useState('');
@@ -33,13 +31,26 @@ export default function EditableTimer(props) {
// Check if there is anything there // Check if there is anything there
if (value === '') return false; if (value === '') return false;
// check if its valid time string let newValMillis;
if (!isTimeString(value)) return false;
// convert entered value to milliseconds // check for known aliases
const newValMillis = timeStringToMillis(value); if (value === 'p' || value === 'prev' || value === 'previous') {
// string to pass should be the time of the end before
if (previousEnd != null) {
newValMillis = previousEnd;
} else {
newValMillis = 0;
}
} else if (value.startsWith('+')) {
// string to pass should add to the end before
const val = value.substring(1);
newValMillis = previousEnd + forgivingStringToMillis(val);
} else {
// convert entered value to milliseconds
newValMillis = forgivingStringToMillis(value);
}
// Time now and time submitedVal // Time now and time submittedVal
const originalMillis = time + delay; const originalMillis = time + delay;
// check if time is different from before // check if time is different from before
@@ -67,3 +78,12 @@ export default function EditableTimer(props) {
</Editable> </Editable>
); );
} }
EditableTimer.propTypes = {
name: PropTypes.string.isRequired,
actionHandler: PropTypes.func.isRequired,
time: PropTypes.number.isRequired,
delay: PropTypes.number.isRequired,
validate: PropTypes.func.isRequired,
previousEnd: PropTypes.number.isRequired,
};
@@ -1,7 +1,9 @@
import { import {
formatDisplay, formatDisplay,
isTimeString,
millisToMinutes, millisToMinutes,
millisToSeconds, millisToSeconds,
forgivingStringToMillis,
timeStringToMillis, timeStringToMillis,
} from '../dateConfig'; } from '../dateConfig';
@@ -244,3 +246,59 @@ describe('test timeStringToMillis function', () => {
expect(timeStringToMillis(t.val)).toBe(t.result); expect(timeStringToMillis(t.val)).toBe(t.result);
}); });
}); });
describe('test isTimeString() function', () => {
test('it validates time strings', () => {
const ts = ['2', '2:10', '2:10:22'];
for (const s of ts) {
expect(isTimeString(s)).toBe(true);
}
});
test('it fails overloaded times', () => {
const ts = ['70', '89:10', '26:10:22'];
for (const s of ts) {
expect(isTimeString(s)).toBe(false);
}
});
});
describe('test isTimeString() function handle different separators', () => {
const ts = ['2:10', '2,10', '2.10'];
for (const s of ts) {
test(`it handles ${s}`, () => {
expect(isTimeString(s)).toBe(true);
});
}
});
describe('test timeHelper() function handles separators', () => {
const ts = ['1:2:3:10', '2,10', '2.10'];
for (const s of ts) {
test(`it handles ${s}`, () => {
expect(typeof forgivingStringToMillis(s)).toBe('number');
});
}
});
describe('test timeHelper() parses strings correctly', () => {
const ts = [
{ value: '', expect: 0 },
{ value: '0', expect: 0 },
{ value: '-0', expect: 0 },
{ value: '1', expect: 60 * 1000 },
{ value: '-1', expect: 60 * 1000 },
{ value: '1.2', expect: 60 * 1000 + 2 * 1000 },
{ value: '1.70', expect: 60 * 1000 + 70 * 1000 },
{ value: '1.1.1', expect: 60 * 60 * 1000 + 60 * 1000 + 1000 },
{ value: '12.1.1', expect: 12 * 60 * 60 * 1000 + 60 * 1000 + 1000 },
{ value: '12.55.1', expect: 12 * 60 * 60 * 1000 + 55 * 60 * 1000 + 1000 },
{ value: '12.55.40', expect: 12 * 60 * 60 * 1000 + 55 * 60 * 1000 + 40 * 1000 },
];
for (const s of ts) {
test(`it handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
+39 -14
View File
@@ -5,7 +5,6 @@ const mts = 1000; // millis to seconds
const mtm = 1000 * 60; // millis to minutes const mtm = 1000 * 60; // millis to minutes
const mth = 1000 * 60 * 60; // millis to hours const mth = 1000 * 60 * 60; // millis to hours
/** /**
* another go at simpler string formatting (counters) * another go at simpler string formatting (counters)
* @description Converts seconds to string representing time * @description Converts seconds to string representing time
@@ -13,8 +12,7 @@ const mth = 1000 * 60 * 60; // millis to hours
* @param {boolean} [hideZero] - whether to show hours in case its 00 * @param {boolean} [hideZero] - whether to show hours in case its 00
* @returns {string} String representing absolute time 00:12:02 * @returns {string} String representing absolute time 00:12:02
*/ */
export function formatDisplay(seconds, hideZero = false) {
export function formatDisplay(seconds, hideZero=false) {
// add an extra 0 if necessary // add an extra 0 if necessary
const format = (val) => `0${Math.floor(val)}`.slice(-2); const format = (val) => `0${Math.floor(val)}`.slice(-2);
@@ -31,8 +29,6 @@ export function formatDisplay(seconds, hideZero=false) {
* @param {number} millis - time in seconds * @param {number} millis - time in seconds
* @returns {number} Amount in seconds * @returns {number} Amount in seconds
*/ */
// millis to seconds
export const millisToSeconds = (millis) => { export const millisToSeconds = (millis) => {
return millis < 0 ? Math.ceil(millis / mts) : Math.floor(millis / mts); return millis < 0 ? Math.ceil(millis / mts) : Math.floor(millis / mts);
}; };
@@ -42,8 +38,6 @@ export const millisToSeconds = (millis) => {
* @param {number} millis - time in seconds * @param {number} millis - time in seconds
* @returns {number} Amount in seconds * @returns {number} Amount in seconds
*/ */
// millis to minutes
export const millisToMinutes = (millis) => { export const millisToMinutes = (millis) => {
return millis < 0 ? Math.ceil(millis / mtm) : Math.floor(millis / mtm); return millis < 0 ? Math.ceil(millis / mtm) : Math.floor(millis / mtm);
}; };
@@ -53,15 +47,12 @@ export const millisToMinutes = (millis) => {
* @param {string} string - time string "23:00:12" * @param {string} string - time string "23:00:12"
* @returns {number} Amount in milliseconds * @returns {number} Amount in milliseconds
*/ */
// timeStringToMillis
export const timeStringToMillis = (string) => { export const timeStringToMillis = (string) => {
if (typeof string !== 'string') return 0; if (typeof string !== 'string') return 0;
const time = string.split(':'); const time = string.split(':');
if (time.length === 1) return Math.abs(time[0] * mts); if (time.length === 1) return Math.abs(time[0] * mts);
if (time.length === 2) return Math.abs(time[0]) * mtm + time[1] * mts; if (time.length === 2) return Math.abs(time[0]) * mtm + time[1] * mts;
if (time.length === 3) if (time.length === 3) return Math.abs(time[0]) * mth + time[1] * mtm + time[2] * mts;
return Math.abs(time[0]) * mth + time[1] * mtm + time[2] * mts;
else return 0; else return 0;
}; };
@@ -70,8 +61,6 @@ export const timeStringToMillis = (string) => {
* @param {string} string - time string "23:00:12" * @param {string} string - time string "23:00:12"
* @returns {boolean} string represents time * @returns {boolean} string represents time
*/ */
// isTimeString
export const isTimeString = (string) => { export const isTimeString = (string) => {
// ^ # Start of string // ^ # Start of string
// (?: # Try to match... // (?: # Try to match...
@@ -83,6 +72,42 @@ export const isTimeString = (string) => {
// ([0-5]?\d) # SS (required) // ([0-5]?\d) # SS (required)
// $ # End of string // $ # End of string
const regex = /^(?:(?:([01]?\d|2[0-3]):)?([0-5]?\d):)?([0-5]?\d)$/; const regex = /^(?:(?:([01]?\d|2[0-3])[:,.])?([0-5]?\d)[:,.])?([0-5]?\d)$/;
return regex.test(string); return regex.test(string);
}; };
const parse = (valueAsString) => {
const parsed = parseInt(valueAsString, 10);
if (isNaN(parsed)) {
return 0;
}
return Math.abs(parsed);
};
/**
* @description Parses a time string to millis
* @param string - time string
* @returns {number} - time string in millis
*/
export const forgivingStringToMillis = (string) => {
let millis = 0;
// split string at known separators : , .
const separatorRegex = /[\s,:.]+/;
const [first, second, third] = string.split(separatorRegex);
if (first != null && second != null && third != null) {
// if string has three sections, treat as [hours] [minutes] [seconds]
millis = parse(first) * mth;
millis += parse(second) * mtm;
millis += parse(third) * mts;
} else if (third == null) {
// if string has two sections, treat as [minutes] [seconds]
millis = parse(first) * mtm;
millis += parse(second) * mts;
} else if (second == null) {
// if string has one section, treat as [minutes]
millis = parse(first) * mtm;
}
return millis;
};
+21 -21
View File
@@ -1,8 +1,8 @@
import {Editable, EditableInput, EditablePreview} from '@chakra-ui/editable'; import { Editable, EditableInput, EditablePreview } from '@chakra-ui/editable';
import {Switch} from "@chakra-ui/react"; import { useEffect, useState } from 'react';
import {useEffect, useState} from 'react'; import { useSocket } from 'app/context/socketContext';
import {useSocket} from 'app/context/socketContext';
import VisibleIconBtn from 'common/components/buttons/VisibleIconBtn'; import VisibleIconBtn from 'common/components/buttons/VisibleIconBtn';
import OnAirIconBtn from '../../common/components/buttons/OnAirIconBtn';
import style from './MessageControl.module.scss'; import style from './MessageControl.module.scss';
const inputProps = { const inputProps = {
@@ -10,7 +10,7 @@ const inputProps = {
}; };
const InputRow = (props) => { const InputRow = (props) => {
const {label, placeholder, text, visible} = props; const { label, placeholder, text, visible } = props;
return ( return (
<> <>
@@ -23,8 +23,8 @@ const InputRow = (props) => {
className={style.inline} className={style.inline}
color={text === '' ? '#666' : 'inherit'} color={text === '' ? '#666' : 'inherit'}
> >
<EditablePreview className={style.padleft}/> <EditablePreview className={style.padleft} />
<EditableInput className={style.padleft}/> <EditableInput className={style.padleft} />
</Editable> </Editable>
<VisibleIconBtn <VisibleIconBtn
active={visible || undefined} active={visible || undefined}
@@ -55,19 +55,19 @@ export default function MessageControl() {
useEffect(() => { useEffect(() => {
if (socket == null) return; if (socket == null) return;
// Handle presenter messages // Handle timer messages
socket.on('messages-presenter', (data) => { socket.on('messages-timer', (data) => {
setPres({...data}); setPres({ ...data });
}); });
// Handle public messages // Handle public messages
socket.on('messages-public', (data) => { socket.on('messages-public', (data) => {
setPubl({...data}); setPubl({ ...data });
}); });
// Handle lower third messages // Handle lower third messages
socket.on('messages-lower', (data) => { socket.on('messages-lower', (data) => {
setLower({...data}); setLower({ ...data });
}); });
// Handle lower third messages // Handle lower third messages
@@ -83,7 +83,7 @@ export default function MessageControl() {
// Clear listeners // Clear listeners
return () => { return () => {
socket.off('messages-public'); socket.off('messages-public');
socket.off('messages-presenter'); socket.off('messages-timer');
socket.off('messages-lower'); socket.off('messages-lower');
socket.off('onAir'); socket.off('onAir');
}; };
@@ -92,10 +92,10 @@ export default function MessageControl() {
const messageControl = async (action, payload) => { const messageControl = async (action, payload) => {
switch (action) { switch (action) {
case 'pres-text': case 'pres-text':
socket.emit('set-presenter-text', payload); socket.emit('set-timer-text', payload);
break; break;
case 'toggle-pres-visible': case 'toggle-pres-visible':
socket.emit('set-presenter-visible', !pres.visible); socket.emit('set-timer-visible', !pres.visible);
break; break;
case 'publ-text': case 'publ-text':
socket.emit('set-public-text', payload); socket.emit('set-public-text', payload);
@@ -146,13 +146,13 @@ export default function MessageControl() {
/> />
</div> </div>
<div className={style.onAirToggle}> <div className={style.onAirToggle}>
<Switch <OnAirIconBtn
colorScheme='green' className={style.btn}
active={onAir}
size='md' size='md'
isChecked={onAir} actionHandler={() => messageControl('toggle-onAir')}
onChange={() => messageControl('toggle-onAir')}> />
On Air? <span className={style.onAirLabel}>On Air</span>
</Switch>
<span className={style.oscLabel}> <span className={style.oscLabel}>
{`/ontime/offAir << OSC >> /ontime/onAir`} {`/ontime/offAir << OSC >> /ontime/onAir`}
</span> </span>
@@ -1,12 +1,12 @@
@use '../../styles/main' as *;
@use '../../styles/mixins' as *;
.messageContainer, .messageContainer,
.onAirToggle { .onAirToggle {
background-color: rgba(0, 0, 0, 0.05); @include main-container;
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 4px;
display: flex; display: flex;
gap: 0.5em; gap: 0.5em;
padding: 0.5em; padding: 0.5em;
} }
.messageContainer { .messageContainer {
@@ -17,17 +17,20 @@
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
gap: 1em; gap: 1em;
} }
.label { .label {
padding: 0; padding: 0;
margin: 0; margin: 0;
font-size: 0.9em; font-size: 0.9em;
color: #ccc; color: #ccc;
} }
.inline { .inline {
border-radius: 4px; border-radius: 4px;
background-color: rgba(255, 255, 255, 0.05); background-color: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.05);
} }
.padleft { .padleft {
padding-left: 0.5em; padding-left: 0.5em;
} }
@@ -35,20 +38,25 @@
.onAirToggle { .onAirToggle {
margin-top: 1em; margin-top: 1em;
display: flex;
gap: 1em;
align-items: center; align-items: center;
line-height: 3em; display: grid;
grid-template-areas:
'btn label'
'btn osc';
grid-template-columns: 2.5em 1fr;
grid-template-rows: 1.2em 0.8em;
.btn {
grid-area: btn;
}
.onAirLabel { .onAirLabel {
grid-area: label;
font-size: 1.2em; font-size: 1.2em;
} }
.oscLabel { .oscLabel {
color: #4bffabcc; @include osc-label;
font-size: 0.8em; grid-area: osc;
float: right;
padding-right: 1em;
-webkit-user-select: text;
user-select: text;
} }
} }
+63 -39
View File
@@ -1,37 +1,37 @@
import style from './PlaybackControl.module.scss'; import style from './PlaybackControl.module.scss';
import Countdown from 'common/components/countdown/Countdown'; import Countdown from 'common/components/countdown/Countdown';
import { stringFromMillis } from 'ontime-utils/time'; import { stringFromMillis } from 'ontime-utils/time';
import {Tooltip} from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import {Button} from '@chakra-ui/button'; import { Button } from '@chakra-ui/button';
import {memo} from 'react'; import { memo } from 'react';
import PropTypes from "prop-types"; import PropTypes from 'prop-types';
const areEqual = (prevProps, nextProps) => { const areEqual = (prevProps, nextProps) => {
return ( return (
prevProps.timer.running === nextProps.timer.running prevProps.timer.running === nextProps.timer.running &&
&& prevProps.timer.expectedFinish === nextProps.timer.expectedFinish prevProps.timer.expectedFinish === nextProps.timer.expectedFinish &&
&& prevProps.timer.startedAt === nextProps.timer.startedAt prevProps.timer.startedAt === nextProps.timer.startedAt &&
&& prevProps.playback === nextProps.playback prevProps.playback === nextProps.playback &&
&& prevProps.timer.secondary === nextProps.timer.secondary prevProps.timer.secondary === nextProps.timer.secondary &&
&& prevProps.selectedId === nextProps.selectedId prevProps.selectedId === nextProps.selectedId
); );
}; };
const PlaybackTimer = (props) => { const PlaybackTimer = (props) => {
const {timer, playback, handleIncrement, selectedId} = props; const { timer, playback, handleIncrement, selectedId } = props;
const started = stringFromMillis(timer.startedAt, true); const started = stringFromMillis(timer.startedAt, true);
const finish = stringFromMillis(timer.expectedFinish, true); const finish = stringFromMillis(timer.expectedFinish, true);
const isNegative = timer.running < 0; const isNegative = timer.running < 0;
const isRolling = playback === 'roll'; const isRolling = playback === 'roll';
const isWaiting = timer.secondary > 0 && timer.running == null; const isWaiting = timer.secondary > 0 && timer.running == null;
const disableButtons = (selectedId == null || isRolling); const disableButtons = selectedId == null || isRolling;
const incrementProps = { const incrementProps = {
size: 'sm', size: 'sm',
width: '2.9em', width: '2.9em',
colorScheme: 'whiteAlpha', colorScheme: 'whiteAlpha',
variant: 'outline', variant: 'outline',
_focus: {boxShadow: 'none'}, _focus: { boxShadow: 'none' },
}; };
return ( return (
@@ -39,12 +39,12 @@ const PlaybackTimer = (props) => {
<div className={style.timeContainer}> <div className={style.timeContainer}>
<div className={style.indicators}> <div className={style.indicators}>
<Tooltip label='Roll mode active'> <Tooltip label='Roll mode active'>
<div className={isRolling ? style.indRollActive : style.indRoll}/> <div className={isRolling ? style.indRollActive : style.indRoll} />
</Tooltip> </Tooltip>
<div <div
className={isNegative ? style.indNegativeActive : style.indNegative} className={isNegative ? style.indNegativeActive : style.indNegative}
/> />
<div className={style.indDelay}/> <div className={style.indDelay} />
</div> </div>
<div className={style.timer}> <div className={style.timer}>
<Countdown <Countdown
@@ -71,34 +71,58 @@ const PlaybackTimer = (props) => {
</> </>
)} )}
<div className={style.btn}> <div className={style.btn}>
<Button <Tooltip
{...incrementProps} label={'Remove 1 minute'}
disabled={disableButtons} delay={500}
onClick={() => handleIncrement(-1)} shouldWrapChildren={disableButtons}
> >
-1 <Button
</Button> {...incrementProps}
<Button disabled={disableButtons}
{...incrementProps} onClick={() => handleIncrement(-1)}
disabled={disableButtons} >
onClick={() => handleIncrement(1)} -1
</Button>
</Tooltip>
<Tooltip
label={'Add 1 minute'}
delay={500}
shouldWrapChildren={disableButtons}
> >
+1 <Button
</Button> {...incrementProps}
<Button disabled={disableButtons}
{...incrementProps} onClick={() => handleIncrement(1)}
disabled={disableButtons} >
onClick={() => handleIncrement(-5)} +1
</Button>
</Tooltip>
<Tooltip
label={'Remove 5 minutes'}
delay={500}
shouldWrapChildren={disableButtons}
> >
-5 <Button
</Button> {...incrementProps}
<Button disabled={disableButtons}
{...incrementProps} onClick={() => handleIncrement(-5)}
disabled={disableButtons} >
onClick={() => handleIncrement(5)} -5
</Button>
</Tooltip>
<Tooltip
label={'Add 5 minutes'}
delay={500}
shouldWrapChildren={disableButtons}
> >
+5 <Button
</Button> {...incrementProps}
disabled={disableButtons}
onClick={() => handleIncrement(5)}
>
+5
</Button>
</Tooltip>
</div> </div>
</div> </div>
</> </>
@@ -1,8 +1,9 @@
import { Draggable } from 'react-beautiful-dnd'; import { Draggable } from 'react-beautiful-dnd';
import { FiMoreVertical } from 'react-icons/fi'; import { FiMoreVertical } from 'react-icons/fi';
import DeleteIconBtn from 'common/components/buttons/DeleteIconBtn'; import DeleteIconBtn from 'common/components/buttons/DeleteIconBtn';
import ActionButtons from './ActionButtons'; import ActionButtons from '../list/ActionButtons';
import style from './BlockBlock.module.css'; import style from './BlockBlock.module.css';
import PropTypes from 'prop-types';
export default function BlockBlock(props) { export default function BlockBlock(props) {
const { index, data, actionHandler } = props; const { index, data, actionHandler } = props;
@@ -27,3 +28,10 @@ export default function BlockBlock(props) {
</Draggable> </Draggable>
); );
} }
BlockBlock.propTypes = {
index: PropTypes.number.isRequired,
data: PropTypes.object.isRequired,
actionHandler: PropTypes.func.isRequired,
};
@@ -1,11 +1,12 @@
import { Draggable } from 'react-beautiful-dnd'; import { Draggable } from 'react-beautiful-dnd';
import { FiMoreVertical } from 'react-icons/fi'; import { FiMoreVertical } from 'react-icons/fi';
import { millisToMinutes } from 'common/utils/dateConfig'; import { millisToMinutes } from 'common/utils/dateConfig';
import ActionButtons from './ActionButtons'; import ActionButtons from '../list/ActionButtons';
import DeleteIconBtn from 'common/components/buttons/DeleteIconBtn'; import DeleteIconBtn from 'common/components/buttons/DeleteIconBtn';
import ApplyIconBtn from 'common/components/buttons/ApplyIconBtn'; import ApplyIconBtn from 'common/components/buttons/ApplyIconBtn';
import DelayInput from 'common/input/DelayInput'; import DelayInput from 'common/input/DelayInput';
import style from './DelayBlock.module.css'; import style from './DelayBlock.module.css';
import PropTypes from 'prop-types';
export default function DelayBlock(props) { export default function DelayBlock(props) {
const { eventsHandler, data, index, actionHandler } = props; const { eventsHandler, data, index, actionHandler } = props;
@@ -14,25 +15,15 @@ export default function DelayBlock(props) {
eventsHandler('applyDelay', { id: data.id, duration: data.duration }); eventsHandler('applyDelay', { id: data.id, duration: data.duration });
}; };
let delayValue = let delayValue = data.duration != null ? millisToMinutes(data.duration) : undefined;
data.duration != null ? millisToMinutes(data.duration) : undefined;
return ( return (
<Draggable key={data.id} draggableId={data.id} index={index}> <Draggable key={data.id} draggableId={data.id} index={index}>
{(provided) => ( {(provided) => (
<div <div className={style.delay} {...provided.draggableProps} ref={provided.innerRef}>
className={style.delay}
{...provided.draggableProps}
ref={provided.innerRef}
>
<span className={style.drag} {...provided.dragHandleProps}> <span className={style.drag} {...provided.dragHandleProps}>
<FiMoreVertical /> <FiMoreVertical />
</span> </span>
<DelayInput <DelayInput className={style.input} value={delayValue} actionHandler={actionHandler} />
className={style.input}
value={delayValue}
actionHandler={actionHandler}
/>
<div className={style.actionOverlay}> <div className={style.actionOverlay}>
<ApplyIconBtn clickhandler={applyDelayHandler} /> <ApplyIconBtn clickhandler={applyDelayHandler} />
<DeleteIconBtn actionHandler={actionHandler} /> <DeleteIconBtn actionHandler={actionHandler} />
@@ -43,3 +34,10 @@ export default function DelayBlock(props) {
</Draggable> </Draggable>
); );
} }
DelayBlock.propTypes = {
eventsHandler: PropTypes.func.isRequired,
data: PropTypes.object.isRequired,
index: PropTypes.number.isRequired,
actionHandler: PropTypes.func.isRequired,
};
@@ -5,17 +5,17 @@ import { Draggable } from 'react-beautiful-dnd';
import EventTimes from 'common/components/eventTimes/EventTimes'; import EventTimes from 'common/components/eventTimes/EventTimes';
import EventTimesVertical from 'common/components/eventTimes/EventTimesVertical'; import EventTimesVertical from 'common/components/eventTimes/EventTimesVertical';
import EditableText from 'common/input/EditableText'; import EditableText from 'common/input/EditableText';
import ActionButtons from './ActionButtons'; import ActionButtons from '../list/ActionButtons';
import PublicIconBtn from 'common/components/buttons/PublicIconBtn'; import PublicIconBtn from 'common/components/buttons/PublicIconBtn';
import DeleteIconBtn from 'common/components/buttons/DeleteIconBtn'; import DeleteIconBtn from 'common/components/buttons/DeleteIconBtn';
import { millisToMinutes } from 'common/utils/dateConfig'; import { millisToMinutes } from 'common/utils/dateConfig';
import style from './EventBlock.module.css'; import style from './EventBlock.module.css';
import { SelectCollapse, HandleCollapse } from 'app/context/collapseAtom'; import { HandleCollapse, SelectCollapse } from 'app/context/collapseAtom';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import PropTypes from 'prop-types';
const ExpandedBlock = (props) => { const ExpandedBlock = (props) => {
const { provided, data, eventIndex, next, delay, delayValue, actionHandler } = const { provided, data, eventIndex, next, delay, delayValue, previousEnd, actionHandler } = props;
props;
const oscid = data.id.length > 4 ? '...' : data.id; const oscid = data.id.length > 4 ? '...' : data.id;
@@ -28,14 +28,12 @@ const ExpandedBlock = (props) => {
return ( return (
<> <>
<span className={style.drag} {...provided.dragHandleProps}> <span className={style.drag} {...provided.dragHandleProps}>
<FiMoreVertical /> <FiMoreVertical />
</span> </span>
<div className={style.indicators}> <div className={style.indicators}>
<span className={next ? style.next : style.nextDisabled}>Next</span> <span className={next ? style.next : style.nextDisabled}>Next</span>
{delayValue != null && ( {delayValue != null && <span className={style.delayValue}>+ {delayValue}</span>}
<span className={style.delayValue}>+ {delayValue}</span>
)}
</div> </div>
<div className={style.timeExpanded}> <div className={style.timeExpanded}>
<EventTimesVertical <EventTimesVertical
@@ -44,6 +42,7 @@ const ExpandedBlock = (props) => {
timeEnd={data.timeEnd} timeEnd={data.timeEnd}
duration={duration} duration={duration}
delay={delay} delay={delay}
previousEnd={previousEnd}
className={style.time} className={style.time}
/> />
</div> </div>
@@ -53,25 +52,19 @@ const ExpandedBlock = (props) => {
label='Title' label='Title'
defaultValue={data.title} defaultValue={data.title}
placeholder='Add Title' placeholder='Add Title'
submitHandler={(v) => submitHandler={(v) => actionHandler('update', { field: 'title', value: v })}
actionHandler('update', { field: 'title', value: v })
}
/> />
<EditableText <EditableText
label='Presenter' label='Presenter'
defaultValue={data.presenter} defaultValue={data.presenter}
placeholder='Add Presenter name' placeholder='Add Presenter name'
submitHandler={(v) => submitHandler={(v) => actionHandler('update', { field: 'presenter', value: v })}
actionHandler('update', { field: 'presenter', value: v })
}
/> />
<EditableText <EditableText
label='Subtitle' label='Subtitle'
defaultValue={data.subtitle} defaultValue={data.subtitle}
placeholder='Add Subtitle' placeholder='Add Subtitle'
submitHandler={(v) => submitHandler={(v) => actionHandler('update', { field: 'subtitle', value: v })}
actionHandler('update', { field: 'subtitle', value: v })
}
/> />
<EditableText <EditableText
label='Note' label='Note'
@@ -79,9 +72,7 @@ const ExpandedBlock = (props) => {
placeholder='Add Note' placeholder='Add Note'
style={{ color: '#d69e2e' }} style={{ color: '#d69e2e' }}
maxchar={160} maxchar={160}
submitHandler={(v) => submitHandler={(v) => actionHandler('update', { field: 'note', value: v })}
actionHandler('update', { field: 'note', value: v })
}
/> />
<span className={style.oscLabel}> <span className={style.oscLabel}>
{`/ontime/goto ${eventIndex + 1} << OSC >> /ontime/gotoid ${oscid}`} {`/ontime/goto ${eventIndex + 1} << OSC >> /ontime/gotoid ${oscid}`}
@@ -89,38 +80,43 @@ const ExpandedBlock = (props) => {
</div> </div>
<div className={style.actionOverlay}> <div className={style.actionOverlay}>
<PublicIconBtn actionHandler={actionHandler} active={data.isPublic} /> <PublicIconBtn actionHandler={actionHandler} active={data.isPublic} />
<ActionButtons <ActionButtons showAdd showDelay showBlock actionHandler={actionHandler} />
showAdd
showDelay
showBlock
actionHandler={actionHandler}
/>
<DeleteIconBtn actionHandler={actionHandler} /> <DeleteIconBtn actionHandler={actionHandler} />
</div> </div>
</> </>
); );
}; };
ExpandedBlock.propTypes = {
provided: PropTypes.any.isRequired,
data: PropTypes.object.isRequired,
eventIndex: PropTypes.number.isRequired,
next: PropTypes.bool.isRequired,
delay: PropTypes.number,
delayValue: PropTypes.number,
previousEnd: PropTypes.number.isRequired,
actionHandler: PropTypes.func.isRequired,
};
const CollapsedBlock = (props) => { const CollapsedBlock = (props) => {
const { provided, data, next, delay, delayValue, actionHandler } = props; const { provided, data, next, delay, delayValue, previousEnd, actionHandler } = props;
return ( return (
<> <>
<span className={style.drag} {...provided.dragHandleProps}> <span className={style.drag} {...provided.dragHandleProps}>
<FiMoreVertical /> <FiMoreVertical />
</span> </span>
<div className={style.indicators}> <div className={style.indicators}>
<span className={next ? style.next : style.nextDisabled}>Next</span> <span className={next ? style.next : style.nextDisabled}>Next</span>
{delayValue != null && ( {delayValue != null && <span className={style.delayValue}>+ {delayValue}</span>}
<span className={style.delayValue}>+ {delayValue}</span>
)}
</div> </div>
<EventTimes <EventTimes
actionHandler={actionHandler} actionHandler={actionHandler}
timeStart={data.timeStart} timeStart={data.timeStart}
timeEnd={data.timeEnd} timeEnd={data.timeEnd}
delay={delay} delay={delay}
previousEnd={previousEnd}
className={style.time} className={style.time}
/> />
<div className={style.titleContainer}> <div className={style.titleContainer}>
@@ -128,33 +124,32 @@ const CollapsedBlock = (props) => {
label='Title' label='Title'
defaultValue={data.title} defaultValue={data.title}
placeholder='Add Title' placeholder='Add Title'
submitHandler={(v) => submitHandler={(v) => actionHandler('update', { field: 'title', value: v })}
actionHandler('update', { field: 'title', value: v })
}
/> />
</div> </div>
<div className={style.actionOverlay}> <div className={style.actionOverlay}>
<PublicIconBtn actionHandler={actionHandler} active={data.isPublic} /> <PublicIconBtn actionHandler={actionHandler} active={data.isPublic} />
<ActionButtons <ActionButtons showAdd showDelay showBlock actionHandler={actionHandler} />
showAdd
showDelay
showBlock
actionHandler={actionHandler}
/>
</div> </div>
</> </>
); );
}; };
CollapsedBlock.propTypes = {
provided: PropTypes.any.isRequired,
data: PropTypes.object.isRequired,
next: PropTypes.bool.isRequired,
delay: PropTypes.any,
delayValue: PropTypes.any,
previousEnd: PropTypes.number.isRequired,
actionHandler: PropTypes.func.isRequired,
};
export default function EventBlock(props) { export default function EventBlock(props) {
const { data, selected, delay, index, eventIndex, actionHandler } = props; const { data, selected, delay, index, eventIndex, previousEnd, actionHandler } = props;
const [collapsed] = useAtom( const [collapsed] = useAtom(useMemo(() => SelectCollapse(data.id), [data.id]));
useMemo(() => SelectCollapse(data.id), [data.id])
);
const [, setCollapsed] = useAtom(HandleCollapse); const [, setCollapsed] = useAtom(HandleCollapse);
// TODO: should this go inside useEffect()
// Would I then need to add this to state?
const isSelected = selected ? style.active : ''; const isSelected = selected ? style.active : '';
const isCollapsed = collapsed ? style.collapsed : style.expanded; const isCollapsed = collapsed ? style.collapsed : style.expanded;
const classSelect = `${style.event} ${isCollapsed} ${isSelected}`; const classSelect = `${style.event} ${isCollapsed} ${isSelected}`;
@@ -169,11 +164,7 @@ export default function EventBlock(props) {
return ( return (
<Draggable key={data.id} draggableId={data.id} index={index}> <Draggable key={data.id} draggableId={data.id} index={index}>
{(provided) => ( {(provided) => (
<div <div className={classSelect} {...provided.draggableProps} ref={provided.innerRef}>
className={classSelect}
{...provided.draggableProps}
ref={provided.innerRef}
>
<Icon <Icon
className={collapsed ? style.moreCollapsed : style.moreExpanded} className={collapsed ? style.moreCollapsed : style.moreExpanded}
as={FiChevronUp} as={FiChevronUp}
@@ -186,6 +177,7 @@ export default function EventBlock(props) {
next={props.next} next={props.next}
delay={delay} delay={delay}
delayValue={delayValue} delayValue={delayValue}
previousEnd={previousEnd}
actionHandler={actionHandler} actionHandler={actionHandler}
/> />
) : ( ) : (
@@ -196,6 +188,7 @@ export default function EventBlock(props) {
next={props.next} next={props.next}
delay={delay} delay={delay}
delayValue={delayValue} delayValue={delayValue}
previousEnd={previousEnd}
actionHandler={actionHandler} actionHandler={actionHandler}
/> />
)} )}
@@ -204,3 +197,13 @@ export default function EventBlock(props) {
</Draggable> </Draggable>
); );
} }
EventBlock.propTypes = {
data: PropTypes.object.isRequired,
selected: PropTypes.bool.isRequired,
delay: PropTypes.number,
index: PropTypes.number.isRequired,
eventIndex: PropTypes.number.isRequired,
previousEnd: PropTypes.number.isRequired,
actionHandler: PropTypes.func.isRequired,
};
@@ -1,6 +1,7 @@
import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/menu'; import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/menu';
import { IconButton } from '@chakra-ui/button'; import { IconButton } from '@chakra-ui/button';
import { FiPlus, FiMinusCircle, FiClock } from 'react-icons/fi'; import { FiClock, FiMinusCircle, FiPlus } from 'react-icons/fi';
import { Tooltip } from '@chakra-ui/tooltip';
export default function ActionButtons(props) { export default function ActionButtons(props) {
const { showAdd, showDelay, showBlock, actionHandler } = props; const { showAdd, showDelay, showBlock, actionHandler } = props;
@@ -12,16 +13,18 @@ export default function ActionButtons(props) {
return ( return (
<Menu isLazy lazyBehavior='unmount'> <Menu isLazy lazyBehavior='unmount'>
<MenuButton <Tooltip label='Add ...' delay={500}>
as={IconButton} <MenuButton
aria-label='Options' as={IconButton}
size='xs' aria-label='Options'
icon={<FiPlus />} size='xs'
_expanded={{ bg: 'orange.300', color: 'white' }} icon={<FiPlus />}
_focus={{ boxShadow: 'none' }} _expanded={{ bg: 'orange.300', color: 'white' }}
backgroundColor={'orange.200'} _focus={{ boxShadow: 'none' }}
color={'orange.500'} backgroundColor={'orange.200'}
/> color={'orange.500'}
/>
</Tooltip>
<MenuList style={menuStyle}> <MenuList style={menuStyle}>
<MenuItem <MenuItem
icon={<FiPlus />} icon={<FiPlus />}
@@ -20,8 +20,10 @@ export default function EventList(props) {
// Handle keyboard shortcuts // Handle keyboard shortcuts
const handleKeyPress = useCallback( const handleKeyPress = useCallback(
(e) => { (e) => {
// handle held key
if (e.repeat) return;
// Check if the alt key is pressed // Check if the alt key is pressed
if (e.altKey) { if (e.altKey && (!e.ctrlKey || !e.shiftKey)) {
// Arrow down // Arrow down
if (e.keyCode === 40) { if (e.keyCode === 40) {
if (cursor == null) setCursor(0); if (cursor == null) setCursor(0);
@@ -145,6 +147,8 @@ export default function EventList(props) {
let cumulativeDelay = 0; let cumulativeDelay = 0;
let eventIndex = -1; let eventIndex = -1;
let previousEnd = 0;
let thisEnd = 0;
return ( return (
<div className={style.eventContainer}> <div className={style.eventContainer}>
@@ -167,6 +171,8 @@ export default function EventList(props) {
cumulativeDelay = 0; cumulativeDelay = 0;
} else if (e.type === 'event') { } else if (e.type === 'event') {
eventIndex++; eventIndex++;
previousEnd = thisEnd;
thisEnd = e.timeEnd;
} }
return ( return (
@@ -184,6 +190,7 @@ export default function EventList(props) {
next={nextId === e.id} next={nextId === e.id}
eventsHandler={eventsHandler} eventsHandler={eventsHandler}
delay={cumulativeDelay} delay={cumulativeDelay}
previousEnd={previousEnd}
/> />
</div> </div>
); );
@@ -1,6 +1,6 @@
import DelayBlock from './DelayBlock'; import DelayBlock from '../DelayBlock/DelayBlock';
import BlockBlock from './BlockBlock'; import BlockBlock from '../BlockBlock/BlockBlock';
import EventBlock from './EventBlock'; import EventBlock from '../EventBlock/EventBlock';
import { memo, useContext } from 'react'; import { memo, useContext } from 'react';
import { LoggingContext } from '../../../app/context/LoggingContext'; import { LoggingContext } from '../../../app/context/LoggingContext';
@@ -10,7 +10,8 @@ const areEqual = (prevProps, nextProps) => {
prevProps.selected === nextProps.selected && prevProps.selected === nextProps.selected &&
prevProps.next === nextProps.next && prevProps.next === nextProps.next &&
prevProps.index === nextProps.index && prevProps.index === nextProps.index &&
prevProps.delay === nextProps.delay prevProps.delay === nextProps.delay &&
prevProps.previousEnd === nextProps.previousEnd
); );
}; };
@@ -24,6 +25,7 @@ const EventListItem = (props) => {
next, next,
eventsHandler, eventsHandler,
delay, delay,
previousEnd,
...rest ...rest
} = props; } = props;
const { emitError } = useContext(LoggingContext); const { emitError } = useContext(LoggingContext);
@@ -79,6 +81,7 @@ const EventListItem = (props) => {
next={next} next={next}
actionHandler={actionHandler} actionHandler={actionHandler}
delay={delay} delay={delay}
previousEnd={previousEnd}
/> />
); );
case 'block': case 'block':
+14 -11
View File
@@ -1,7 +1,8 @@
import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/menu'; import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/menu';
import { IconButton } from '@chakra-ui/button'; import { IconButton } from '@chakra-ui/button';
import { FiTrash2, FiPlus, FiClock, FiMinusCircle } from 'react-icons/fi'; import { FiClock, FiMinusCircle, FiPlus, FiTrash2 } from 'react-icons/fi';
import { Divider } from '@chakra-ui/layout'; import { Divider } from '@chakra-ui/layout';
import { Tooltip } from '@chakra-ui/tooltip';
export default function MenuActionButtons(props) { export default function MenuActionButtons(props) {
const { actionHandler } = props; const { actionHandler } = props;
@@ -12,16 +13,18 @@ export default function MenuActionButtons(props) {
return ( return (
<Menu isLazy lazyBehavior='unmount'> <Menu isLazy lazyBehavior='unmount'>
<MenuButton <Tooltip label='Add / Delete ...'>
as={IconButton} <MenuButton
aria-label='Create Menu' as={IconButton}
size={props.size || 'xs'} aria-label='Create Menu'
icon={<FiPlus />} size={props.size || 'xs'}
_expanded={{ bg: 'orange.300', color: 'white' }} icon={<FiPlus />}
_focus={{ boxShadow: 'none' }} _expanded={{ bg: 'orange.300', color: 'white' }}
backgroundColor={'orange.200'} _focus={{ boxShadow: 'none' }}
color={'orange.500'} backgroundColor={'orange.200'}
/> color={'orange.500'}
/>
</Tooltip>
<MenuList style={menuStyle}> <MenuList style={menuStyle}>
<MenuItem icon={<FiPlus />} onClick={() => actionHandler('event')}> <MenuItem icon={<FiPlus />} onClick={() => actionHandler('event')}>
Add Event first Add Event first
@@ -5,7 +5,7 @@ import { FiDownload } from 'react-icons/fi';
export default function DownloadIconBtn(props) { export default function DownloadIconBtn(props) {
const { clickhandler, ...rest } = props; const { clickhandler, ...rest } = props;
return ( return (
<Tooltip label='Download File'> <Tooltip label='Export event list'>
<IconButton <IconButton
size={props.size || 'xs'} size={props.size || 'xs'}
icon={<FiDownload />} icon={<FiDownload />}
@@ -1,19 +0,0 @@
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import { FiHome } from 'react-icons/fi';
export default function InfoIconBtn(props) {
const { clickhandler, ...rest } = props;
return (
<Tooltip label='Event Main'>
<IconButton
size={props.size || 'xs'}
icon={<FiHome />}
colorScheme='white'
onClick={clickhandler}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -5,7 +5,7 @@ import { FiUpload } from 'react-icons/fi';
export default function UploadIconBtn(props) { export default function UploadIconBtn(props) {
const { clickhandler, ...rest } = props; const { clickhandler, ...rest } = props;
return ( return (
<Tooltip label='Upload File'> <Tooltip label='Import event list'>
<IconButton <IconButton
size={props.size || 'xs'} size={props.size || 'xs'}
icon={<FiUpload />} icon={<FiUpload />}
+4 -4
View File
@@ -1,5 +1,5 @@
import { Button, IconButton } from '@chakra-ui/button'; import { Button, IconButton } from '@chakra-ui/button';
import { FiInfo, FiMinus, FiSun } from 'react-icons/fi'; import { IoInformationCircleOutline, IoRemove, IoSunny } from 'react-icons/io5';
import { ModalBody } from '@chakra-ui/modal'; import { ModalBody } from '@chakra-ui/modal';
import { Input } from '@chakra-ui/react'; import { Input } from '@chakra-ui/react';
import { getAliases, postAliases } from '../../app/api/ontimeApi'; import { getAliases, postAliases } from '../../app/api/ontimeApi';
@@ -176,7 +176,7 @@ export default function AliasesModal() {
<div className={style.hSeparator}>Custom Aliases</div> <div className={style.hSeparator}>Custom Aliases</div>
<div className={style.blockNotes}> <div className={style.blockNotes}>
<span className={style.inlineFlex}> <span className={style.inlineFlex}>
<FiInfo color='#2b6cb0' fontSize={'2em'} /> <IoInformationCircleOutline color='#2b6cb0' fontSize={'2em'} />
URL aliases are useful in two main scenarios URL aliases are useful in two main scenarios
</span> </span>
<span className={style.labelNote}>Complicated URLs</span> <span className={style.labelNote}>Complicated URLs</span>
@@ -267,7 +267,7 @@ export default function AliasesModal() {
<Tooltip label='Enable alias' openDelay={500}> <Tooltip label='Enable alias' openDelay={500}>
<IconButton <IconButton
size='xs' size='xs'
icon={<FiSun />} icon={<IoSunny />}
colorScheme='blue' colorScheme='blue'
variant={alias.enabled ? null : 'outline'} variant={alias.enabled ? null : 'outline'}
onClick={() => setEnabled(alias.id, !alias.enabled)} onClick={() => setEnabled(alias.id, !alias.enabled)}
@@ -276,7 +276,7 @@ export default function AliasesModal() {
<Tooltip label='Delete alias' openDelay={500}> <Tooltip label='Delete alias' openDelay={500}>
<IconButton <IconButton
size='xs' size='xs'
icon={<FiMinus />} icon={<IoRemove />}
colorScheme='red' colorScheme='red'
onClick={() => deleteAlias(alias.id)} onClick={() => deleteAlias(alias.id)}
/> />
@@ -99,7 +99,7 @@ export default function AppSettingsModal() {
<p className={style.notes}> <p className={style.notes}>
Options related to the application Options related to the application
<br /> <br />
🔥 Changes take effect after app restart 🔥 🔥 Changes take effect on save 🔥
</p> </p>
<form onSubmit={submitHandler}> <form onSubmit={submitHandler}>
<div className={style.modalFields}> <div className={style.modalFields}>
@@ -39,6 +39,7 @@ export default function SettingsModal() {
setSubmitting(true); setSubmitting(true);
await postEvent(formData); await postEvent(formData);
await refetch();
setChanged(false); setChanged(false);
setSubmitting(false); setSubmitting(false);
+144 -19
View File
@@ -8,7 +8,70 @@ import style from './Modals.module.scss';
import { LoggingContext } from '../../app/context/LoggingContext'; import { LoggingContext } from '../../app/context/LoggingContext';
import SubmitContainer from './SubmitContainer'; import SubmitContainer from './SubmitContainer';
import { inputProps, portInputProps } from './modalHelper'; import { inputProps, portInputProps } from './modalHelper';
import { IoInformationCircleOutline } from 'react-icons/io5';
import EnableBtn from '../../common/components/buttons/EnableBtn';
// 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() { export default function OscSettingsModal() {
const { data, status, refetch } = useFetch(OSC_SETTINGS, getOSC); const { data, status, refetch } = useFetch(OSC_SETTINGS, getOSC);
@@ -57,6 +120,7 @@ export default function OscSettingsModal() {
} else { } else {
// Post here // Post here
await postOSC(formData); await postOSC(formData);
await refetch();
setChanged(false); setChanged(false);
} }
setSubmitting(false); setSubmitting(false);
@@ -91,25 +155,44 @@ export default function OscSettingsModal() {
</p> </p>
<form onSubmit={submitHandler}> <form onSubmit={submitHandler}>
<div className={style.modalFields}> <div className={style.modalFields}>
<div className={style.hSeparator}>OSC Input (control)</div> <div className={style.hSeparator}>
<div className={style.spacedEntry}> OSC Input (Control ontime over OSC)
<FormLabel htmlFor='port'> </div>
OSC In Port <div className={style.modalInline}>
<span className={style.labelNote}> <FormControl id='oscInEnabled'>
<br /> <FormLabel htmlFor='oscInEnabled'>
Open port for 3rd party control over OSC - Default 8888 OSC Enable
</span> <span className={style.labelNote}>
</FormLabel> <br />
<Input Enable / Disable control
{...portInputProps} </span>
name='port' </FormLabel>
placeholder='8888' <EnableBtn
value={formData.port} active={formData.enabled}
onChange={(event) => text={formData.enabled ? 'OSC IN Enabled' : 'OSC IN Disabled'}
handleChange('port', parseInt(event.target.value)) actionHandler={() => handleChange('enabled', !formData.enabled)}
} onClick={() => console.log('yay')}
style={{ width: '6em', textAlign: 'center' }} />
/> </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))
}
style={{ width: '6em', textAlign: 'center' }}
/>
</FormControl>
</div> </div>
<div className={style.hSeparator}>OSC Output (feedback)</div> <div className={style.hSeparator}>OSC Output (feedback)</div>
<div className={style.modalInline}> <div className={style.modalInline}>
@@ -155,6 +238,48 @@ export default function OscSettingsModal() {
/> />
</FormControl> </FormControl>
</div> </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> </div>
<SubmitContainer <SubmitContainer
revert={revert} revert={revert}
@@ -1,62 +0,0 @@
import styles from './PreviewContainer.module.css';
import IFrameLoader from './iframes/IFrameLoader';
// get origin from URL
const serverURL = `${window.location.origin}`;
export default function PreviewContainer() {
return (
<div className={styles.previewContainer}>
<div className={styles.previewItem}>
<IFrameLoader title='Default Presenter' src={`${serverURL}/speaker`} />
<a
href={`${serverURL}/speaker`}
target='_blank'
rel='noreferrer'
className={styles.label}
>
Default Presenter
</a>
</div>
<div className={styles.previewItem}>
<IFrameLoader title='Audience' src={`${serverURL}/public`} />
<a
href={`${serverURL}/public`}
target='_blank'
rel='noreferrer'
className={styles.label}
>
Audience
</a>
</div>
<div className={styles.previewItem}>
<IFrameLoader title='Stage Manager' src={`${serverURL}/sm`} />
<a
href={`${serverURL}/sm`}
target='_blank'
rel='noreferrer'
className={styles.label}
>
Stage Manager
</a>
</div>
<div className={styles.previewItem}>
<IFrameLoader
title='Lower third'
src={`${serverURL}/lower?key=242424`}
/>
<a
href={`${serverURL}/lower`}
target='_blank'
rel='noreferrer'
className={styles.label}
>
Lower third
</a>
</div>
</div>
);
}
@@ -1,25 +0,0 @@
.previewContainer {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
align-content: center;
gap: 1em;
}
.previewItem {
width: 45%;
}
.label {
padding: 0.1em 4em;
}
a::after {
content: ' \2197';
color: #ff7597;
}
a:hover {
color: #ff7597;
}
+9 -15
View File
@@ -4,16 +4,12 @@ import { fetchEvent } from 'app/api/eventApi';
import { useSocket } from 'app/context/socketContext'; import { useSocket } from 'app/context/socketContext';
import { stringFromMillis } from 'ontime-utils/time'; import { stringFromMillis } from 'ontime-utils/time';
import { useFetch } from 'app/hooks/useFetch'; import { useFetch } from 'app/hooks/useFetch';
import { EVENTS_TABLE, EVENT_TABLE } from 'app/api/apiConstants'; import { EVENT_TABLE, EVENTS_TABLE } from 'app/api/apiConstants';
const withSocket = (Component) => { const withSocket = (Component) => {
const WrappedComponent = (props) => { return (props) => {
const { const { data: eventsData } = useFetch(EVENTS_TABLE, fetchAllEvents);
data: eventsData, const { data: genData } = useFetch(EVENT_TABLE, fetchEvent);
} = useFetch(EVENTS_TABLE, fetchAllEvents);
const {
data: genData,
} = useFetch(EVENT_TABLE, fetchEvent);
const [publicEvents, setPublicEvents] = useState([]); const [publicEvents, setPublicEvents] = useState([]);
const [backstageEvents, setBackstageEvents] = useState([]); const [backstageEvents, setBackstageEvents] = useState([]);
@@ -70,8 +66,8 @@ const withSocket = (Component) => {
useEffect(() => { useEffect(() => {
if (socket == null) return; if (socket == null) return;
// Handle presenter messages // Handle timer messages
socket.on('messages-presenter', (data) => { socket.on('messages-timer', (data) => {
setPres({ ...data }); setPres({ ...data });
}); });
@@ -121,14 +117,14 @@ const withSocket = (Component) => {
socket.emit('get-messages'); socket.emit('get-messages');
// Ask for up to data // Ask for up to data
socket.emit('get-presenter'); socket.emit('get-timer');
// ask for timer // ask for timer
socket.emit('get-timer'); socket.emit('get-timer');
// ask for playstate // ask for playstate
socket.emit('get-playstate'); socket.emit('get-playstate');
socket.emit('get-onAir') socket.emit('get-onAir');
// Ask for up titles // Ask for up titles
socket.emit('get-titles'); socket.emit('get-titles');
@@ -141,7 +137,7 @@ const withSocket = (Component) => {
// Clear listeners // Clear listeners
return () => { return () => {
socket.off('messages-public'); socket.off('messages-public');
socket.off('messages-presenter'); socket.off('messages-timer');
socket.off('messages-lower'); socket.off('messages-lower');
socket.off('timer'); socket.off('timer');
socket.off('playstate'); socket.off('playstate');
@@ -255,8 +251,6 @@ const withSocket = (Component) => {
/> />
); );
}; };
return WrappedComponent;
}; };
export default withSocket; export default withSocket;
@@ -1,28 +0,0 @@
import style from './PresenterView.module.css';
export default function PresenterSimple() {
return (
<div className={style.container__graySimple}>
{/* <div className={style.messageOverlayActive}>
<div className={style.message}>Remember to smile</div>
</div> */}
<div className={style.timerContainer}>
<div className={style.countdownBig}>01:03</div>
</div>
<div className={style.progress}>
<div className={style.progressed}></div>
</div>
{/* <div className={style.mainContainer}>
<div className={style.finished}>TIME UP</div>
</div> */}
<div className={style.clockContainer}>
<div className={style.label}>Time Now</div>
<div className={style.clock}>11:00:23</div>
</div>
</div>
);
}
@@ -7,7 +7,7 @@ const isEqual = require('react-fast-compare');
const areEqual = (prevProps, nextProps) => { const areEqual = (prevProps, nextProps) => {
return ( return (
isEqual(prevProps.title, nextProps.title) && isEqual(prevProps.title, nextProps.title) &&
isEqual(prevProps.lower && nextProps.lower) isEqual(prevProps.lower, nextProps.lower)
); );
}; };
@@ -56,6 +56,7 @@ const Lower = (props) => {
clearTimeout(timeout); clearTimeout(timeout);
} }
}; };
// eslint-disable-next-line
}, [title.titleNow, title.subtitleNow, title.presenterNow]); }, [title.titleNow, title.subtitleNow, title.presenterNow]);
// TODO: sanitize data // TODO: sanitize data
@@ -0,0 +1,37 @@
import { useEffect } from 'react';
import { formatDisplay } from '../../../common/utils/dateConfig';
import NavLogo from '../../../common/components/nav/NavLogo';
import style from './MinimalTimer.module.scss';
export default function MinimalTimer(props) {
const { pres, time } = props;
// Set window title
useEffect(() => {
document.title = 'ontime - Minimal Timer';
}, []);
const showOverlay = pres.text !== '' && pres.visible;
const isPlaying = time.playstate !== 'pause';
const timer = formatDisplay(time.running, true);
const clean = timer.replaceAll(':', '');
return (
<div className={time.finished ? style.containerFinished : style.container}>
<div
className={
showOverlay ? style.messageOverlayActive : style.messageOverlay
}
>
<div className={style.message}>{pres.text}</div>
</div>
<NavLogo />
<div
style={{ fontSize: `${89 / (clean.length - 1)}vw` }}
className={isPlaying ? style.timer : style.timerPaused}
>
{time.running < 0 ? `-${timer}` : timer}
</div>
</div>
);
}
@@ -0,0 +1,72 @@
@use '../../../styles/main' as *;
.container,
.containerFinished {
background: $bg-black;
height: 100vh;
color: $title-white;
display: grid;
place-content: center;
gap: 1vw;
border: 1vw solid $bg-black;
}
.containerFinished {
border: 1vw solid $ontime-pink-variant;
color: $ontime-pink-variant;
transition: 0.3s;
}
.timer,
.timerPaused {
font-family: "Arial Black", sans-serif;
font-size: 20vw;
color: inherit;
opacity: 1;
transition: 0.5s;
transition-property: opacity;
}
.timerPaused {
opacity: 0.6;
transition: 0.5s;
}
/* =================== OVERLAY ===================*/
.messageOverlay,
.messageOverlayActive {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.85);
z-index: -1;
opacity: 0;
transition: 0.5s;
}
.messageOverlayActive {
opacity: 1;
transition: 0.5s;
transition-property: opacity;
z-index: 2;
}
.message {
width: inherit;
padding: 2vw;
position: absolute;
top: 50%;
left: 50%;
color: white;
transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
font-size: 15vw;
line-height: 30vh;
text-align: center;
font-weight: 600;
}
@@ -1,19 +1,35 @@
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import Countdown from 'common/components/countdown/Countdown'; import Countdown from 'common/components/countdown/Countdown';
import MyProgressBar from 'common/components/myProgressBar/MyProgressBar'; import MyProgressBar from 'common/components/myProgressBar/MyProgressBar';
import NavLogo from 'common/components/nav/NavLogo'; import NavLogo from 'common/components/nav/NavLogo';
import TitleCard from 'common/components/views/TitleCard'; import TitleCard from 'common/components/views/TitleCard';
import style from './PresenterView.module.css'; import style from './Timer.module.scss';
export default function PresenterView(props) { export default function Timer(props) {
const { general, pres, title, time } = props; const { general, pres, title, time } = props;
const [elapsed, setElapsed] = useState(true);
const [searchParams] = useSearchParams();
// Set window title // Set window title
useEffect(() => { useEffect(() => {
document.title = 'ontime - Presenter Screen'; document.title = 'ontime - Timer';
}, []); }, []);
// eg. http://localhost:3000/timer?progress=up
// Check for user options
useEffect(() => {
// progress: selector
// Should be 'up' or 'down'
const progress = searchParams.get('progress');
if (progress === 'up') {
setElapsed(true);
} else if (progress === 'down') {
setElapsed(false);
}
}, [searchParams]);
const showOverlay = pres.text !== '' && pres.visible; const showOverlay = pres.text !== '' && pres.visible;
const isPlaying = time.playstate !== 'pause'; const isPlaying = time.playstate !== 'pause';
const normalisedTime = Math.max(time.running, 0); const normalisedTime = Math.max(time.running, 0);
@@ -79,7 +95,11 @@ export default function PresenterView(props) {
isPlaying ? style.progressContainer : style.progressContainerPaused isPlaying ? style.progressContainer : style.progressContainerPaused
} }
> >
<MyProgressBar now={normalisedTime} complete={time.durationSeconds} /> <MyProgressBar
now={normalisedTime}
complete={time.durationSeconds}
showElapsed={elapsed}
/>
</div> </div>
)} )}
@@ -1,3 +1,5 @@
@use '../../../styles/main' as *;
.container__gray, .container__gray,
.container__grayFinished { .container__grayFinished {
margin: 0; margin: 0;
@@ -5,9 +7,9 @@
overflow: hidden; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%; /* restrict the page width to viewport */
background: radial-gradient(circle, #202020 0%, #121212 80%); background: radial-gradient(circle, $bg-black-gradient 0%, $bg-black 80%);
height: 100vh; height: 100vh;
color: #fffd; color: $title-white;
display: grid; display: grid;
grid-template-columns: 1fr 1fr 5vw 1fr 1fr; grid-template-columns: 1fr 1fr 5vw 1fr 1fr;
grid-template-rows: auto 1fr auto minmax(25vh, auto); grid-template-rows: auto 1fr auto minmax(25vh, auto);
@@ -20,25 +22,11 @@
padding: 1vw; padding: 1vw;
} }
.container__graySimple,
.container__grayFinishedSimple {
background: radial-gradient(circle, #202020 0%, #121212 80%);
height: 100vh;
color: #fffd;
display: grid;
grid-template-columns: 1fr 1fr 5vw 1fr 1fr;
grid-template-rows: auto 1fr 30vh;
grid-template-areas:
' clck .... .... .... ....'
' timr timr timr timr timr'
' prog prog prog prog prog';
gap: 1vw;
}
.label { .label {
font-size: 1.3vw; font-size: 1.3vw;
color: #ff7597; color: $ontime-pink;
} }
/* =================== TITLES ===================*/ /* =================== TITLES ===================*/
.nowContainer, .nowContainer,
@@ -52,6 +40,7 @@
.nowContainer { .nowContainer {
grid-area: now; grid-area: now;
} }
.nextContainer { .nextContainer {
grid-area: next; grid-area: next;
} }
@@ -71,7 +60,7 @@
font-size: 12vw; font-size: 12vw;
line-height: 18vw; line-height: 18vw;
font-weight: 600; font-weight: 600;
color: #ff6969; color: $ontime-pink-variant;
padding: 0; padding: 0;
} }
@@ -95,7 +84,7 @@
} }
.container__grayFinished { .container__grayFinished {
border: 1vw solid #ff6969; border: 1vw solid $ontime-pink-variant;
} }
/* =================== OVERLAY ===================*/ /* =================== OVERLAY ===================*/
+7
View File
@@ -2,6 +2,7 @@
$ontime-accent: #4bffabcc; $ontime-accent: #4bffabcc;
$ontime-pink: #ff7597; $ontime-pink: #ff7597;
$ontime-pink-variant: #ff6969;
$ontime-roll: #2b6cb0; $ontime-roll: #2b6cb0;
$notes-color: #d69e2e; $notes-color: #d69e2e;
@@ -16,6 +17,11 @@ $light-text: #2b6cb022;
$error-red: #E53E3E; $error-red: #E53E3E;
$title-white: #fffd;
$bg-black: #121212;
$bg-black-gradient: #202020;
//////////////////////////////////// general app element overriders //////////////////////////////////// general app element overriders
// no decoration on lists // no decoration on lists
@@ -34,6 +40,7 @@ a {
content: ' \2197'; content: ' \2197';
color: $ontime-pink; color: $ontime-pink;
} }
&:hover { &:hover {
color: $ontime-pink; color: $ontime-pink;
} }
+12
View File
@@ -1,5 +1,11 @@
//////////////////////////////////// general app elements //////////////////////////////////// general app elements
@mixin main-container {
background-color: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 4px;
}
@mixin container-bg { @mixin container-bg {
background-color: rgba(0, 0, 0, 0.13); background-color: rgba(0, 0, 0, 0.13);
border-radius: 2px; border-radius: 2px;
@@ -7,3 +13,9 @@
margin: 0 0.5em; margin: 0 0.5em;
} }
@mixin osc-label {
color: #4bffabcc;
font-size: 0.8em;
-webkit-user-select: text;
user-select: text;
}
+9
View File
@@ -9002,6 +9002,15 @@ prop-types@^15.6.2, prop-types@^15.7.2:
object-assign "^4.1.1" object-assign "^4.1.1"
react-is "^16.8.1" react-is "^16.8.1"
prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
dependencies:
loose-envify "^1.4.0"
object-assign "^4.1.1"
react-is "^16.13.1"
proxy-addr@~2.0.5: proxy-addr@~2.0.5:
version "2.0.6" version "2.0.6"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf"
+2 -1
View File
@@ -13,7 +13,8 @@
"rules": { "rules": {
"prettier/prettier": ["error", { "prettier/prettier": ["error", {
"endOfLine": "auto", "endOfLine": "auto",
"singleQuote": true "singleQuote": true,
"printWidth": 100
}] }]
} }
} }
+5
View File
@@ -0,0 +1,5 @@
{
"viewportWidth": 1920,
"viewportHeight": 1080,
"video": false
}
@@ -0,0 +1,48 @@
// go trough routes and make sure things render as expected
describe('validate routes', () => {
it('viewer routes', () => {
cy.visit('http://localhost:4001/');
cy.contains('Time Now');
cy.visit('http://localhost:4001/timer');
cy.contains('Time Now');
cy.visit('http://localhost:4001/presenter');
cy.contains('Time Now');
cy.visit('http://localhost:4001/speaker');
cy.contains('Time Now');
cy.visit('http://localhost:4001/stage');
cy.contains('Time Now');
cy.visit('http://localhost:4001/backstage');
cy.contains('Today');
cy.contains('Time Now');
cy.contains('Info');
cy.visit('http://localhost:4001/sm');
cy.contains('Today');
cy.contains('Time Now');
cy.contains('Info');
cy.visit('http://localhost:4001/public');
cy.contains('Today');
cy.contains('Time Now');
cy.contains('Info');
cy.visit('http://localhost:4001/pip');
cy.contains('Today');
cy.contains('Info');
cy.visit('http://localhost:4001/studio');
cy.contains('ON AIR');
});
it('editor routes', () => {
cy.visit('http://localhost:4001/editor');
cy.contains('Event List');
cy.contains('Timer Control');
cy.contains('Display Messages');
cy.contains('Info');
});
});
+22
View File
@@ -0,0 +1,22 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
// eslint-disable-next-line no-unused-vars
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}
+25
View File
@@ -0,0 +1,25 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
+20
View File
@@ -0,0 +1,20 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
+10 -4
View File
@@ -23,9 +23,7 @@ const nodePath =
(async () => { (async () => {
try { try {
const { startServer, startOSCServer } = await import( const { startServer, startOSCServer } = await import(nodePath);
nodePath
);
// Start express server // Start express server
loaded = await startServer(); loaded = await startServer();
@@ -61,7 +59,6 @@ if (!lock) {
'An instance if the App is already running.' 'An instance if the App is already running.'
); );
app.quit(); app.quit();
return;
} else { } else {
app.on('second-instance', (event, commandLine, workingDirectory) => { app.on('second-instance', (event, commandLine, workingDirectory) => {
// Someone tried to run a second instance, we should focus our window. // Someone tried to run a second instance, we should focus our window.
@@ -191,6 +188,15 @@ app.whenReady().then(() => {
const trayContextMenu = Menu.buildFromTemplate(trayMenuTemplate); const trayContextMenu = Menu.buildFromTemplate(trayMenuTemplate);
tray.setContextMenu(trayContextMenu); tray.setContextMenu(trayContextMenu);
// on tray click event, show main window
tray.on('click', function (e) {
if (!win.isVisible()) {
win.show();
}
win.focus();
});
}); });
// unregister shortcuts before quitting // unregister shortcuts before quitting
+23 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "0.5.1", "version": "0.6.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
@@ -12,22 +12,30 @@
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"main": "main.js", "main": "main.js",
"devDependencies": { "devDependencies": {
"cypress": "^9.2.1",
"electron": "^13.6.1", "electron": "^13.6.1",
"electron-builder": "^22.14.5", "electron-builder": "^22.14.5",
"eslint": "^8.5.0", "eslint": "^8.5.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.0.0",
"jest": "^27.4.5", "jest": "^27.4.5",
"prettier": "^2.5.1" "nodemon": "^2.0.15",
"prettier": "^2.5.1",
"start-server-and-test": "^1.14.0",
"supertest": "^6.1.6"
}, },
"scripts": { "scripts": {
"nodestart": "NODE_ENV=development node src/app.js", "nodestart": "NODE_ENV=development node src/app.js",
"make": "mkdir -p src/data",
"setdb": "cp data/db.json src/data/db.json", "setdb": "cp data/db.json src/data/db.json",
"clean": "rm -rf ../client/build/ && rm -rf ../client/node_modules && rm -rf src/node_modules && rm -rf ./node_modules && rm -rf ./dist", "clean": "rm -rf ../client/build/ && rm -rf ../client/node_modules && rm -rf src/node_modules && rm -rf ./node_modules && rm -rf ./dist",
"cheat": "rm src/node_modules/ontime-utils && cp -R utils src/node_modules/ontime-utils", "cheat": "rm src/node_modules/ontime-utils && cp -R utils src/node_modules/ontime-utils",
"prep": "yarn clean && yarn setdb", "prep": "yarn clean && yarn setdb",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"start": "NODE_ENV=development electron .", "start": "NODE_ENV=development electron .",
"start:server": "nodemon --experimental-modules --es-module-specifier-resolution=node src/app.js",
"cy:run": "cypress run",
"cypress": "start-server-and-test start http://localhost:4001 cy:run",
"pack": "electron-builder --dir", "pack": "electron-builder --dir",
"dist": "electron-builder", "dist": "electron-builder",
"dist-win": "electron-builder --publish=never --x64 --win", "dist-win": "electron-builder --publish=never --x64 --win",
@@ -38,7 +46,8 @@
"testEnvironment": "node", "testEnvironment": "node",
"testRunner": "jasmine2", "testRunner": "jasmine2",
"testPathIgnorePatterns": [ "testPathIgnorePatterns": [
"dist" "dist",
"cypress"
] ]
}, },
"build": { "build": {
@@ -74,7 +83,9 @@
"**/*", "**/*",
"assets/", "assets/",
"!**/{yarn.lock,yarn-error.log}", "!**/{yarn.lock,yarn-error.log}",
"!**/{test,tests,__test__,__tests__,mock,mocks,__mock__,__mocks__}" "!**/{test,tests,__test__,__tests__}",
"!**/{mock,mocks,__mock__,__mocks__}",
"!*{.spec.js,*.test.js}"
], ],
"directories": { "directories": {
"buildResources": "./assets/" "buildResources": "./assets/"
@@ -86,7 +97,8 @@
"filter": [ "filter": [
"**/*", "**/*",
"!**/{yarn.lock,yarn-error.log}", "!**/{yarn.lock,yarn-error.log}",
"!**/{test,tests,__test__,__tests__,mock,mocks,__mock__,__mocks__}" "!**/{test,tests,__test__,__tests__}",
"!**/{mock,mocks,__mock__,__mocks__}"
] ]
}, },
{ {
@@ -95,7 +107,8 @@
"filter": [ "filter": [
"**/*", "**/*",
"!**/{yarn.lock,yarn-error.log}", "!**/{yarn.lock,yarn-error.log}",
"!**/{test,tests,__test__,__tests__,mock,mocks,__mock__,__mocks__}" "!**/{test,tests,__test__,__tests__}",
"!**/{mock,mocks,__mock__,__mocks__}"
] ]
}, },
{ {
@@ -103,8 +116,11 @@
"to": "extraResources/utils", "to": "extraResources/utils",
"filter": [ "filter": [
"**/*", "**/*",
"!cypress/",
"!**/{yarn.lock,yarn-error.log}", "!**/{yarn.lock,yarn-error.log}",
"!**/{test,tests,__test__,__tests__,mock,mocks,__mock__,__mocks__}" "!**/{test,tests,__test__,__tests__}",
"!**/{mock,mocks,__mock__,__mocks__}",
"!*{.spec.js,*.test.js}"
] ]
} }
] ]
+12 -4
View File
@@ -123,6 +123,7 @@ const osc = data.osc;
const oscIP = osc?.targetIP || config.osc.targetIP; const oscIP = osc?.targetIP || config.osc.targetIP;
const oscOutPort = osc?.portOut || config.osc.portOut; const oscOutPort = osc?.portOut || config.osc.portOut;
const oscInPort = osc?.port || config.osc.port; const oscInPort = osc?.port || config.osc.port;
const oscInEnabled = osc?.enabled !== undefined ? osc.enabled : config.osc.inputEnabled;
const serverPort = data.settings.serverPort || config.server.port; const serverPort = data.settings.serverPort || config.server.port;
@@ -130,14 +131,19 @@ const serverPort = data.settings.serverPort || config.server.port;
import { initiateOSC, shutdownOSCServer } from './controllers/OscController.js'; import { initiateOSC, shutdownOSCServer } from './controllers/OscController.js';
export const startOSCServer = async (overrideConfig = null) => { export const startOSCServer = async (overrideConfig = null) => {
if (!oscInEnabled) {
global.timer.info('RX', 'OSC Input Disabled')
return;
}
// Setup default port // Setup default port
const oscSettings = { const oscSettings = {
port: overrideConfig?.port || oscInPort, port: overrideConfig?.port || oscInPort,
ipOut: oscIP,
portOut: oscOutPort,
}; };
// Start OSC Server // Start OSC Server
global.timer.info('RX', `Starting OSC Server on port: ${oscInPort}`)
initiateOSC(oscSettings); initiateOSC(oscSettings);
}; };
@@ -156,7 +162,7 @@ export const startServer = async (overrideConfig = null) => {
// Start server // Start server
const returnMessage = `Ontime is listening on port ${port}`; const returnMessage = `Ontime is listening on port ${port}`;
server.listen(port, '0.0.0.0', () => console.log(returnMessage)); server.listen(port, '0.0.0.0');
// OSC Config // OSC Config
const oscConfig = { const oscConfig = {
@@ -167,7 +173,7 @@ export const startServer = async (overrideConfig = null) => {
// init timer // init timer
global.timer = new EventTimer(server, config.timer, oscConfig, data.http); global.timer = new EventTimer(server, config.timer, oscConfig, data.http);
global.timer.setupWithEventList(data.events); global.timer.setupWithEventList(data.events);
global.timer.info('SERVER', returnMessage);
return returnMessage; return returnMessage;
}; };
@@ -185,3 +191,5 @@ export const shutdown = async () => {
// shutdown timer // shutdown timer
global.timer.shutdown(); global.timer.shutdown();
}; };
export { server, app };
+36 -12
View File
@@ -306,8 +306,11 @@ export class EventTimer extends Timer {
// _finish at is only set when an event is loaded // _finish at is only set when an event is loaded
if (this._finishAt > 0) { if (this._finishAt > 0) {
this.sendOsc(this.osc.implemented.play); this.sendOsc(this.osc.implemented.play);
this.sendOsc(
this.osc.implemented.eventNumber,
this.selectedEventIndex || 0
);
} }
// check integrations - http // check integrations - http
if (h?.onLoad?.enabled) { if (h?.onLoad?.enabled) {
if (h?.onLoad?.url != null || h?.onStart?.url !== '') { if (h?.onLoad?.url != null || h?.onStart?.url !== '') {
@@ -335,6 +338,10 @@ export class EventTimer extends Timer {
this.osc.implemented.title, this.osc.implemented.title,
this.titles?.titleNow || '' this.titles?.titleNow || ''
); );
this.sendOsc(
this.osc.implemented.presenter,
this.titles?.presenterNow || ''
);
} }
} }
@@ -504,13 +511,13 @@ export class EventTimer extends Timer {
switch (action) { switch (action) {
/*******************************************/ /*******************************************/
// Presenter message // Presenter message
case 'set-presenter-text': case 'set-timer-text':
this.presenter.text = payload; this.presenter.text = payload;
this.broadcastThis('messages-presenter', this.presenter); this.broadcastThis('messages-timer', this.presenter);
break; break;
case 'set-presenter-visible': case 'set-timer-visible':
this.presenter.visible = payload; this.presenter.visible = payload;
this.broadcastThis('messages-presenter', this.presenter); this.broadcastThis('messages-timer', this.presenter);
break; break;
/*******************************************/ /*******************************************/
@@ -679,22 +686,22 @@ export class EventTimer extends Timer {
// Messages // Messages
socket.on('get-messages', () => { socket.on('get-messages', () => {
this.broadcastThis('messages-presenter', this.presenter); this.broadcastThis('messages-timer', this.presenter);
this.broadcastThis('messages-public', this.public); this.broadcastThis('messages-public', this.public);
this.broadcastThis('messages-lower', this.lower); this.broadcastThis('messages-lower', this.lower);
}); });
// Presenter message // Presenter message
socket.on('set-presenter-text', (data) => { socket.on('set-timer-text', (data) => {
this._setTitles('set-presenter-text', data); this._setTitles('set-timer-text', data);
}); });
socket.on('set-presenter-visible', (data) => { socket.on('set-timer-visible', (data) => {
this._setTitles('set-presenter-visible', data); this._setTitles('set-timer-visible', data);
}); });
socket.on('get-presenter', () => { socket.on('get-timer', () => {
this.broadcastThis('messages-presenter', this.presenter); this.broadcastThis('messages-timer', this.presenter);
}); });
/*******************************************/ /*******************************************/
// Public message // Public message
@@ -1390,6 +1397,7 @@ export class EventTimer extends Timer {
} }
/****************************************************************************/ /****************************************************************************/
/** /**
* Logger logic * Logger logic
* ------------- * -------------
@@ -1453,6 +1461,7 @@ export class EventTimer extends Timer {
} }
/****************************************************************************/ /****************************************************************************/
/** /**
* Integrations * Integrations
* ------------- * -------------
@@ -1473,4 +1482,19 @@ export class EventTimer extends Timer {
this.error('TX', reply.message); this.error('TX', reply.message);
} }
} }
/**
* Builds sync object
* @returns {{running: number, timer: (null|string|*), presenter: null, playback: string, clock: null, title: null}}
*/
poll() {
return {
clock: this.clock,
running: Timer.toSeconds(this.current),
timer: this.timeTag,
playback: this.state,
title: this.titles.titleNow,
presenter: this.titles.presenterNow,
};
}
} }
+48 -53
View File
@@ -4,26 +4,26 @@ import {
replacePlaceholder, replacePlaceholder,
normaliseEndTime, normaliseEndTime,
sortArrayByProperty, sortArrayByProperty,
updateRoll updateRoll,
} from '../classUtils.js'; } from '../classUtils.js';
// test sortArrayByProperty() // test sortArrayByProperty()
describe('sort simple arrays of objects', () => { describe('sort simple arrays of objects', () => {
it('sort array 1-5', () => { it('sort array 1-5', () => {
const arr1 = [ const arr1 = [
{timeStart: 1}, { timeStart: 1 },
{timeStart: 5}, { timeStart: 5 },
{timeStart: 3}, { timeStart: 3 },
{timeStart: 2}, { timeStart: 2 },
{timeStart: 4}, { timeStart: 4 },
]; ];
const arr1Expected = [ const arr1Expected = [
{timeStart: 1}, { timeStart: 1 },
{timeStart: 2}, { timeStart: 2 },
{timeStart: 3}, { timeStart: 3 },
{timeStart: 4}, { timeStart: 4 },
{timeStart: 5}, { timeStart: 5 },
]; ];
const sorted = sortArrayByProperty(arr1, 'timeStart'); const sorted = sortArrayByProperty(arr1, 'timeStart');
@@ -32,21 +32,21 @@ describe('sort simple arrays of objects', () => {
it('sort array 1-5 with null', () => { it('sort array 1-5 with null', () => {
const arr1 = [ const arr1 = [
{timeStart: 1}, { timeStart: 1 },
{timeStart: 5}, { timeStart: 5 },
{timeStart: 3}, { timeStart: 3 },
{timeStart: 2}, { timeStart: 2 },
{timeStart: 4}, { timeStart: 4 },
{timeStart: null}, { timeStart: null },
]; ];
const arr1Expected = [ const arr1Expected = [
{timeStart: null}, { timeStart: null },
{timeStart: 1}, { timeStart: 1 },
{timeStart: 2}, { timeStart: 2 },
{timeStart: 3}, { timeStart: 3 },
{timeStart: 4}, { timeStart: 4 },
{timeStart: 5}, { timeStart: 5 },
]; ];
const sorted = sortArrayByProperty(arr1, 'timeStart'); const sorted = sortArrayByProperty(arr1, 'timeStart');
@@ -393,78 +393,76 @@ describe('test that roll behaviour with overlapping times', () => {
// test replacePlaceholder() // test replacePlaceholder()
describe('test that it replaces data correctly', () => { describe('test that it replaces data correctly', () => {
const values = { const values = {
$timer: "timer", $timer: 'timer',
$title: "title", $title: 'title',
$presenter: "presenter", $presenter: 'presenter',
$subtitle: "subtitle", $subtitle: 'subtitle',
"$next-title": "next title", '$next-title': 'next title',
"$next-presenter": "next presenter", '$next-presenter': 'next presenter',
"$next-subtitle": "next subtitle" '$next-subtitle': 'next subtitle',
}; };
it('replaces timer', () => { it('replaces timer', () => {
const test = '___1232132 $timer'; const test = '___1232132 $timer';
const expected = '___1232132 timer'; const expected = '___1232132 timer';
const s = replacePlaceholder(test, values) const s = replacePlaceholder(test, values);
expect(s).toBe(expected); expect(s).toBe(expected);
}); });
it('replaces title', () => { it('replaces title', () => {
const test = '___1232132 $title'; const test = '___1232132 $title';
const expected = '___1232132 title'; const expected = '___1232132 title';
const s = replacePlaceholder(test, values) const s = replacePlaceholder(test, values);
expect(s).toBe(expected); expect(s).toBe(expected);
}); });
it('replaces presenter', () => { it('replaces presenter', () => {
const test = '___1232132 $presenter'; const test = '___1232132 $presenter';
const expected = '___1232132 presenter'; const expected = '___1232132 presenter';
const s = replacePlaceholder(test, values) const s = replacePlaceholder(test, values);
expect(s).toBe(expected); expect(s).toBe(expected);
}); });
it('replaces subtitle', () => { it('replaces subtitle', () => {
const test = '___1232132 $subtitle'; const test = '___1232132 $subtitle';
const expected = '___1232132 subtitle'; const expected = '___1232132 subtitle';
const s = replacePlaceholder(test, values) const s = replacePlaceholder(test, values);
expect(s).toBe(expected); expect(s).toBe(expected);
}); });
it('replaces next next title', () => { it('replaces next next title', () => {
const test = '___1232132 $next-title'; const test = '___1232132 $next-title';
const expected = '___1232132 next title'; const expected = '___1232132 next title';
const s = replacePlaceholder(test, values) const s = replacePlaceholder(test, values);
expect(s).toBe(expected); expect(s).toBe(expected);
}); });
it('replaces next presenter', () => { it('replaces next presenter', () => {
const test = '___1232132 $next-presenter'; const test = '___1232132 $next-presenter';
const expected = '___1232132 next presenter'; const expected = '___1232132 next presenter';
const s = replacePlaceholder(test, values) const s = replacePlaceholder(test, values);
expect(s).toBe(expected); expect(s).toBe(expected);
}); });
it('replaces next subtitle', () => { it('replaces next subtitle', () => {
const test = '___1232132 $next-subtitle'; const test = '___1232132 $next-subtitle';
const expected = '___1232132 next subtitle'; const expected = '___1232132 next subtitle';
const s = replacePlaceholder(test, values) const s = replacePlaceholder(test, values);
expect(s).toBe(expected); expect(s).toBe(expected);
}); });
}); });
// test getSelectionByRoll() on issue #58 // test getSelectionByRoll() on issue #58
describe('test that roll behaviour multi day event edge cases', () => { describe('test that roll behaviour multi day event edge cases', () => {
it('if the start time is the day after end time, and start time is earlier than now', () => { it('if the start time is the day after end time, and start time is earlier than now', () => {
const now = 66600000; // 19:30 const now = 66600000; // 19:30
const eventlist = [ const eventlist = [
{ {
id: 1, id: 1,
timeStart: 66000000, // 19:20 timeStart: 66000000, // 19:20
timeEnd: 54600000, // 16:10 timeEnd: 54600000, // 16:10
isPublic: false, isPublic: false,
} },
]; ];
const expected = { const expected = {
nowIndex: 0, nowIndex: 0,
@@ -486,14 +484,14 @@ describe('test that roll behaviour multi day event edge cases', () => {
}); });
it('if the start time is the day after end time, and both are later than now', () => { it('if the start time is the day after end time, and both are later than now', () => {
const now = 66840000; // 19:34 const now = 66840000; // 19:34
const eventlist = [ const eventlist = [
{ {
id: 1, id: 1,
timeStart: 67200000, // 19:40 timeStart: 67200000, // 19:40
timeEnd: 66900000, // 19:35 timeEnd: 66900000, // 19:35
isPublic: false, isPublic: false,
} },
]; ];
const expected = { const expected = {
nowIndex: null, nowIndex: null,
@@ -512,11 +510,10 @@ describe('test that roll behaviour multi day event edge cases', () => {
// test normaliseEndTime() on issue #58 // test normaliseEndTime() on issue #58
test('test typical scenarios', () => { test('test typical scenarios', () => {
const t1 = { const t1 = {
start: 10, start: 10,
end: 20, end: 20,
} };
const t1_expected = 20; const t1_expected = 20;
expect(normaliseEndTime(t1.start, t1.end)).toBe(t1_expected); expect(normaliseEndTime(t1.start, t1.end)).toBe(t1_expected);
@@ -524,7 +521,7 @@ test('test typical scenarios', () => {
const t2 = { const t2 = {
start: 10 + DAY_TO_MS, start: 10 + DAY_TO_MS,
end: 20, end: 20,
} };
const t2_expected = 20 + DAY_TO_MS; const t2_expected = 20 + DAY_TO_MS;
expect(normaliseEndTime(t2.start, t2.end)).toBe(t2_expected); expect(normaliseEndTime(t2.start, t2.end)).toBe(t2_expected);
@@ -532,16 +529,14 @@ test('test typical scenarios', () => {
const t3 = { const t3 = {
start: 10, start: 10,
end: 10, end: 10,
} };
const t3_expected = 10; const t3_expected = 10;
expect(normaliseEndTime(t3.start, t3.end)).toBe(t3_expected); expect(normaliseEndTime(t3.start, t3.end)).toBe(t3_expected);
}); });
// test updateRoll() // test updateRoll()
describe('typical scenarios', () => { describe('typical scenarios', () => {
it('it updates running events correctly', () => { it('it updates running events correctly', () => {
const timers = { const timers = {
selectedEventId: 1, selectedEventId: 1,
+27 -2
View File
@@ -24,6 +24,7 @@ export class OSCIntegration {
time: 'time', time: 'time',
overtime: 'overtime', overtime: 'overtime',
title: 'title', title: 'title',
eventNumber: 'eventNumber',
presenter: 'presenter', presenter: 'presenter',
}; };
} }
@@ -36,6 +37,15 @@ export class OSCIntegration {
*/ */
init(oscConfig) { init(oscConfig) {
const { ip, port } = oscConfig; const { ip, port } = oscConfig;
const validateType = typeof ip !== 'string' || typeof port !== 'number';
const validateNull = ip == null || port == null;
if (validateType || validateNull) {
return {
success: false,
message: `Config options incorrect`,
};
}
try { try {
this.oscClient = new Client(ip, port); this.oscClient = new Client(ip, port);
return { return {
@@ -45,7 +55,7 @@ export class OSCIntegration {
} catch (error) { } catch (error) {
this.oscClient = null; this.oscClient = null;
return { return {
success: true, success: false,
message: `Failed initialising OSC Client: ${error}`, message: `Failed initialising OSC Client: ${error}`,
}; };
} }
@@ -101,9 +111,24 @@ export class OSCIntegration {
} }
break; break;
case 'eventNumber':
if (payload != null && payload !== '') {
// Send event number of current event
this.oscClient.send(`${this.ADDRESS}/eventNumber`, payload, (err) => {
if (err) {
reply.success = false;
reply.message = err;
}
});
} else {
reply.success = false;
reply.message = 'Missing message data';
}
break;
case 'presenter': case 'presenter':
if (payload != null && payload !== '') { if (payload != null && payload !== '') {
// Send presenter data on current event // Send timer data on current event
this.oscClient.send(`${this.ADDRESS}/presenter`, payload, (err) => { this.oscClient.send(`${this.ADDRESS}/presenter`, payload, (err) => {
if (err) { if (err) {
reply.success = false; reply.success = false;
@@ -0,0 +1,162 @@
import { OSCIntegration } from '../Osc';
import { Server } from 'node-osc';
test('Class initialises correctly', () => {
const osc = new OSCIntegration();
expect(osc.ADDRESS).toBe('/ontime');
expect(osc.oscClient).toBe(null);
// defined objects
expect(osc.implemented.play).toBeDefined();
expect(osc.implemented.pause).toBeDefined();
expect(osc.implemented.stop).toBeDefined();
expect(osc.implemented.previous).toBeDefined();
expect(osc.implemented.next).toBeDefined();
expect(osc.implemented.reload).toBeDefined();
expect(osc.implemented.finished).toBeDefined();
expect(osc.implemented.time).toBeDefined();
expect(osc.implemented.overtime).toBeDefined();
expect(osc.implemented.title).toBeDefined();
expect(osc.implemented.eventNumber).toBeDefined();
expect(osc.implemented.presenter).toBeDefined();
// initialise client succeeds
const { ip, port } = { ip: '127.0.0.1', port: 12345 };
const init = osc.init({ ip, port });
expect(init.message).toBe(`Initialised OSC Client at ${ip}:${port}`);
expect(init.success).toBe(true);
expect(osc.oscClient).not.toBe(null);
// object shutdown as expected
osc.shutdown();
expect(osc.oscClient).toBe(null);
});
describe('OSC fails to initialise when incorrect data is given', () => {
test('IP of wrong type', () => {
const osc = new OSCIntegration();
const init = osc.init({ ip: 123, port: 8888 });
expect(init.message).toBe('Config options incorrect');
expect(init.success).toBe(false);
expect(osc.oscClient).toBe(null);
});
test('IP is null', () => {
const osc = new OSCIntegration();
const init = osc.init({ ip: null, port: 8888 });
expect(init.message).toBe('Config options incorrect');
expect(init.success).toBe(false);
expect(osc.oscClient).toBe(null);
});
test('Port of wrong type', () => {
const osc = new OSCIntegration();
const init = osc.init({ ip: 'localhost', port: 'test' });
expect(init.message).toBe('Config options incorrect');
expect(init.success).toBe(false);
expect(osc.oscClient).toBe(null);
});
test('Port is null', () => {
const osc = new OSCIntegration();
const init = osc.init({ ip: 'localhost', port: null });
expect(init.message).toBe('Config options incorrect');
expect(init.success).toBe(false);
expect(osc.oscClient).toBe(null);
});
});
test('Test messages sending', async () => {
const testPort = 9999;
const testIP = 'localhost';
const testPayload = 'test';
const osc = new OSCIntegration();
const messages = [];
// prepare dummy server to receive messages
const oscServer = new Server(testPort, testIP);
oscServer.on('message', (m) => {
messages.push({ yay: m });
});
// try and send a message before initialising
const test = await osc.send('test');
expect(test.success).toBe(false);
expect(test.message).toBe('Client not initialised');
// initialise osc
osc.init({ ip: testIP, port: testPort });
// try and send unrecognised message
const test2 = await osc.send('test');
expect(test2.success).toBe(true);
// send play message
const playAddress = osc.implemented.play;
const playSent = await osc.send(playAddress);
expect(playSent.success).toBe(true);
// send pause message
const pauseAddress = osc.implemented.pause;
const pauseSent = await osc.send(pauseAddress);
expect(pauseSent.success).toBe(true);
// send stop message
const stopAddress = osc.implemented.stop;
const stopSent = await osc.send(stopAddress);
expect(stopSent.success).toBe(true);
// send previous message
const previousAddress = osc.implemented.previous;
const previousSent = await osc.send(previousAddress);
expect(previousSent.success).toBe(true);
// send next message
const nextAddress = osc.implemented.next;
const nextSent = await osc.send(nextAddress);
expect(nextSent.success).toBe(true);
// send reload message
const reloadAddress = osc.implemented.reload;
const reloadSent = await osc.send(reloadAddress);
expect(reloadSent.success).toBe(true);
// send finished message
const finishedAddress = osc.implemented.finished;
const finishedSent = await osc.send(finishedAddress);
expect(finishedSent.success).toBe(true);
// send time message
const timeAddress = osc.implemented.time;
const timeSent = await osc.send(timeAddress);
expect(timeSent.success).toBe(true);
// send overtime message
const overtimeAddress = osc.implemented.overtime;
const overtimeSent = await osc.send(overtimeAddress, testPayload);
expect(overtimeSent.success).toBe(true);
// send title message
const titleAddress = osc.implemented.title;
const titleSent = await osc.send(titleAddress, testPayload);
expect(titleSent.success).toBe(true);
// send eventNumber message
const eventNumberAddress = osc.implemented.eventNumber;
const eventNumberSent = await osc.send(eventNumberAddress, testPayload);
expect(eventNumberSent.success).toBe(true);
// send timer message
const presenterAddress = osc.implemented.presenter;
const presenterSent = await osc.send(presenterAddress, testPayload);
expect(presenterSent.success).toBe(true);
// cleanup
await osc.shutdown();
await oscServer.close();
// see messagesObject
// expect(messages.length).toBe(5);
});
+1 -1
View File
@@ -13,7 +13,7 @@ export const config = {
port: 8888, port: 8888,
portOut: 9999, portOut: 9999,
targetIP: '127.0.0.1', targetIP: '127.0.0.1',
enabled: true, inputEnabled: true,
}, },
http: { http: {
user: '', user: '',
+19 -13
View File
@@ -1,12 +1,12 @@
// get database // get database
import { db, data } from '../app.js'; import { data, db } from '../app.js';
// utils // utils
import { generateId } from 'ontime-utils/generate_id.js'; import { generateId } from 'ontime-utils/generate_id.js';
import { import {
event as eventDef,
delay as delayDef,
block as blockDef, block as blockDef,
delay as delayDef,
event as eventDef,
} from '../models/eventsDefinition.js'; } from '../models/eventsDefinition.js';
async function _insertAt(entry, index) { async function _insertAt(entry, index) {
@@ -73,9 +73,14 @@ export const eventsGetAll = async (req, res) => {
// Create controller for GET request to '/events/:eventId' // Create controller for GET request to '/events/:eventId'
// Returns - // Returns -
export const eventsGetById = async (req, res) => { export const eventsGetById = async (req, res) => {
const e = data.events.find({ id: req.params.eventId }).value(); const id = req.params?.eventId;
console.log('event by id', e);
res.json(e); if (id == null) {
res.status(400).send(`No eventId found in request`);
} else {
const event = data.events.find((e) => e.id === id);
res.json(event);
}
}; };
// Create controller for POST request to '/events/' // Create controller for POST request to '/events/'
@@ -89,23 +94,24 @@ export const eventsPost = async (req, res) => {
// ensure structure // ensure structure
let newEvent = {}; let newEvent = {};
req.body.id = generateId(); let id = req.body.id;
if (data.events.find((e) => e.id === id)) {
id = generateId();
}
switch (req.body.type) { switch (req.body.type) {
case 'event': case 'event':
newEvent = { ...eventDef, ...req.body }; newEvent = { ...eventDef, ...req.body, id };
break; break;
case 'delay': case 'delay':
newEvent = { ...delayDef, ...req.body }; newEvent = { ...delayDef, ...req.body, id };
break; break;
case 'block': case 'block':
newEvent = { ...blockDef, ...req.body }; newEvent = { ...blockDef, ...req.body, id };
break; break;
default: default:
res res.status(400).send(`Object type missing or unrecognised: ${req.body.type}`);
.status(400)
.send(`Object type missing or unrecognised: ${req.body.type}`);
break; break;
} }
+14 -1
View File
@@ -3,7 +3,7 @@ import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
// get database // get database
import { db, data } from '../app.js'; import { data, db } from '../app.js';
import { networkInterfaces } from 'os'; import { networkInterfaces } from 'os';
import { fileHandler } from '../utils/parser.js'; import { fileHandler } from '../utils/parser.js';
import { generateId } from 'ontime-utils/generate_id.js'; import { generateId } from 'ontime-utils/generate_id.js';
@@ -15,6 +15,19 @@ function getEventTitle() {
return data.event.title; return data.event.title;
} }
// Create controller for GET request to '/ontime/poll'
// Returns data for current state
export const poll = async (req, res) => {
try {
const s = global.timer.poll();
res.status(200).send(s);
} catch (error) {
res.status(500).send({
message: `Could not get sync data: ${error}`,
});
}
};
// Create controller for GET request to '/ontime/db' // Create controller for GET request to '/ontime/db'
// Returns - // Returns -
export const dbDownload = async (req, res) => { export const dbDownload = async (req, res) => {
+1 -1
View File
@@ -1,7 +1,7 @@
// Create controller for GET request to '/playback' // Create controller for GET request to '/playback'
// Returns ACK message // Returns ACK message
export const pbGet = async (req, res) => { export const pbGet = async (req, res) => {
res.send(global.timer.playState); res.send({ playback: global.timer.state });
}; };
// Create controller for GET request to '/playback/onAir' // Create controller for GET request to '/playback/onAir'
@@ -0,0 +1,22 @@
import { server, shutdown, startServer } from '../../app.js';
import supertest from 'supertest';
beforeAll(() => startServer());
afterAll(() => shutdown());
describe('When a GET request request is sent', () => {
test('GET /event returns a valid object', async () => {
await supertest(server)
.get('/event')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
expect(response.body).toBeDefined();
expect(typeof response.body.title).toBe('string');
expect(typeof response.body.url).toBe('string');
expect(typeof response.body.publicInfo).toBe('string');
expect(typeof response.body.backstageInfo).toBe('string');
expect(typeof response.body.endMessage).toBe('string');
});
});
});
@@ -0,0 +1,57 @@
import { server, shutdown, startServer } from '../../app.js';
import supertest from 'supertest';
beforeAll(() => startServer());
afterAll(() => shutdown());
const testEvent = {
title: 'API test event',
subtitle: 'test title',
presenter: 'test presenter',
note: 'test note',
timeStart: 0,
timeEnd: 42,
isPublic: false,
type: 'event',
id: 'superSpecial12',
};
describe('When a POST request is sent', () => {
test('POST /event should return a 201', async () => {
await supertest(server)
.post('/events')
.send(testEvent)
.expect(201)
});
});
describe('When a GET request request is sent', () => {
test('GET /events returns a valid object', async () => {
await supertest(server)
.get('/events')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
expect(response.body).toBeDefined();
expect(typeof response.body).toBe('object');
});
});
test('GET /events/:eventId returns a valid object', async () => {
await supertest(server)
.get(`/events/${testEvent.id}`)
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
expect(response.body).toBeDefined();
expect(response.body.title).toBe(testEvent.title);
expect(response.body.subtitle).toBe(testEvent.subtitle);
expect(response.body.presenter).toBe(testEvent.presenter);
expect(response.body.note).toBe(testEvent.note);
expect(response.body.timeStart).toBe(testEvent.timeStart);
expect(response.body.timeEnd).toBe(testEvent.timeEnd);
expect(response.body.isPublic).toBe(testEvent.isPublic);
expect(response.body.type).toBe(testEvent.type);
expect(response.body.id).toBe(testEvent.id);
});
});
});
@@ -0,0 +1,98 @@
import { server, shutdown, startServer } from '../../app.js';
import supertest from 'supertest';
beforeAll(() => startServer());
afterAll(() => shutdown());
describe('When a GET request request is sent', () => {
test('GET /ontime/poll returns a valid object', async () => {
await supertest(server)
.get('/ontime/poll')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
expect(response.body).toBeDefined();
expect(typeof response.body.clock).toBe('number');
expect(typeof response.body.running).toBe('number');
expect(typeof response.body.timer).toBe('string');
expect(typeof response.body.playback).toBe('string');
expect(typeof response.body.title).toBe('string');
expect(typeof response.body.presenter).toBe('string');
});
});
test('GET /ontime/db returns a JSON object', async () => {
await supertest(server)
.get('/ontime/db')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
expect(response.body).toBeDefined();
expect(response.headers['content-type']).toContain('json');
});
});
test('GET /ontime/info returns a valid object', async () => {
await supertest(server)
.get('/ontime/info')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
expect(response.body).toBeDefined();
expect(typeof response.body.networkInterfaces).toBe('object');
expect(typeof response.body.version).toBe('number');
expect(typeof response.body.serverPort).toBe('number');
expect(typeof response.body.osc).toBe('object');
});
});
test('GET /ontime/aliases returns a valid object', async () => {
await supertest(server)
.get('/ontime/aliases')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
expect(response.body).toBeDefined();
expect(typeof response.body).toBe('object');
});
});
test('GET /ontime/settings returns a valid object', async () => {
await supertest(server)
.get('/ontime/settings')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
expect(response.body).toBeDefined();
expect(typeof response.body.version).toBe('number');
expect(typeof response.body.serverPort).toBe('number');
expect(typeof response.body.pinCode).toBeDefined();
});
});
test('GET /ontime/osc returns a valid object', async () => {
await supertest(server)
.get('/ontime/osc')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
expect(response.body).toBeDefined();
expect(typeof response.body.port).toBe('number');
expect(typeof response.body.portOut).toBe('number');
expect(typeof response.body.targetIP).toBe('string');
expect(typeof response.body.enabled).toBe('boolean');
});
});
});
describe('Any other returns the app', () => {
test('GET / returns ontime app', async () => {
await supertest(server)
.get('/')
.expect(200)
.then((response) => {
expect(response.body).toBeDefined();
expect(response.text.includes('<!doctype html>')).toBe(true);
});
});
});
@@ -0,0 +1,129 @@
import { server, shutdown, startServer } from '../../app.js';
import supertest from 'supertest';
beforeAll(() => startServer());
afterAll(() => shutdown());
describe('When a GET request request is sent', () => {
test('GET /playback returns a valid object', async () => {
await supertest(server)
.get('/playback')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
expect(response.body).toBeDefined();
expect(typeof response.body.playback).toBe('string');
expect(response.body.playback).toBe('stop' || 'start' || 'pause' || 'roll');
});
});
});
describe('When a GET state change is sent', () => {
test('GET /playback/onAir returns 200', async () => {
await supertest(server)
.get('/playback/onAir')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('GET /playback/offAir returns 200', async () => {
await supertest(server)
.get('/playback/offAir')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('GET /playback/start returns 200', async () => {
await supertest(server)
.get('/playback/start')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('GET /playback/play returns 200', async () => {
await supertest(server)
.get('/playback/play')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('GET /playback/pause returns 200', async () => {
await supertest(server)
.get('/playback/pause')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('GET /playback/stop returns 200', async () => {
await supertest(server)
.get('/playback/stop')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('GET /playback/roll returns 200', async () => {
await supertest(server)
.get('/playback/roll')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('GET /playback/previous returns 200', async () => {
await supertest(server)
.get('/playback/previous')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('GET /playback/next returns 200', async () => {
await supertest(server)
.get('/playback/next')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('GET /playback/unload returns 200', async () => {
await supertest(server)
.get('/playback/unload')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('GET /playback/reload returns 200', async () => {
await supertest(server)
.get('/playback/reload')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('GET of unknown request returns app', async () => {
await supertest(server)
.get('/playback/madeup')
.expect(200)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(true);
});
});
});
+4
View File
@@ -15,8 +15,12 @@ import {
postSettings, postSettings,
getAliases, getAliases,
postAliases, postAliases,
poll,
} from '../controllers/ontimeController.js'; } from '../controllers/ontimeController.js';
// create route between controller and '/ontime/sync' endpoint
router.get('/poll', poll);
// create route between controller and '/ontime/db' endpoint // create route between controller and '/ontime/db' endpoint
router.get('/db', dbDownload); router.get('/db', dbDownload);
+1 -1
View File
@@ -13,7 +13,7 @@ import {
pbPrevious, pbPrevious,
pbNext, pbNext,
pbUnload, pbUnload,
pbReload pbReload,
} from '../controllers/playbackController.js'; } from '../controllers/playbackController.js';
// create route between controller and '/playback/' endpoint // create route between controller and '/playback/' endpoint
+3 -4
View File
@@ -1,9 +1,9 @@
import fs from 'fs'; import fs from 'fs';
import xlsx from 'node-xlsx'; import xlsx from 'node-xlsx';
import { import {
event as eventDef,
delay as delayDef,
block as blockDef, block as blockDef,
delay as delayDef,
event as eventDef,
} from '../models/eventsDefinition.js'; } from '../models/eventsDefinition.js';
import { dbModelv1 } from '../models/dataModel.js'; import { dbModelv1 } from '../models/dataModel.js';
import { generateId } from 'ontime-utils/generate_id.js'; import { generateId } from 'ontime-utils/generate_id.js';
@@ -183,7 +183,6 @@ export const parseExcel_v1 = async (excelData) => {
events.push({ ...event, type: 'event' }); events.push({ ...event, type: 'event' });
} }
}); });
return { return {
events, events,
event: eventData, event: eventData,
@@ -429,7 +428,7 @@ export const parseOsc_v1 = (data, enforce) => {
if (s.port) osc.port = s.port; if (s.port) osc.port = s.port;
if (s.portOut) osc.portOut = s.portOut; if (s.portOut) osc.portOut = s.portOut;
if (s.targetIP) osc.targetIP = s.targetIP; if (s.targetIP) osc.targetIP = s.targetIP;
if (s.enabled) osc.enabled = s.enabled; if (s.enabled !== undefined) osc.enabled = s.enabled;
// write to db // write to db
newOsc = { newOsc = {
-14
View File
@@ -58,20 +58,6 @@ describe('test string to millis function', () => {
}); });
describe('test excel date parser', () => { describe('test excel date parser', () => {
it('parses the given dates correctly', () => {
const d0 = '1899-12-30T00:00:00.000Z';
const d1 = '1899-12-30T08:00:00.000Z';
const d2 = '1899-12-30T08:30:00.000Z';
const d0Millis = 0;
const d1Millis = 28800000;
const d2Millis = 30600000;
expect(excelDateStringToMillis(d0)).toBe(d0Millis);
expect(excelDateStringToMillis(d1)).toBe(d1Millis);
expect(excelDateStringToMillis(d2)).toBe(d2Millis);
});
it('handles an invalid date string', () => { it('handles an invalid date string', () => {
const s = 'hello'; const s = 'hello';
expect(excelDateStringToMillis(s)).toBe(0); expect(excelDateStringToMillis(s)).toBe(0);
+1 -1
View File
@@ -57,7 +57,7 @@ export const stringFromMillis = (
export const excelDateStringToMillis = (excelDate) => { export const excelDateStringToMillis = (excelDate) => {
const date = new Date(excelDate); const date = new Date(excelDate);
if (date instanceof Date && !isNaN(date)) { if (date instanceof Date && !isNaN(date)) {
const h = date.getUTCHours(); const h = date.getHours();
const m = date.getMinutes(); const m = date.getMinutes();
const s = date.getSeconds(); const s = date.getSeconds();
+957 -29
View File
File diff suppressed because it is too large Load Diff