Merge pull request #18 from cpvalente/chore/cleanup

Chore/cleanup
This commit is contained in:
Carlos Valente
2021-10-29 13:48:15 +02:00
committed by GitHub
58 changed files with 1867 additions and 1215 deletions
+2 -2
View File
@@ -29,7 +29,7 @@ TODO.md
# working stuff
_SS/
.vscode/launch.json
db.json
.eslintrc.json
db.json
db backup.json
server/src/data/db.json
server/src/models/db.json
+6 -7
View File
@@ -3,27 +3,26 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@chakra-ui/react": "^1.4.1",
"@emotion/react": "^11",
"@emotion/styled": "^11",
"@chakra-ui/react": "^1.6.10",
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"axios": "^0.21.1",
"date-fns": "^2.20.1",
"framer-motion": "^4.1.6",
"jotai": "^0.16.5",
"react": "^17.0.1",
"react-beautiful-dnd": "^13.1.0",
"react-dom": "^17.0.1",
"react-fast-compare": "^3.2.0",
"react-icons": "^4.2.0",
"react-qr-code": "^1.1.1",
"react-icons": "^4.3.1",
"react-qr-code": "^2.0.2",
"react-query": "^3.13.5",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"socket.io-client": "^4.0.1",
"socket.io-client": "^4.3.2",
"tinykeys": "^1.1.2",
"typeface-open-sans": "^1.1.13",
"web-vitals": "^1.0.1"
+14 -7
View File
@@ -5,14 +5,21 @@
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#121212" />
<meta
name="onsite"
content="onsite - event timer manager"
/>
<meta name="onsite" content="onsite - event timer manager" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest">
<link
rel="icon"
type="image/png"
sizes="32x32"
href="%PUBLIC_URL%/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="%PUBLIC_URL%/favicon-16x16.png"
/>
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
+4
View File
@@ -14,3 +14,7 @@ html,
user-select: none;
-webkit-app-region: drag;
}
option {
color: black;
}
+19 -1
View File
@@ -1,9 +1,10 @@
import { lazy, Suspense } from 'react';
import { lazy, Suspense, useEffect } from 'react';
import { Route, Switch } from 'react-router-dom';
import './App.css';
import { QueryClient, QueryClientProvider } from 'react-query';
import SocketProvider from 'app/context/socketContext';
import withSocket from 'features/viewers/ViewWrapper';
import tinykeys from 'tinykeys';
const Editor = lazy(() => import('features/editors/Editor'));
const PresenterView = lazy(() =>
@@ -36,6 +37,22 @@ const SLowerThird = withSocket(Lower);
const SPip = withSocket(Pip);
function App() {
useEffect(() => {
let unsubscribe = tinykeys(window, {
'Alt+t': () => {
// if we are in electron
if (window.process?.type === undefined) return;
if (window.process.type === 'renderer') {
// ask to see debug
window.ipcRenderer.send('set-window', 'show-dev');
}
},
});
return () => {
unsubscribe();
};
});
return (
<SocketProvider>
<QueryClientProvider client={queryClient}>
@@ -45,6 +62,7 @@ function App() {
<Route exact path='/' component={SSpeaker} />
<Route exact path='/sm' component={SStageManager} />
<Route exact path='/speaker' component={SSpeaker} />
<Route exact path='/stage' component={SSpeaker} />
<Route exact path='/speakersimple' component={SSpeakerSimple} />
<Route exact path='/editor' component={Editor} />
<Route exact path='/public' component={SPublic} />
+10 -1
View File
@@ -3,6 +3,11 @@ import { ontimeURL } from './apiConstants';
export const ontimePlaceholderInfo = {
networkInterfaces: [],
version: '',
serverPort: 4001,
oscInPort: '',
oscOutPort: '',
oscOutIP: '',
};
export const getInfo = async () => {
@@ -10,6 +15,11 @@ export const getInfo = async () => {
return res.data;
};
export const postInfo = async (data) => {
const res = await axios.post(ontimeURL + '/info', data);
return res;
};
export const downloadEvents = async () => {
await axios({
url: ontimeURL + '/db',
@@ -17,7 +27,6 @@ export const downloadEvents = async () => {
responseType: 'blob', // important
}).then((response) => {
let headerLine = response.headers['Content-Disposition'];
console.log(response);
let filename = 'events.json';
// try and get the filename from the response
@@ -7,9 +7,11 @@ export default function AddIconBtn(props) {
<IconButton
size={props.size || 'xs'}
icon={<FiPlus />}
colorScheme='blue'
onClick={clickhandler}
_expanded={{ bg: 'orange.300', color: 'white' }}
_focus={{ boxShadow: 'none' }}
backgroundColor={'orange.200'}
color={'orange.500'}
onClick={clickhandler}
{...rest}
/>
);
@@ -1,11 +1,11 @@
import { IconButton } from '@chakra-ui/button';
import { FiCornerLeftUp } from 'react-icons/fi';
import { FiXOctagon } from 'react-icons/fi';
export default function UnloadIconBtn(props) {
const { clickhandler, ...rest } = props;
return (
<IconButton
icon={<FiCornerLeftUp />}
icon={<FiXOctagon />}
colorScheme='whiteAlpha'
backgroundColor='#ffffff05'
variant='outline'
@@ -7,7 +7,6 @@ export default function Paginator(props) {
const { events, selectedId } = props;
const LIMIT_PER_PAGE = props.limit || 8;
const SCROLL_TIME = props.time * 1000 || 10000;
const SCROLL_PAST = false;
const [numEvents, setNumEvents] = useState(0);
const [page, setPage] = useState([]);
const [pages, setPages] = useState(0);
@@ -37,6 +37,10 @@
.entryTitle {
align-self: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entryPast,
@@ -30,22 +30,29 @@ const Playback = ({ playback, selectedId, playbackControl }) => {
/>
<RollIconBtn
active={playback === 'roll'}
disabled={playback === 'roll'}
clickhandler={() => playbackControl('roll')}
/>
</div>
);
};
const Transport = ({ selectedId, playbackControl }) => {
const Transport = ({ playback, selectedId, playbackControl }) => {
return (
<div className={style.playbackContainer}>
<PrevIconBtn clickhandler={() => playbackControl('previous')} />
<NextIconBtn clickhandler={() => playbackControl('next')} />
<UnloadIconBtn clickhandler={() => playbackControl('unload')} />
<PrevIconBtn
clickhandler={() => playbackControl('previous')}
disabled={playback === 'roll'}
/>
<NextIconBtn
clickhandler={() => playbackControl('next')}
disabled={playback === 'roll'}
/>
<ReloadIconButton
clickhandler={() => playbackControl('reload')}
disabled={!selectedId}
/>
<UnloadIconBtn clickhandler={() => playbackControl('unload')} />
</div>
);
};
@@ -60,6 +67,7 @@ const PlaybackButtons = (props) => {
playbackControl={props.playbackControl}
/>
<Transport
playback={playback}
selectedId={selectedId}
playbackControl={props.playbackControl}
/>
@@ -12,6 +12,7 @@ export default function PlaybackControl() {
running: null,
startedAt: null,
expectedFinish: null,
secondary: null,
});
const [selectedId, setSelectedId] = useState(null);
@@ -20,6 +21,7 @@ export default function PlaybackControl() {
running: null,
startedAt: null,
expectedFinish: null,
secondary: null,
});
};
@@ -105,6 +105,11 @@
font-size: 0.9em;
}
.rolltag {
color: #2b6cb0;
font-size: 0.9em;
}
.playbackContainer {
display: flex;
justify-content: space-evenly;
+29 -12
View File
@@ -1,6 +1,7 @@
import style from './PlaybackControl.module.css';
import Countdown from 'common/components/countdown/Countdown';
import { stringFromMillis } from 'common/dateConfig';
import { Tooltip } from '@chakra-ui/react';
import { Button } from '@chakra-ui/button';
import { memo } from 'react';
@@ -9,7 +10,8 @@ const areEqual = (prevProps, nextProps) => {
prevProps.timer.running === nextProps.timer.running &&
prevProps.timer.expectedFinish === nextProps.timer.expectedFinish &&
prevProps.timer.startedAt === nextProps.timer.startedAt &&
prevProps.playback === nextProps.playback
prevProps.playback === nextProps.playback &&
prevProps.timer.secondary === nextProps.timer.secondary
);
};
@@ -18,8 +20,8 @@ const PlaybackTimer = (props) => {
const started = stringFromMillis(timer.startedAt, true);
const finish = stringFromMillis(timer.expectedFinish, true);
const isNegative = timer.running < 0;
const isDelayed = false;
const isRolling = playback === 'roll';
const isWaiting = timer.secondary > 0 && timer.running == null;
const incrementProps = {
size: 'sm',
@@ -33,23 +35,38 @@ const PlaybackTimer = (props) => {
<>
<div className={style.timeContainer}>
<div className={style.indicators}>
<div className={isRolling ? style.indRollActive : style.indRoll} />
<Tooltip label='Roll mode active'>
<div className={isRolling ? style.indRollActive : style.indRoll} />
</Tooltip>
<div
className={isNegative ? style.indNegativeActive : style.indNegative}
/>
<div className={style.indDelay} />
</div>
<div className={style.timer}>
<Countdown time={timer?.running} small negative={isNegative} />
</div>
<div className={style.start}>
<span className={style.tag}>Started at </span>
<span className={style.time}>{started}</span>
</div>
<div className={style.finish}>
<span className={style.tag}>Finish at </span>
<span className={style.time}>{finish}</span>
<Countdown
time={isWaiting ? timer.secondary : timer.running}
small
negative={isNegative}
/>
</div>
{isWaiting ? (
<div className={style.start}>
<span className={style.rolltag}>Roll: Countdown to start</span>
<span className={style.time}>{''}</span>
</div>
) : (
<>
<div className={style.start}>
<span className={style.tag}>Started at </span>
<span className={style.time}>{started}</span>
</div>
<div className={style.finish}>
<span className={style.tag}>Finish at </span>
<span className={style.time}>{finish}</span>
</div>
</>
)}
<div className={style.btn}>
<Button
{...incrementProps}
@@ -114,7 +114,7 @@ h1 {
}
.content {
padding-top: 1.5em;
padding-top: 0.5em;
}
.cornerButtonContainer {
@@ -28,7 +28,7 @@ export default function ActionButtons(props) {
onClick={() => actionHandler('event')}
isDisabled={!showAdd}
>
Event next
Add Event after
</MenuItem>
<MenuItem
@@ -36,14 +36,14 @@ export default function ActionButtons(props) {
onClick={() => actionHandler('delay')}
isDisabled={!showDelay}
>
Delay next
Add Delay after
</MenuItem>
<MenuItem
icon={<FiMinusCircle />}
onClick={() => actionHandler('block')}
isDisabled={!showBlock}
>
Block next
Add Block after
</MenuItem>
</MenuList>
</Menu>
@@ -1,5 +1,5 @@
import Icon from '@chakra-ui/icon';
import { FiChevronDown, FiChevronUp, FiMoreVertical } from 'react-icons/fi';
import { FiChevronUp, FiMoreVertical } from 'react-icons/fi';
import { useMemo } from 'react';
import { Draggable } from 'react-beautiful-dnd';
import EventTimes from 'common/components/eventTimes/EventTimes';
@@ -86,13 +86,6 @@ const ExpandedBlock = (props) => {
{`/ontime/goto ${eventIndex + 1} << OSC >> /ontime/gotoid ${oscid}`}
</span>
</div>
<Icon
className={style.more}
as={FiChevronUp}
marginTop='0.2em'
gridArea='more'
onClick={() => props.setCollapsed(true)}
/>
<div className={style.actionOverlay}>
<PublicIconBtn actionHandler={actionHandler} active={data.isPublic} />
<ActionButtons
@@ -139,13 +132,6 @@ const CollapsedBlock = (props) => {
}
/>
</div>
<Icon
className={style.more}
as={FiChevronDown}
marginTop='0.2em'
gridArea='more'
onClick={() => props.setCollapsed(false)}
/>
<div className={style.actionOverlay}>
<PublicIconBtn actionHandler={actionHandler} active={data.isPublic} />
<ActionButtons
@@ -176,6 +162,7 @@ export default function EventBlock(props) {
const delayValue = delay > 0 ? millisToMinutes(delay) : null;
const handleCollapse = (isCollapsed) => {
console.log('setting collapsed', isCollapsed);
setCollapsed({ [data.id]: isCollapsed });
};
@@ -187,6 +174,11 @@ export default function EventBlock(props) {
{...provided.draggableProps}
ref={provided.innerRef}
>
<Icon
className={collapsed ? style.moreCollapsed : style.moreExpanded}
as={FiChevronUp}
onClick={() => handleCollapse(!collapsed)}
/>
{collapsed ? (
<CollapsedBlock
provided={provided}
@@ -195,7 +187,6 @@ export default function EventBlock(props) {
delay={delay}
delayValue={delayValue}
actionHandler={actionHandler}
setCollapsed={handleCollapse}
/>
) : (
<ExpandedBlock
@@ -206,7 +197,6 @@ export default function EventBlock(props) {
delay={delay}
delayValue={delayValue}
actionHandler={actionHandler}
setCollapsed={handleCollapse}
/>
)}
</div>
@@ -144,6 +144,9 @@
border-radius: 4px;
width: 100%;
display: block;
white-space: nowrap;
overflow: hidden;
}
.oscLabel {
@@ -157,9 +160,24 @@
/* ================ MORE ================ */
.more {
.moreExpanded,
.moreCollapsed {
cursor: pointer;
color: #fff;
grid-area: more;
margin-top: 0.2em;
}
.moreExpanded {
transform: scaleY(-1);
transition: transform 0.3s;
}
.moreCollapsed {
transform: scaleY(1);
transition: transform 0.3s;
}
/* ============== ACTION ================ */
.actionOverlay {
@@ -127,7 +127,6 @@ export default function EventList(props) {
});
};
console.log('EventList: events in event list', events);
let cumulativeDelay = 0;
let eventIndex = -1;
@@ -35,12 +35,10 @@ export default function EventListWrapper() {
// Snapshot the previous value
let previousEvents = queryClient.getQueryData(EVENTS_TABLE);
console.log('debug', previousEvents);
if (previousEvents == null) {
refetch();
previousEvents = queryClient.getQueryData(EVENTS_TABLE);
}
console.log('debug 2', previousEvents);
// optimistically update object, temp ID until refetch
let optimistic = [...previousEvents];
@@ -241,52 +239,40 @@ export default function EventListWrapper() {
switch (action) {
case 'add':
try {
let t = Date.now();
await addEvent.mutateAsync(payload);
console.log('debug m add', Date.now() - t);
} catch (error) {
showErrorToast('Error creating event', error.message);
}
break;
case 'update':
try {
let t = Date.now();
await updateEvent.mutateAsync(payload);
console.log('debug m update', Date.now() - t);
} catch (error) {
showErrorToast('Error updating event', error.message);
}
break;
case 'patch':
try {
let t = Date.now();
await patchEvent.mutateAsync(payload);
console.log('debug m patch', Date.now() - t);
} catch (error) {
showErrorToast('Error updating event', error.message);
}
break;
case 'delete':
try {
let t = Date.now();
await deleteEvent.mutateAsync(payload);
console.log('debug m delete', Date.now() - t);
} catch (error) {
showErrorToast('Error deleting event', error.message);
}
break;
case 'reorder':
try {
let t = Date.now();
await reorderEvent.mutateAsync(payload);
console.log('debug m reorder', Date.now() - t);
} catch (error) {
showErrorToast('Error reordering event', error.message);
}
break;
case 'applyDelay':
let t = Date.now();
// if delay <= 0 delete delay and next block
if (payload.duration <= 0) {
try {
@@ -309,14 +295,12 @@ export default function EventListWrapper() {
showErrorToast('Error applying delay', error.message);
}
} else {
console.log('debug applydelay', payload.id);
try {
await applyDelay.mutateAsync(payload.id);
} catch (error) {
showErrorToast('Error applying delay', error.message);
}
}
console.log('debug m apply', Date.now() - t);
break;
case 'collapseall':
@@ -330,9 +314,7 @@ export default function EventListWrapper() {
case 'deleteall':
try {
let t = Date.now();
await deleteAllEvents.mutateAsync();
console.log('debug m deleteall', Date.now() - t);
} catch (error) {
showErrorToast('Error deleting events', error.message);
}
+3 -2
View File
@@ -1,10 +1,9 @@
import { useEffect } from 'react';
import { useState, useEffect } from 'react';
import { useSocket } from 'app/context/socketContext';
import style from './Info.module.css';
import InfoTitle from './InfoTitle';
import InfoLogger from './InfoLogger';
import InfoNif from './InfoNif';
import { useState } from 'react';
export default function Info() {
const socket = useSocket();
@@ -21,6 +20,8 @@ export default function Info() {
const [selected, setSelected] = useState('No events');
const logData = [];
console.log(`titles`, titles);
// handle incoming messages
useEffect(() => {
if (socket == null) return;
+8
View File
@@ -24,6 +24,12 @@
justify-content: space-between;
}
.labelContainer {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.label,
.emptyLabel {
padding: 0;
@@ -47,6 +53,8 @@
.notes {
color: #d69e2e;
overflow: hidden;
text-overflow: ellipsis;
}
.if {
+4 -3
View File
@@ -11,12 +11,13 @@ export default function InfoNif() {
placeholderData: ontimePlaceholderInfo,
});
const [collapsed, setCollapsed] = useState(false);
const baseURL = `http://__IP__:${4001}`;
const baseURL = 'http://__IP__:4001';
const handleLink = (url) => {
if (window.process.type === 'renderer') {
if (window.process?.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
} else {
window.open(url);
}
};
+3 -3
View File
@@ -26,19 +26,19 @@ export default function InfoTitle(props) {
{!collapsed && (
<>
<div>
<div className={style.labelContainer}>
<span className={noTitl ? style.emptyLabel : style.label}>
Title
</span>
{data.title}
</div>
<div>
<div className={style.labelContainer}>
<span className={noPres ? style.emptyLabel : style.label}>
Presenter
</span>
{data.presenter}
</div>
<div>
<div className={style.labelContainer}>
<span className={noSubt ? style.emptyLabel : style.label}>
Subtitle
</span>
@@ -24,16 +24,16 @@ export default function MenuActionButtons(props) {
/>
<MenuList style={menuStyle}>
<MenuItem icon={<FiPlus />} onClick={() => actionHandler('event')}>
Event first
Add Event first
</MenuItem>
<MenuItem icon={<FiClock />} onClick={() => actionHandler('delay')}>
Delay first
Add Delay first
</MenuItem>
<MenuItem
icon={<FiMinusCircle />}
onClick={() => actionHandler('block')}
>
Block first
Add Block first
</MenuItem>
<Divider />
<MenuItem
+6 -1
View File
@@ -45,7 +45,12 @@ export default function MenuBar(props) {
const handleIPC = (action) => {
// Stop crashes when testing locally
if (window.process?.type === undefined) return;
if (window.process?.type === undefined) {
if (action === 'help') {
window.open('https://cpvalente.gitbook.io/ontime/');
}
return;
}
if (window.process.type === 'renderer') {
switch (action) {
+152
View File
@@ -0,0 +1,152 @@
import { IconButton } from '@chakra-ui/button';
import { FiPlus, FiMinus } from 'react-icons/fi';
import { ModalBody } from '@chakra-ui/modal';
import { Input } from '@chakra-ui/react';
import { fetchEvent } from 'app/api/eventApi';
import { useState } from 'react';
import { useFetch } from 'app/hooks/useFetch';
import { EVENT_TABLE } from 'app/api/apiConstants';
import style from './Modals.module.css';
export default function AliasesModal() {
const { data, status, isError } = useFetch(EVENT_TABLE, fetchEvent);
const [submitting, setSubmitting] = useState(false);
const submitHandler = async (event) => {
event.preventDefault();
// NOTHING HERE YET
};
// Hardcoded links for now
// it will need dynamic PORT assignment
const speakerLink = 'http://localhost:4001/speaker';
const smLink = 'http://localhost:4001/sm';
const publicLink = 'http://localhost:4001/public';
const pipLink = 'http://localhost:4001/pip';
return (
<>
<form onSubmit={submitHandler}>
<ModalBody className={style.modalBody}>
<p className={style.notes}>
Configure easy to use URL Aliases
<br />
!!! Feature is not yet implemented !!!
</p>
<span> Default URLs </span>
<div className={style.highNotes}>
<p className={style.flexNote}>
Presenter Screen <br />
<a
href={speakerLink}
target='_blank'
rel='noreferrer'
className={style.label}
>
{speakerLink}
</a>
</p>
<p className={style.flexNote}>
Backstage / Stage Manager Screen <br />
<a
href={smLink}
target='_blank'
rel='noreferrer'
className={style.label}
>
{smLink}
</a>
</p>
<p className={style.flexNote}>
Public / Foyer Screen <br />
<a
href={publicLink}
target='_blank'
rel='noreferrer'
className={style.label}
>
{publicLink}
</a>
</p>
<p className={style.flexNote}>
Picture in Picture Screen <br />
<a
href={pipLink}
target='_blank'
rel='noreferrer'
className={style.label}
>
{pipLink}
</a>
</p>
</div>
<span> Manage custom aliases</span>
<div className={style.modalInline}>
<Input
size='sm'
name='URL'
placeholder='A long URL'
autoComplete='off'
value={'A long URL'}
onChange={(event) => {
// Nothing here yet
}}
isDisabled={true}
/>
<Input
size='sm'
name='Alias'
placeholder='A nice alias'
autoComplete='off'
value={'A nice alias'}
onChange={(event) => {
// Nothing here yet
}}
isDisabled={true}
/>
<IconButton
size='sm'
icon={<FiMinus />}
colorScheme='red'
disabled
/>
</div>
<div className={style.separator} />
<div className={style.modalInline}>
<Input
size='sm'
name='URL'
placeholder='URL'
autoComplete='off'
value={'URL'}
onChange={(event) => {
// Nothing here yet
}}
isDisabled={true}
/>
<Input
size='sm'
name='Alias'
placeholder='Alias'
autoComplete='off'
value={'Alias'}
onChange={(event) => {
// Nothing here yet
}}
isDisabled={true}
/>
<IconButton
size='sm'
icon={<FiPlus />}
colorScheme='blue'
disabled
/>
</div>
</ModalBody>
</form>
</>
);
}
@@ -0,0 +1,185 @@
import { ModalBody } from '@chakra-ui/modal';
import { FormLabel, FormControl, Input, Button } from '@chakra-ui/react';
import { getInfo, ontimePlaceholderInfo, postInfo } from 'app/api/ontimeApi';
import { useEffect, useState } from 'react';
import { useFetch } from 'app/hooks/useFetch';
import { APP_TABLE } from 'app/api/apiConstants';
import { showErrorToast } from 'common/helpers/toastManager';
import style from './Modals.module.css';
export default function AppSettingsModal() {
const { data, status } = useFetch(APP_TABLE, getInfo);
const [formData, setFormData] = useState(ontimePlaceholderInfo);
const [changed, setChanged] = useState(false);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (data == null) return;
setFormData({
oscInPort: data.oscInPort,
oscOutPort: data.oscOutPort,
oscOutIP: data.oscOutIP,
});
}, [data]);
const submitHandler = async (event) => {
event.preventDefault();
const f = formData;
let e = { status: false, message: '' };
// Validate fields
if (f.oscInPort < 1024 || f.oscInPort > 65535) {
// Port in incorrect range
e.status = true;
e.message += 'OSC IN Port in incorrect range (1024 - 65535)';
} else if (f.oscOutPort < 1024 || f.oscOutPort > 65535) {
// Port in incorrect range
e.status = true;
e.message += 'OSC OUT Port in incorrect range (1024 - 65535)';
} else if (f.oscInPort === f.oscOutPort) {
// Cant use the same port
e.status = true;
e.message += 'OSC IN and OUT Ports cant be the same';
}
// set fields with error
if (e.status) {
showErrorToast('Invalid Input', e.message);
return;
}
// Post here
postInfo(formData);
setChanged(false);
setSubmitting(false);
};
return (
<>
<form onSubmit={submitHandler}>
<ModalBody className={style.modalBody}>
{status === 'success' && (
<>
<p className={style.notes}>
Options related to the application
<br />
!!! Changes take effect after app restart !!!
</p>
<FormControl id='serverPort'>
<FormLabel htmlFor='serverPort'>
Viewer Port
<span className={style.notes}>Port to access viewers</span>
</FormLabel>
<Input
size='sm'
name='title'
placeholder='4001'
autoComplete='off'
value={4001}
readOnly
style={{ width: '6em', textAlign: 'center' }}
/>
<span className={style.notes}>(Read Only Value)</span>
</FormControl>
<FormControl id='oscInPort'>
<FormLabel htmlFor='oscInPort'>
OSC In Port
<span className={style.notes}>
<br />
App Control - Default 8888
</span>
</FormLabel>
<Input
size='sm'
name='oscInPort'
placeholder='8888'
autoComplete='off'
type='number'
value={formData.oscInPort}
min='1024'
max='65535'
onChange={(event) => {
setChanged(true);
setFormData({
...formData,
oscInPort: parseInt(event.target.value),
});
}}
isDisabled={submitting}
style={{ width: '6em', textAlign: 'center' }}
/>
</FormControl>
<div className={style.modalInline}>
<FormControl id='oscOutIP' width='auto'>
<FormLabel htmlFor='oscOutIP'>
OSC Out Target IP
<span className={style.notes}>
<br />
App Feedback - Default 127.0.0.1
</span>
</FormLabel>
<Input
size='sm'
name='oscOutIP'
placeholder='127.0.0.1'
autoComplete='off'
value={formData.oscOutIP}
onChange={(event) => {
setChanged(true);
setFormData({
...formData,
oscOutIP: event.target.value,
});
}}
isDisabled={submitting}
style={{ width: '12em', textAlign: 'right' }}
/>
</FormControl>
<FormControl id='oscOutPort' width='auto'>
<FormLabel htmlFor='oscOutPort'>
OSC Out Port
<span className={style.notes}>
<br />
Default 9999
</span>
</FormLabel>
<Input
size='sm'
name='oscOutPort'
placeholder='9999'
autoComplete='off'
type='number'
value={formData.oscOutPort}
min='1024'
max='65535'
onChange={(event) => {
setChanged(true);
setFormData({
...formData,
oscOutPort: parseInt(event.target.value),
});
}}
isDisabled={submitting}
style={{ width: '6em', textAlign: 'left' }}
/>
</FormControl>
</div>
</>
)}
<Button
colorScheme='blue'
type='submit'
isLoading={submitting}
disabled={!changed}
>
Save
</Button>
</ModalBody>
</form>
</>
);
}
@@ -10,15 +10,18 @@ import { fetchEvent, postEvent } from 'app/api/eventApi';
import { useEffect, useState } from 'react';
import { useFetch } from 'app/hooks/useFetch';
import { EVENT_TABLE } from 'app/api/apiConstants';
import style from './Modals.module.css';
export default function SettingsModal() {
const { data, status, isError } = useFetch(EVENT_TABLE, fetchEvent);
const { data, status } = useFetch(EVENT_TABLE, fetchEvent);
const [formData, setFormData] = useState({
title: '',
url: '',
publicInfo: '',
backstageInfo: '',
endMessage: '',
});
const [changed, setChanged] = useState(false);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
@@ -29,6 +32,7 @@ export default function SettingsModal() {
url: data.url,
publicInfo: data.publicInfo,
backstageInfo: data.backstageInfo,
endMessage: data.endMessage,
});
}, [data]);
@@ -36,41 +40,47 @@ export default function SettingsModal() {
event.preventDefault();
setSubmitting(true);
await postEvent(formData).then(setSubmitting(false));
await postEvent(formData);
setChanged(false);
setSubmitting(false);
};
return (
<>
<form onSubmit={submitHandler}>
<ModalBody>
<ModalBody className={style.modalBody}>
{status === 'success' && (
<>
<p className={style.notes}>
Options related to the running event
<br />
Affect rendered views
</p>
<FormControl id='title'>
<FormLabel
style={{ fontWeight: 400, paddingTop: '1em' }}
htmlFor='title'
>
Event Title
</FormLabel>
<FormLabel htmlFor='title'>Event Title</FormLabel>
<Input
size='sm'
maxLength={35}
name='title'
placeholder='Event Title'
autoComplete='off'
value={formData.title}
onChange={(event) =>
setFormData({ ...formData, title: event.target.value })
}
onChange={(event) => {
setChanged(true);
setFormData({ ...formData, title: event.target.value });
}}
isDisabled={submitting}
/>
</FormControl>
<FormControl id='url'>
<FormLabel
style={{ fontWeight: 400, paddingTop: '1em' }}
htmlFor='url'
>
<FormLabel htmlFor='url'>
Event URL
<span className={style.notes}>
(shown as a QR code in some views)
</span>
</FormLabel>
<Input
size='sm'
@@ -78,55 +88,73 @@ export default function SettingsModal() {
placeholder='www.onsite.no'
autoComplete='off'
value={formData.url}
onChange={(event) =>
setFormData({ ...formData, url: event.target.value })
}
onChange={(event) => {
setChanged(true);
setFormData({ ...formData, url: event.target.value });
}}
isDisabled={submitting}
/>
</FormControl>
<FormControl id='pubInfo'>
<FormLabel
style={{ fontWeight: 400, paddingTop: '1em' }}
htmlFor='pubInfo'
>
Public Info
</FormLabel>
<FormLabel htmlFor='pubInfo'>Public Info</FormLabel>
<Textarea
size='sm'
name='pubInfo'
placeholder='Information to be shown on public screens'
autoComplete='off'
value={formData.publicInfo}
onChange={(event) =>
onChange={(event) => {
setChanged(true);
setFormData({
...formData,
publicInfo: event.target.value,
})
}
});
}}
isDisabled={submitting}
/>
</FormControl>
<FormControl id='backstageInfo'>
<FormLabel
style={{ fontWeight: 400, paddingTop: '1em' }}
htmlFor='backstageInfo'
>
Backstage Info
</FormLabel>
<FormLabel htmlFor='backstageInfo'>Backstage Info</FormLabel>
<Textarea
size='sm'
name='backstageInfo'
placeholder='Information to be shown on backstage screens'
autoComplete='off'
value={formData.backstageInfo}
onChange={(event) =>
onChange={(event) => {
setChanged(true);
setFormData({
...formData,
backstageInfo: event.target.value,
})
}
});
}}
isDisabled={submitting}
/>
</FormControl>
<FormControl id='endMessage'>
<FormLabel htmlFor='endMessage'>
End Message
<span className={style.notes}>
Shown on presenter view when time is finished
</span>
</FormLabel>
<Input
size='sm'
maxLength={30}
name='endMessage'
placeholder='Empty message shows elapsed time'
autoComplete='off'
value={formData.endMessage}
onChange={(event) => {
setChanged(true);
setFormData({
...formData,
endMessage: event.target.value,
});
}}
isDisabled={submitting}
/>
</FormControl>
@@ -134,9 +162,9 @@ export default function SettingsModal() {
)}
<Button
colorScheme='blue'
mr={3}
isLoading={submitting}
type='submit'
isLoading={submitting}
disabled={!changed}
>
Save
</Button>
+7 -11
View File
@@ -7,7 +7,9 @@ import {
ModalOverlay,
} from '@chakra-ui/modal';
import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/tabs';
import SettingsModal from './SettingsModal';
import EventSettingsModal from './EventSettingsModal';
import AppSettingsModal from './AppSettingsModal';
import AliasesModal from './AliasesModal';
export default function ModalManager(props) {
const { isOpen, onClose } = props;
@@ -31,19 +33,13 @@ export default function ModalManager(props) {
</TabList>
<TabPanels>
<TabPanel>
<SettingsModal onClose={onClose} />
<EventSettingsModal />
</TabPanel>
<TabPanel>
<p>Application settings will be here!</p>
<p>Set port</p>
<p>Set end message</p>
<AppSettingsModal />
</TabPanel>
<TabPanel>
<p>You will be able to define URL aliases here!</p>
<p>List existing aliases</p>
<p>List existing aliases</p>
<p>List existing aliases</p>
<p>Add new alias input + button</p>
<AliasesModal />
</TabPanel>
</TabPanels>
</Tabs>
@@ -54,5 +50,5 @@ export default function ModalManager(props) {
ModalManager.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
};
@@ -0,0 +1,60 @@
.modalBody {
font-weight: 400;
}
.modalBody > * {
margin-top: 0.5em;
}
.modalBody > button {
margin-top: 1em;
}
.notes {
font-weight: 400;
color: #2b6cb0;
}
p.notes {
text-align: center;
border-color: #2b6cb055;
border-width: 0 2px;
font-size: 0.9em;
margin-bottom: 1em;
}
span.notes {
font-size: 0.8em;
padding-left: 0.4em;
}
.modalInline {
display: flex;
gap: 2em;
}
.highNotes {
background-color: #2b6cb022;
margin: 1em 0;
padding: 0.3em;
}
.flexNote {
font-size: 0.9em;
padding-bottom: 0.3em;
}
a::after {
content: ' \2197';
color: #ff7597;
}
a:hover {
color: #ff7597;
}
.separator {
border: 1px solid #2b6cb055;
width: 50%;
margin: 0.5em auto;
}
@@ -64,6 +64,7 @@ const withSocket = (Component) => {
url: '',
publicInfo: '',
backstageInfo: '',
endMessage: '',
});
const [playback, setPlayback] = useState(null);
@@ -142,7 +142,7 @@ export default function StageManager(props) {
<div className={style.qr}>
{general.url != null && general.url !== '' && (
<QRCode
value='www.carlosvalente.com'
value={general.url}
size={window.innerWidth / 12}
level='L'
/>
@@ -11,7 +11,7 @@
font-weight: 300;
display: grid;
grid-template-columns: 1fr 1fr 1fr 3vw 2fr;
grid-template-rows: 2fr 1fr 1fr 0 1fr;
grid-template-rows: 15vh 1fr 1fr 0 13vh 13vh;
grid-template-areas:
' titl titl titl . schd'
' now now now . schd'
@@ -55,6 +55,8 @@
.todayContainer {
background-color: rgba(255, 255, 255, 0.05);
padding: 1vh 2vw;
overflow: hidden;
}
.clockContainer,
@@ -163,7 +165,7 @@
.countdownContainer {
grid-area: clck;
border-radius: 1vw 1vw 0 0;
border-radius: 1vw 1vw 0 0;
}
.clock {
+1 -1
View File
@@ -109,7 +109,7 @@ export default function Public(props) {
<div className={style.qr}>
{general.url != null && general.url !== '' && (
<QRCode
value='www.carlosvalente.com'
value={general.url}
size={window.innerWidth / 12}
level='L'
/>
@@ -11,7 +11,7 @@
font-weight: 300;
display: grid;
grid-template-columns: 1fr 1fr 1fr 3vw 2fr;
grid-template-rows: 2fr 1fr 1fr 0 1fr;
grid-template-rows: 15vh 1fr 1fr 0 13vh 13vh;
grid-template-areas:
' titl titl titl . schd'
' now now now . schd'
@@ -55,6 +55,8 @@
.todayContainer {
background-color: rgba(255, 255, 255, 0.05);
padding: 1vh 2vw;
overflow: hidden;
}
.clockContainer,
@@ -7,7 +7,7 @@ import TitleCard from 'common/components/views/TitleCard';
import style from './PresenterView.module.css';
export default function PresenterView(props) {
const { pres, title, time } = props;
const { general, pres, title, time } = props;
// Set window title
useEffect(() => {
@@ -18,6 +18,14 @@ export default function PresenterView(props) {
const isPlaying = time.playstate !== 'pause';
const normalisedTime = Math.max(time.running, 0);
// show timer if end message is empty
const endMessage =
general.endMessage == null || general.endMessage === '' ? (
<Countdown time={time.running} hideZeroHours negative />
) : (
general.endMessage
);
// motion
const titleVariants = {
hidden: {
@@ -57,7 +65,7 @@ export default function PresenterView(props) {
<div className={style.timerContainer}>
{time.finished ? (
<div className={style.finished}>TIME UP</div>
<div className={style.finished}>{endMessage}</div>
) : (
<div className={isPlaying ? style.countdown : style.countdownPaused}>
<Countdown time={normalisedTime} hideZeroHours />
@@ -66,7 +66,8 @@
}
.finished {
font-size: 18vw;
text-align: center;
font-size: 12vw;
line-height: 18vw;
font-weight: 600;
color: #ff6969;
@@ -87,7 +87,7 @@ export default function Pip(props) {
<div className={style.qr}>
{general.url != null && general.url !== '' && (
<QRCode
value='www.carlosvalente.com'
value={general.url}
size={window.innerWidth / 12}
level='L'
/>
+521 -488
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+127
View File
@@ -0,0 +1,127 @@
{
"events": [
{
"title": "Welcome to Ontime",
"subtitle": "Subtitles are useful",
"presenter": "cpvalente",
"note": "Maybe a running note for the operator?",
"timeStart": 28800000,
"timeEnd": 30600000,
"isPublic": false,
"type": "event",
"revision": 7,
"id": "5946"
},
{
"title": "This is your event list",
"subtitle": "",
"presenter": "cpvalente",
"note": "",
"timeStart": 30600000,
"timeEnd": 34200000,
"isPublic": false,
"type": "event",
"revision": 4,
"id": "c2e7"
},
{
"title": "Events run in the list order",
"subtitle": "",
"presenter": "",
"note": "",
"timeStart": 34200000,
"timeEnd": 34800000,
"isPublic": false,
"type": "event",
"revision": 4,
"id": "cc0f"
},
{
"title": "Unless recalled by the OSC address",
"subtitle": "",
"presenter": "",
"note": "In green, below",
"timeStart": 34800000,
"timeEnd": 35400000,
"isPublic": false,
"type": "event",
"revision": 7,
"id": "8ee5"
},
{
"title": "Use simpler times to create a timer",
"subtitle": "",
"presenter": "",
"note": "",
"timeStart": 0,
"timeEnd": 600000,
"isPublic": false,
"type": "event",
"revision": 2,
"id": "8222"
},
{
"duration": 900000,
"type": "delay",
"revision": 1,
"id": "a386"
},
{
"title": "Add delay blocks to affect all events",
"subtitle": "",
"presenter": "",
"note": "* Until a block is found",
"timeStart": 35400000,
"timeEnd": 36600000,
"isPublic": false,
"type": "event",
"revision": 6,
"id": "6dce"
},
{
"title": "Add and remove events with [+] and [-]",
"subtitle": "",
"presenter": "",
"note": "Orange and red buttons on the right",
"timeStart": 36600000,
"timeEnd": 43200000,
"isPublic": false,
"type": "event",
"revision": 9,
"id": "2651"
},
{
"type": "block",
"id": "e6a1"
},
{
"title": "And control whether they are public",
"subtitle": "",
"presenter": "",
"note": "Blue button on the right",
"timeStart": 46800000,
"timeEnd": 57600000,
"isPublic": false,
"type": "event",
"revision": 6,
"id": "1358"
}
],
"event": {
"title": "All about Carlos demo event",
"url": "www.carlosvalente.com",
"publicInfo": "WiFi: demoproject \nPassword: ontimeproject",
"backstageInfo": "WiFi: demobackstage\nPassword: ontimeproject"
"endMessage": ""
},
"settings": {
"app": "ontime",
"version": 1,
"serverPort": 4001,
"oscInPort": 8888,
"oscOutPort": 9999,
"oscOutIP": "127.0.0.1",
"oscEnabled": true,
"lock": false
}
}
+21 -18
View File
@@ -26,12 +26,14 @@ const nodePath =
const { startServer, startOSCServer, startOSCClient } = await import(
nodePath
);
// Start express server
loaded = startServer();
// Start OSC Server (API)
startOSCServer();
// Start OSC Client (Feedback)
startOSCClient();
await startOSCClient();
// Start express server
loaded = await startServer();
// Start OSC Server (API)
await startOSCServer();
} catch (error) {
console.log(error);
loaded = error;
@@ -42,9 +44,9 @@ const nodePath =
const trayIcon = path.join(__dirname, './assets/background.png');
const appIcon = path.join(__dirname, './assets/logo.png');
function showNotification(text) {
function showNotification(title, text) {
new Notification({
title: 'ontime',
title: title,
body: text,
silent: true,
}).show();
@@ -99,7 +101,6 @@ function createWindow() {
show: false,
textAreasAreResizable: false,
enableWebSQL: false,
skipTaskbar: true,
darkTheme: true,
webPreferences: {
preload: path.join(__dirname, './electron/preload.js'),
@@ -113,6 +114,11 @@ function createWindow() {
}
app.whenReady().then(() => {
// Set app title in windows
if (process.platform === 'win32') {
app.setAppUserModelId(app.name);
}
createWindow();
// register global shortcuts
@@ -123,11 +129,6 @@ app.whenReady().then(() => {
win.focus();
});
globalShortcut.register('Alt+t', () => {
// Show dev tools
win.webContents.openDevTools({ mode: 'detach' });
});
// recreate window if no others open
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
@@ -151,7 +152,6 @@ app.whenReady().then(() => {
win.focus();
splash.destroy();
showNotification(loaded.toString());
// tray stuff
// TODO: get IP Address
@@ -163,7 +163,7 @@ app.whenReady().then(() => {
win.on('close', function (event) {
event.preventDefault();
if (!isQuitting) {
showNotification('App running in background');
showNotification('Window Closed', 'App running in background');
win.hide();
return false;
}
@@ -177,14 +177,14 @@ app.whenReady().then(() => {
// Define context menu
const trayMenuTemplate = [
{
label: 'Show App',
label: 'Show App (Alt + 1)',
click: () => {
win.show();
win.focus();
},
},
{
label: 'Close',
label: 'Shutdown',
click: () => {
win.destroy();
app.quit();
@@ -209,7 +209,7 @@ app.once('before-quit', () => {
// Get messages from react
// Test message
ipcMain.on('test-message', (event, arg) => {
showNotification('testing 1-2', arg);
showNotification('Test Message', 'test from react', arg);
});
// Terminate
@@ -240,6 +240,9 @@ ipcMain.on('set-window', (event, arg) => {
} else if (arg === 'to-tray') {
// window to tray
win.hide();
} else if (arg === 'show-dev') {
// Show dev tools
win.webContents.openDevTools({ mode: 'detach' });
}
});
+7 -3
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "0.1.0",
"version": "0.1.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -8,7 +8,6 @@
"license": "AGPL-3.0-only",
"main": "main.js",
"devDependencies": {
"concurrently": "^6.1.0",
"electron": "^13.1.2",
"electron-builder": "^22.11.3",
"eslint": "^7.26.0",
@@ -48,11 +47,16 @@
},
"nsis": {
"artifactName": "ontime-win64.exe",
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"allowElevation": false,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"deleteAppDataOnUninstall": true,
"runAfterFinish": false,
"installerIcon": "icon.ico"
"installerIcon": "icon.ico",
"installerSidebar": "ontime-install.bmp",
"uninstallerSidebar": "ontime-uninstall.bmp"
},
"files": [
"**/*",
+58 -29
View File
@@ -4,6 +4,7 @@ import { sessionId, user } from './utils/analytics.js';
user.screenview('Node service', 'ontime').send();
user.event('NODE', 'started', 'starting node service').send();
// import config
import { config } from './config/config.js';
// init database
@@ -25,7 +26,7 @@ import { Client } from 'node-osc';
import express from 'express';
import http from 'http';
import cors from 'cors';
import { dbModel } from './data/dataModel.js';
import { dbModel } from './models/dataModel.js';
import ua from 'universal-analytics';
// Read data from JSON file, this will set db.data content
@@ -95,20 +96,66 @@ app.use((err, req, res, next) => {
res.status(500).send(err.stack);
});
/*************** START SERVICES ***************/
/* Override config
* ----------------
*
* Configuration of services comes from app general config
* It can be overriden here by the settings in the db
* It can also be overriden on call
*
*/
const s = data.settings;
const oscIP = s.oscOutIP || config.osc.ipOut;
const oscOutPort = s.oscOutPort || config.osc.portOut;
const oscInPort = s.oscInPort || config.osc.port;
const serverPort = s.serverPort || config.server.port;
// Start OSC server
import { initiateOSC, shutdownOSCServer } from './controllers/OscController.js';
export const startOSCServer = async (overrideConfig = null) => {
// Setup default port
const oscSettings = {
port: overrideConfig?.port || oscInPort,
ipOut: oscIP,
portOut: oscOutPort,
};
// Start OSC Server
initiateOSC(oscSettings);
};
// Start OSC Client
// TODO: Move this to function
const oscClient = new Client('127.0.0.1', 9999);
let oscClient = null;
export const startOSCClient = async (overrideConfig = null) => {
// Setup default port
const port = overrideConfig?.port || oscOutPort;
console.log('initialise OSC Client on port: ', port);
oscClient = new Client(oscIP, oscOutPort);
};
// create HTTP server
const server = http.createServer(app);
export const startServer = (overrideConfig = null) => {
export const startServer = async (overrideConfig = null) => {
// Setup default port
const serverPort = overrideConfig?.port || config.server.port;
// const port = overrideConfig?.port || serverPort;
/* Note: Port is hardcoded
* need to find a good solution for sharing
* the dynamic info with the frontend
*/
const port = 4001;
// Start server
const returnMessage = `HTTP Server is listening on port ${serverPort}`;
server.listen(serverPort, '0.0.0.0', () => console.log(returnMessage));
const returnMessage = `HTTP Server is listening on port ${port}`;
server.listen(port, '0.0.0.0', () => console.log(returnMessage));
// init timer
global.timer = new EventTimer(server, oscClient, config);
@@ -117,38 +164,20 @@ export const startServer = (overrideConfig = null) => {
return returnMessage;
};
// Start OSC server
import { initiateOSC, shutdownOSCServer } from './controllers/OscController.js';
export const startOSCServer = (overrideConfig = null) => {
// Setup default port
const oscInPort = overrideConfig?.port || config.osc.port;
initiateOSC(config.osc);
};
export const startOSCClient = (overrideConfig = null) => {
// Setup default port
const oscOutPort = overrideConfig?.port || config.osc.portOut;
console.log('initialise OSC Client at port: ', oscOutPort);
};
export const shutdown = () => {
export const shutdown = async () => {
console.log('Node service shutdown');
user.event('NODE', 'shutdown', 'requesting node shutfown').send();
// shutdown express server
server.close();
// shutdown OSC Server
shutdownOSCServer();
// shutdown OSC Client
oscClient.close();
// shutdown timer
global.timer.shutdown();
};
// if (env == 'development') {
// startServer();
// startOSCServer();
// startOSCClient();
// }
+16 -12
View File
@@ -242,10 +242,6 @@ export class EventTimer extends Timer {
} else {
// look for event if none is loaded
if (this.current <= 0 || this.secondaryTimer <= 0) this.rollLoad();
// count to next event
// TODO: replace with proper counter
if (this.secondaryTimer != null) this.secondaryTimer -= 1000;
}
}
@@ -946,8 +942,12 @@ export class EventTimer extends Timer {
rollLoad() {
const now = this._getCurrentTime();
this._resetTimers(true);
this._resetSelection();
// maybe roll has already been loaded
if (this.secondaryTimer == null) {
this._resetTimers(true);
this._resetSelection();
}
let foundNow = null;
let nextIndex = null;
@@ -988,16 +988,20 @@ export class EventTimer extends Timer {
}
// nothing to play next, unload
if (!foundNow && !nextIndex) {
if (foundNow == null && nextIndex == null) {
this.unload();
console.log('Roll: no events found');
return;
}
if (nextIndex) {
if (nextIndex != null) {
// load titles
this._loadThisTitles(nextIndex, 'next');
const e = this._eventlist[nextIndex];
this._loadThisTitles(e, 'next');
if (!foundNow) {
if (foundNow == null) {
if (this.secondaryTimer == null)
console.log('Roll: waiting for event start');
// timer counts to nextStart
this.secondaryTimer = nextStart;
}
@@ -1066,8 +1070,8 @@ export class EventTimer extends Timer {
}
unload() {
// reset duration
this.duration = null;
// reset timer
this._resetTimers(true);
// reset selected
this._resetSelection();
+1
View File
@@ -120,6 +120,7 @@ export class Timer {
_resetTimers(total = false) {
if (total) this.duration = null;
this.current = this.duration;
this.running = null;
this.secondaryTimer = null;
this._finishAt = null;
this._finishedAt = null;
+1 -1
View File
@@ -8,7 +8,7 @@ import {
event as eventDef,
delay as delayDef,
block as blockDef,
} from '../data/eventsDefinition.js';
} from '../models/eventsDefinition.js';
function _getEventsCount() {
return Array.from(data.events).length;
+56 -7
View File
@@ -8,8 +8,8 @@ import {
event as eventDef,
delay as delayDef,
block as blockDef,
} from '../data/eventsDefinition.js';
import { dbModel } from '../data/dataModel.js';
} from '../models/eventsDefinition.js';
import { dbModel } from '../models/dataModel.js';
import { networkInterfaces } from 'os';
const __filename = fileURLToPath(import.meta.url);
@@ -32,6 +32,7 @@ async function deleteFile(file) {
async function parsev1(jsonData) {
let numEntries = 0;
if ('events' in jsonData) {
console.log('Found events definition, importing...');
let events = [];
let ids = [];
for (const e of jsonData.events) {
@@ -69,6 +70,7 @@ async function parsev1(jsonData) {
}
if ('event' in jsonData) {
console.log('Found event data, importing...');
const e = jsonData.event;
// filter known properties
const event = {
@@ -77,6 +79,7 @@ async function parsev1(jsonData) {
url: e.url,
publicInfo: e.publicInfo,
backstageInfo: e.backstageInfo,
endMessage: e.endMessage,
};
// write to db
@@ -84,10 +87,23 @@ async function parsev1(jsonData) {
db.write();
}
// Not handling settings yet
// let settings = {};
// if ('settings' in jsonData) {
// }
// Settings handled partially
if ('settings' in jsonData) {
console.log('Found settings definition, importing...');
const s = jsonData.settings;
let settings = {};
if (s.oscInPort) settings.oscInPort = s.oscInPort;
if (s.oscOutPort) settings.oscOutPort = s.oscOutPort;
if (s.oscOutIP) settings.oscOutIP = s.oscOutIP;
// write to db
db.data.settings = {
...dbModel.settings,
...settings,
};
db.write();
}
}
// Create controller for GET request to '/ontime/db'
@@ -158,19 +174,52 @@ const getNetworkInterfaces = () => {
}
}
}
return results;
};
// Create controller for POST request to '/ontime/info'
// Returns -
// TODO: Add version
export const getInfo = async (req, res) => {
const version = data.settings.version;
const serverPort = data.settings.serverPort;
const oscInPort = data.settings.oscInPort;
const oscOutPort = data.settings.oscOutPort;
const oscOutIP = data.settings.oscOutIP;
// get nif and inject localhost
const ni = getNetworkInterfaces();
ni.unshift({ name: 'localhost', address: '127.0.0.1' });
// send object with network information
res.status(200).send({
networkInterfaces: ni,
version,
serverPort,
oscInPort,
oscOutPort,
oscOutIP,
});
};
// Create controller for POST request to '/ontime/info'
// Returns ACK message
export const postInfo = async (req, res) => {
if (!req.body) {
res.status(400).send('No object found in request');
return;
}
// TODO: validate data
try {
data.settings = { ...data.settings, ...req.body };
await db.write();
res.sendStatus(200);
} catch (error) {
res.status(400).send(error);
console.log(error);
}
};
// Create controller for POST request to '/ontime/db'
// Returns -
export const dbUpload = async (req, res) => {
+8 -2
View File
@@ -7,6 +7,7 @@ export const pbGet = async (req, res) => {
// Create controller for GET request to '/playback/start'
// Starts timer object
export const pbStart = async (req, res) => {
console.log('Calling start');
global.timer.start();
res.sendStatus(200);
};
@@ -14,6 +15,7 @@ export const pbStart = async (req, res) => {
// Create controller for GET request to '/playback/pause'
// Pauses timer object
export const pbPause = async (req, res) => {
console.log('Calling pause');
global.timer.pause();
res.sendStatus(200);
};
@@ -21,6 +23,7 @@ export const pbPause = async (req, res) => {
// Create controller for GET request to '/playback/stop'
// Stops timer object
export const pbStop = async (req, res) => {
console.log('Calling stop');
global.timer.stop();
res.sendStatus(200);
};
@@ -28,6 +31,7 @@ export const pbStop = async (req, res) => {
// Create controller for GET request to '/playback/roll'
// Sets timer object to roll mode
export const pbRoll = async (req, res) => {
console.log('Calling roll');
global.timer.roll();
res.sendStatus(501);
};
@@ -35,6 +39,7 @@ export const pbRoll = async (req, res) => {
// Create controller for GET request to '/playback/previous'
// Sets timer object to roll mode
export const pbPrevious = async (req, res) => {
console.log('Calling previous');
global.timer.previous();
res.sendStatus(200);
};
@@ -42,6 +47,7 @@ export const pbPrevious = async (req, res) => {
// Create controller for GET request to '/playback/next'
// Sets timer object to roll mode
export const pbNext = async (req, res) => {
console.log('Calling next');
global.timer.next();
res.sendStatus(200);
};
@@ -49,8 +55,8 @@ export const pbNext = async (req, res) => {
// Create controller for GET request to '/playback/unload'
// Unloads any events
export const pbUnload = async (req, res) => {
console.log('Calling unload');
global.timer.unload();
console.log('debug: unload called');
res.sendStatus(200);
};
@@ -58,7 +64,7 @@ export const pbUnload = async (req, res) => {
// Create controller for GET request to '/playback/reload'
// Reloads current event
export const pbReload = async (req, res) => {
console.log('Calling reload');
global.timer.reload();
console.log('debug: reload called');
res.sendStatus(200);
};
@@ -5,12 +5,16 @@ export const dbModel = {
url: '',
publicInfo: '',
backstageInfo: '',
endMessage: '',
},
settings: {
app: 'ontime',
version: 1,
osc_enabled: false,
osc_port: 8888,
serverPort: 4001,
oscInPort: 8888,
oscOutPort: 9999,
oscOutIP: '127.0.0.1',
oscEnabled: false,
lock: false,
},
};
+4 -4
View File
@@ -2,16 +2,16 @@
"type": "module",
"dependencies": {
"body-parser": "~1.19.0",
"dotenv": "^10.0.0",
"express": "~4.17.1",
"express-session": "~1.17.1",
"lowdb": "2.1.0",
"lowdb": "3.0.0",
"multer": "^1.4.2",
"nanoid": "^3.1.22",
"node-osc": "6.0.2",
"node-osc": "6.1.10",
"passport": "~0.4.1",
"passport-local": "~1.0.0",
"socket.io": "^4.0.0",
"dotenv": "^10.0.0",
"socket.io": "^4.3.1",
"universal-analytics": "^0.4.23"
},
"devDependencies": {
+4
View File
@@ -6,6 +6,7 @@ import {
dbDownload,
dbUpload,
getInfo,
postInfo,
dbPathToUpload,
} from '../controllers/ontimeController.js';
@@ -18,5 +19,8 @@ router.post('/db', uploadJson, dbUpload);
// create route between controller and '/ontime/info' endpoint
router.get('/info', uploadJson, getInfo);
// create route between controller and '/ontime/info' endpoint
router.post('/info', postInfo);
// create route between controller and '/ontime/dbpath' endpoint
router.post('/dbpath', dbPathToUpload);
+16 -1
View File
@@ -1,8 +1,23 @@
import multer from 'multer';
import { statSync, mkdirSync } from 'fs';
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, 'uploads/');
let newDestination = 'uploads/';
let stat = null;
try {
stat = statSync(newDestination);
} catch (err) {
mkdirSync(newDestination);
}
if (stat && !stat.isDirectory()) {
throw new Error(
'Directory cannot be created because an inode of a different type exists at "' +
dest +
'"'
);
}
cb(null, newDestination);
},
filename: function (req, file, cb) {
cb(null, Date.now() + '--' + file.originalname);
+352 -410
View File
File diff suppressed because it is too large Load Diff
+5 -87
View File
@@ -14,7 +14,7 @@
dependencies:
"@babel/highlight" "^7.10.4"
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.14.5":
"@babel/code-frame@^7.12.13", "@babel/code-frame@^7.14.5":
version "7.14.5"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb"
integrity sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==
@@ -711,11 +711,6 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.3.tgz#6d327abaa4be34a74e421ed6409a0ae2f47f4c3d"
integrity sha512-e6ZowgGJmTuXa3GyaPbTGxX17tnThl2aSSizrFthQ7m9uLGZBXiGhgE55cjRZTF5kjZvYn9EOPOMljdjwbflxw==
"@types/normalize-package-data@^2.4.0":
version "2.4.0"
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==
"@types/plist@^3.0.1":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@types/plist/-/plist-3.0.2.tgz#61b3727bba0f5c462fe333542534a0c3e19ccb01"
@@ -1407,21 +1402,6 @@ concat-stream@^1.6.2:
readable-stream "^2.2.2"
typedarray "^0.0.6"
concurrently@^6.1.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-6.2.0.tgz#587e2cb8afca7234172d8ea55176088632c4c56d"
integrity sha512-v9I4Y3wFoXCSY2L73yYgwA9ESrQMpRn80jMcqMgHx720Hecz2GZAvTI6bREVST6lkddNypDKRN22qhK0X8Y00g==
dependencies:
chalk "^4.1.0"
date-fns "^2.16.1"
lodash "^4.17.21"
read-pkg "^5.2.0"
rxjs "^6.6.3"
spawn-command "^0.0.2-1"
supports-color "^8.1.0"
tree-kill "^1.2.2"
yargs "^16.2.0"
config-chain@^1.1.11:
version "1.1.13"
resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4"
@@ -1521,11 +1501,6 @@ data-urls@^2.0.0:
whatwg-mimetype "^2.3.0"
whatwg-url "^8.0.0"
date-fns@^2.16.1:
version "2.22.1"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.22.1.tgz#1e5af959831ebb1d82992bf67b765052d8f0efc4"
integrity sha512-yUFPQjrxEmIsMqlHhAhmxkuH769baF21Kk+nZwZGyrMoyLA+LugaQtC0+Tqf9CBUUULWwUJt6Q5ySI3LJDDCGg==
debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee"
@@ -3278,11 +3253,6 @@ json-parse-better-errors@^1.0.1:
resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==
json-parse-even-better-errors@^2.3.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
json-schema-traverse@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
@@ -3398,11 +3368,6 @@ levn@~0.3.0:
prelude-ls "~1.1.2"
type-check "~0.3.2"
lines-and-columns@^1.1.6:
version "1.1.6"
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
load-json-file@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b"
@@ -3443,7 +3408,7 @@ lodash.truncate@^4.4.2:
resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"
integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=
lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.21, lodash@^4.7.0:
lodash@^4.17.10, lodash@^4.17.15, lodash@^4.7.0:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -3592,7 +3557,7 @@ node-releases@^1.1.71:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.73.tgz#dd4e81ddd5277ff846b80b52bb40c49edf7a7b20"
integrity sha512-uW7fodD6pyW2FZNZnp/Z3hvWKeEW1Y8R1+1CnErE8cXFXzl5blBOoVB41CvMer6P6Q0S5FXDwcHgFd1Wj0U9zg==
normalize-package-data@^2.3.2, normalize-package-data@^2.5.0:
normalize-package-data@^2.3.2:
version "2.5.0"
resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==
@@ -3796,16 +3761,6 @@ parse-json@^4.0.0:
error-ex "^1.3.1"
json-parse-better-errors "^1.0.1"
parse-json@^5.0.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd"
integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==
dependencies:
"@babel/code-frame" "^7.0.0"
error-ex "^1.3.1"
json-parse-even-better-errors "^2.3.0"
lines-and-columns "^1.1.6"
parse5@6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
@@ -4025,16 +3980,6 @@ read-pkg@^3.0.0:
normalize-package-data "^2.3.2"
path-type "^3.0.0"
read-pkg@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc"
integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==
dependencies:
"@types/normalize-package-data" "^2.4.0"
normalize-package-data "^2.5.0"
parse-json "^5.0.0"
type-fest "^0.6.0"
readable-stream@^2.2.2:
version "2.3.7"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
@@ -4149,13 +4094,6 @@ roarr@^2.15.3:
semver-compare "^1.0.0"
sprintf-js "^1.1.2"
rxjs@^6.6.3:
version "6.6.7"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9"
integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==
dependencies:
tslib "^1.9.0"
safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
@@ -4301,11 +4239,6 @@ source-map@^0.7.3:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
spawn-command@^0.0.2-1:
version "0.0.2-1"
resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2-1.tgz#62f5e9466981c1b796dc5929937e11c9c6921bd0"
integrity sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A=
spdx-correct@^3.0.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9"
@@ -4492,7 +4425,7 @@ supports-color@^7.0.0, supports-color@^7.1.0:
dependencies:
has-flag "^4.0.0"
supports-color@^8.0.0, supports-color@^8.1.0:
supports-color@^8.0.0:
version "8.1.1"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c"
integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==
@@ -4611,11 +4544,6 @@ tr46@^2.1.0:
dependencies:
punycode "^2.1.1"
tree-kill@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc"
integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==
truncate-utf8-bytes@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz#405923909592d56f78a5818434b0b78489ca5f2b"
@@ -4633,11 +4561,6 @@ tsconfig-paths@^3.9.0:
minimist "^1.2.0"
strip-bom "^3.0.0"
tslib@^1.9.0:
version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tunnel@^0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c"
@@ -4677,11 +4600,6 @@ type-fest@^0.21.3:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"
integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==
type-fest@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b"
integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==
typedarray-to-buffer@^3.1.5:
version "3.1.5"
resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"
@@ -4952,7 +4870,7 @@ yargs-parser@^20.2.2:
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.7.tgz#61df85c113edfb5a7a4e36eb8aa60ef423cbc90a"
integrity sha512-FiNkvbeHzB/syOjIUxFDCnhSfzAL8R5vs40MgLFBorXACCOAEaWu0gRZl14vG8MR9AOJIZbmkjhusqBYZ3HTHw==
yargs@^16.0.3, yargs@^16.2.0:
yargs@^16.0.3:
version "16.2.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66"
integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==