diff --git a/apps/client/package.json b/apps/client/package.json index a062b66ed..9a9ca8d29 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -18,9 +18,10 @@ "axios": "^1.2.0", "color": "^4.2.3", "csv-stringify": "^6.2.3", + "deepmerge": "^4.3.0", "framer-motion": "^8.0.2", "jotai": "^1.10.0", - "luxon": "^3.1.0", + "luxon": "^3.3.0", "react": "^18.2.0", "react-beautiful-dnd": "^13.1.1", "react-dom": "^18.2.0", @@ -29,9 +30,10 @@ "react-qr-code": "^2.0.11", "react-router-dom": "^6.3.0", "react-table": "^7.7.0", - "socket.io-client": "^4.5.4", + "react-use-websocket": "^4.3.1", "typeface-open-sans": "^1.1.13", - "web-vitals": "^3.1.1" + "web-vitals": "^3.1.1", + "zustand": "^4.3.6" }, "scripts": { "addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", @@ -63,6 +65,7 @@ "@testing-library/react": "^13.1.1", "@testing-library/user-event": "^14.1.1", "@types/color": "^3.0.3", + "@types/luxon": "^3.2.0", "@types/prop-types": "^15.7.5", "@types/react": "^18.0.26", "@types/react-beautiful-dnd": "^13.1.3", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index f8f02b3a7..d2b0c1daa 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -6,9 +6,9 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import ErrorBoundary from './common/components/error-boundary/ErrorBoundary'; import { AppContextProvider } from './common/context/AppContext'; -import { LoggingProvider } from './common/context/LoggingContext'; import useElectronEvent from './common/hooks/useElectronEvent'; import { ontimeQueryClient } from './common/queryClient'; +import { connectSocket } from './common/utils/socket'; import theme from './theme/theme'; import AppRouter from './AppRouter'; @@ -16,20 +16,22 @@ import AppRouter from './AppRouter'; // @ts-expect-error no types from font import import('typeface-open-sans'); +connectSocket(); + function App() { const { isElectron, sendToElectron } = useElectronEvent(); - const handleKeyPress = (event:KeyboardEvent) => { - // handle held key - if (event.repeat) return; - // check if the alt key is pressed - if (event.altKey) { - if (event.code === 'KeyT') { - // ask to see debug - sendToElectron('set-window', 'show-dev'); - } + const handleKeyPress = (event: KeyboardEvent) => { + // handle held key + if (event.repeat) return; + // check if the alt key is pressed + if (event.altKey) { + if (event.code === 'KeyT') { + // ask to see debug + sendToElectron('set-window', 'show-dev'); } - }; + } + }; useEffect(() => { if (isElectron) { @@ -44,22 +46,20 @@ function App() { return ( - - - - -
- - - - - - -
-
-
-
-
+ + + +
+ + + + + + +
+
+
+
); } diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx index 846ed2fd0..d17a4da4f 100644 --- a/apps/client/src/AppRouter.tsx +++ b/apps/client/src/AppRouter.tsx @@ -2,7 +2,7 @@ import { lazy, useEffect } from 'react'; import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import useAliases from './common/hooks-query/useAliases'; -import withSocket from './features/viewers/ViewWrapper'; +import withData from './features/viewers/ViewWrapper'; const Editor = lazy(() => import('./features/editors/ProtectedEditor')); const Table = lazy(() => import('./features/table/ProtectedTable')); @@ -17,14 +17,14 @@ const Public = lazy(() => import('./features/viewers/public/Public')); const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerWrapper')); const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock')); -const STimer = withSocket(TimerView); -const SMinimalTimer = withSocket(MinimalTimerView); -const SClock = withSocket(ClockView); -const SCountdown = withSocket(Countdown); -const SBackstage = withSocket(Backstage); -const SPublic = withSocket(Public); -const SLowerThird = withSocket(Lower); -const SStudio = withSocket(StudioClock); +const STimer = withData(TimerView); +const SMinimalTimer = withData(MinimalTimerView); +const SClock = withData(ClockView); +const SCountdown = withData(Countdown); +const SBackstage = withData(Backstage); +const SPublic = withData(Public); +const SLowerThird = withData(Lower); +const SStudio = withData(StudioClock); const FeatureWrapper = lazy(() => import('./features/FeatureWrapper')); const RundownPanel = lazy(() => import('./features/rundown/RundownExport')); diff --git a/apps/client/src/common/api/apiConstants.ts b/apps/client/src/common/api/apiConstants.ts index 070e4a0f3..96928bc43 100644 --- a/apps/client/src/common/api/apiConstants.ts +++ b/apps/client/src/common/api/apiConstants.ts @@ -10,14 +10,10 @@ export const APP_INFO = ['appinfo']; export const OSC_SETTINGS = ['oscSettings']; export const APP_SETTINGS = ['appSettings']; export const VIEW_SETTINGS = ['viewSettings']; +export const RUNTIME = ['runtimeStore']; -// websocket stuff -export const FEAT_CUESHEET = 'feat-cuesheet'; -export const FEAT_INFO = 'feat-info'; -export const FEAT_MESSAGECONTROL = 'feat-messagecontrol'; -export const FEAT_PLAYBACKCONTROL = 'feat-playbackcontrol'; -export const FEAT_RUNDOWN = 'feat-rundown'; -export const TIMER = 'timer'; +// external stuff +export const githubURL = 'https://api.github.com/repos/cpvalente/ontime/releases/latest'; // external stuff export const githubURL = 'https://api.github.com/repos/cpvalente/ontime/releases/latest'; @@ -29,6 +25,8 @@ export const githubURL = 'https://api.github.com/repos/cpvalente/ontime/releases export const calculateServer = () => (import.meta.env.DEV ? `http://localhost:${STATIC_PORT}` : window.location.origin); export const serverURL = calculateServer(); +export const websocketUrl = `ws://${window.location.hostname}:${STATIC_PORT}/ws`; + export const eventURL = `${serverURL}/eventdata`; export const rundownURL = `${serverURL}/eventlist`; export const ontimeURL = `${serverURL}/ontime`; diff --git a/apps/client/src/common/components/buttons/QuitIconBtn.tsx b/apps/client/src/common/components/buttons/QuitIconBtn.tsx index 78281928a..2a398afa4 100644 --- a/apps/client/src/common/components/buttons/QuitIconBtn.tsx +++ b/apps/client/src/common/components/buttons/QuitIconBtn.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { AlertDialog, AlertDialogBody, @@ -12,8 +12,8 @@ import { } from '@chakra-ui/react'; import { FiPower } from '@react-icons/all-files/fi/FiPower'; -import { LoggingContext } from '../../context/LoggingContext'; import { Size } from '../../models/Util.type'; +import { useEmitLog } from '../../stores/logger'; interface QuitIconBtnProps { clickHandler: () => void; @@ -39,7 +39,7 @@ const quitBtnStyle = { export default function QuitIconBtn(props: QuitIconBtnProps) { const { clickHandler, size = 'lg', ...rest } = props; const [isOpen, setIsOpen] = useState(false); - const { emitInfo } = useContext(LoggingContext); + const { emitInfo } = useEmitLog(); const onClose = () => setIsOpen(false); const cancelRef = useRef(null); diff --git a/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx b/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx index 9240ea7a6..dac1aa50a 100644 --- a/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx +++ b/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx @@ -2,12 +2,9 @@ import React from 'react'; import * as Sentry from '@sentry/react'; -import { LoggingContext } from '../../context/LoggingContext'; - import style from './ErrorBoundary.module.scss'; class ErrorBoundary extends React.Component { - static contextType = LoggingContext; reportContent = ''; constructor(props) { diff --git a/apps/client/src/common/components/input/time-input/TimeInput.tsx b/apps/client/src/common/components/input/time-input/TimeInput.tsx index fc13e3871..dd7914715 100644 --- a/apps/client/src/common/components/input/time-input/TimeInput.tsx +++ b/apps/client/src/common/components/input/time-input/TimeInput.tsx @@ -1,11 +1,11 @@ -import { KeyboardEvent, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; import { Button, Input, InputGroup, InputLeftElement, Tooltip } from '@chakra-ui/react'; +import { millisToString } from 'ontime-utils'; import { EventEditorSubmitActions } from '../../../../features/event-editor/EventEditor'; import { tooltipDelayFast } from '../../../../ontimeConfig'; -import { LoggingContext } from '../../../context/LoggingContext'; +import { useEmitLog } from '../../../stores/logger'; import { forgivingStringToMillis } from '../../../utils/dateConfig'; -import { stringFromMillis } from '../../../utils/time'; import { TimeEntryField } from '../../../utils/timesManager'; import style from './TimeInput.module.scss'; @@ -24,7 +24,7 @@ export default function TimeInput(props: TimeInputProps) { const { name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0, } = props; - const { emitError } = useContext(LoggingContext); + const { emitError } = useEmitLog(); const inputRef = useRef(null); const [value, setValue] = useState(''); @@ -34,7 +34,7 @@ export default function TimeInput(props: TimeInputProps) { const resetValue = useCallback(() => { // Todo: check if change is necessary try { - setValue(stringFromMillis(time + delay)); + setValue(millisToString(time + delay)); } catch (error) { emitError(`Unable to parse date: ${error}`); } @@ -97,7 +97,7 @@ export default function TimeInput(props: TimeInputProps) { const success = handleSubmit(newValue); if (success) { const ms = forgivingStringToMillis(newValue); - setValue(stringFromMillis(ms + delay)); + setValue(millisToString(ms + delay)); } else { resetValue(); } diff --git a/apps/client/src/common/components/upload-modal/UploadModal.tsx b/apps/client/src/common/components/upload-modal/UploadModal.tsx index 1df81a5aa..66a1109a2 100644 --- a/apps/client/src/common/components/upload-modal/UploadModal.tsx +++ b/apps/client/src/common/components/upload-modal/UploadModal.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, useCallback, useContext, useRef, useState } from 'react'; +import { ChangeEvent, useCallback, useRef, useState } from 'react'; import { Button, Checkbox, @@ -21,7 +21,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { RUNDOWN_TABLE } from '../../api/apiConstants'; import { uploadData } from '../../api/ontimeApi'; -import { LoggingContext } from '../../context/LoggingContext'; +import { useEmitLog } from '../../stores/logger'; import TooltipActionBtn from '../buttons/TooltipActionBtn'; import { validateFile } from './utils'; @@ -35,7 +35,7 @@ interface UploadModalProps { export default function UploadModal({ onClose, isOpen }: UploadModalProps) { const queryClient = useQueryClient(); - const { emitError } = useContext(LoggingContext); + const { emitError } = useEmitLog(); const [errors, setErrors] = useState([]); const [file, setFile] = useState(null); const [progress, setProgress] = useState(0); diff --git a/apps/client/src/common/context/LoggingContext.tsx b/apps/client/src/common/context/LoggingContext.tsx deleted file mode 100644 index b646e0fc0..000000000 --- a/apps/client/src/common/context/LoggingContext.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { createContext, ReactNode, useCallback, useEffect, useState } from 'react'; -import { generateId } from 'ontime-utils'; - -import socket from '../utils/socket'; -import { nowInMillis, stringFromMillis } from '../utils/time'; - -export enum LOG_LEVEL { - INFO = 'INFO', - WARN = 'WARN', - ERROR = 'ERROR', -} - -export type Log = { - id: string; - origin: string; - time: string; - level: LOG_LEVEL; - text: string; -}; - -interface LoggingProviderState { - logData: Log[]; - emitInfo: (text: string) => void; - emitWarning: (text: string) => void; - emitError: (text: string) => void; - clearLog: () => void; -} - -type LoggingProviderProps = { - children: ReactNode; -}; - -const notInitialised = () => { - throw new Error('Not initialised'); -}; - -export const LoggingContext = createContext({ - logData: [], - emitInfo: notInitialised, - emitWarning: notInitialised, - emitError: notInitialised, - clearLog: notInitialised, -}); - -export const LoggingProvider = ({ children }: LoggingProviderProps) => { - const MAX_MESSAGES = 100; - const [logData, setLogData] = useState([]); - const origin = 'USER'; - - // todo: use react-query store - // todo: useSubscription or feature - // handle incoming messages - useEffect(() => { - socket.emit('get-logger'); - - socket.on('logger', (data: Log) => { - setLogData((currentLog) => [data, ...currentLog]); - }); - - // Clear listener - return () => { - socket.off('logger'); - }; - }, []); - - /** - * Utility function sends message over socket - * @param text - * @param level - * @private - */ - const _send = useCallback( - (text: string, level: LOG_LEVEL) => { - if (socket != null) { - const newLogMessage: Log = { - id: generateId(), - origin, - time: stringFromMillis(nowInMillis()), - level, - text, - }; - setLogData((currentLog) => [newLogMessage, ...currentLog]); - socket.emit('logger', newLogMessage); - } - if (logData.length > MAX_MESSAGES) { - setLogData((currentLog) => currentLog.slice(1)); - } - }, - [logData.length, setLogData], - ); - - /** - * Sends a message with level INFO - * @param text - */ - const emitInfo = useCallback( - (text: string) => { - _send(text, LOG_LEVEL.INFO); - }, - [_send], - ); - - /** - * Sends a message with level WARN - * @param text - */ - const emitWarning = useCallback( - (text: string) => { - _send(text, LOG_LEVEL.WARN); - }, - [_send], - ); - - /** - * Sends a message with level ERROR - * @param text - */ - const emitError = useCallback( - (text: string) => { - _send(text, LOG_LEVEL.ERROR); - }, - [_send], - ); - - /** - * Clears running log - */ - const clearLog = useCallback(() => { - setLogData([]); - }, [setLogData]); - - return ( - - {children} - - ); -}; diff --git a/apps/client/src/common/hooks/useEventAction.ts b/apps/client/src/common/hooks/useEventAction.ts index f89dcbfb6..9a447a154 100644 --- a/apps/client/src/common/hooks/useEventAction.ts +++ b/apps/client/src/common/hooks/useEventAction.ts @@ -1,4 +1,4 @@ -import { useCallback, useContext } from 'react'; +import { useCallback } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import axios, { AxiosError } from 'axios'; import { useAtomValue } from 'jotai'; @@ -15,14 +15,14 @@ import { requestReorderEvent, } from '../api/eventsApi'; import { defaultPublicAtom, startTimeIsLastEndAtom } from '../atoms/LocalEventSettings'; -import { LoggingContext } from '../context/LoggingContext'; +import { useEmitLog } from '../stores/logger'; /** * @description Set of utilities for events */ export const useEventAction = () => { const queryClient = useQueryClient(); - const { emitError } = useContext(LoggingContext); + const { emitError } = useEmitLog(); const defaultPublic = useAtomValue(defaultPublicAtom); const startTimeIsLastEnd = useAtomValue(startTimeIsLastEndAtom); @@ -39,23 +39,24 @@ export const useEventAction = () => { networkMode: 'always', }); - type AddOptions = { - defaultPublic?: boolean; - startTimeIsLastEnd?: boolean; - lastEventId?: string; + type BaseOptions = { after?: string; }; + type EventOptions = BaseOptions & { + defaultPublic?: boolean; + lastEventId?: string; + startTimeIsLastEnd?: boolean; + }; + /** * Adds an event to rundown */ const addEvent = useCallback( - async (event: Partial, options?: AddOptions) => { + async (event: Partial, options?: EventOptions) => { const newEvent: Partial = { ...event }; - // ************* CHECK OPTIONS - // there is an option to pass an index of an array to use as start time - // only events have options + // ************* CHECK OPTIONS specific to events if (newEvent.type === SupportedEvent.Event) { const applicationOptions = { defaultPublic: options?.defaultPublic ?? defaultPublic, @@ -81,13 +82,15 @@ export const useEventAction = () => { if (applicationOptions.defaultPublic) { newEvent.isPublic = true; } + } - if (applicationOptions?.after) { - newEvent.after = applicationOptions.after; - } + // handle adding options that concern all event type + if (options?.after) { + newEvent.after = options.after; } try { + // @ts-expect-error -- we know that the object is well formed now await _addEventMutation.mutateAsync(newEvent); } catch (error) { if (!axios.isAxiosError(error)) { diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index 094cb2d84..51c31c032 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -1,144 +1,99 @@ -import { useQuery } from '@tanstack/react-query'; -import { Playback } from 'ontime-types'; +import { RuntimeStore } from 'ontime-types'; -import { - FEAT_CUESHEET, - FEAT_INFO, - FEAT_MESSAGECONTROL, - FEAT_PLAYBACKCONTROL, - FEAT_RUNDOWN, - TIMER, -} from '../api/apiConstants'; -import { ontimeQueryClient as queryClient } from '../queryClient'; -import socket, { subscribeOnce } from '../utils/socket'; +import { deepCompare, useRuntimeStore } from '../stores/runtime'; +import { socketSendJson } from '../utils/socket'; -function createSocketHook(key: string, defaultValue: T | null = null) { - subscribeOnce(key, (data) => queryClient.setQueryData([key], data)); - - // retrieves data from the cache or null if non-existent - // we need the null because useQuery can't receive undefined - const fetcher = () => (queryClient.getQueryData([key]) ?? defaultValue) as T | null; - - return () => useQuery({ queryKey: [key], queryFn: fetcher, placeholderData: defaultValue }); -} - -interface IRundown { - selectedEventId: string | null; - nextEventId: string | null; - playback: Playback | null; -} - -const emptyRundown: IRundown = { - selectedEventId: null, - nextEventId: null, - playback: null, -}; - -export const useRundownEditor = createSocketHook(FEAT_RUNDOWN, emptyRundown); - -const emptyMessageControl = { - messages: { - presenter: { - text: '', - visible: false, - }, - public: { - text: '', - visible: false, - }, - lower: { - text: '', - visible: false, - }, - }, - onAir: false, -}; - -export const useMessageControl = createSocketHook(FEAT_MESSAGECONTROL, emptyMessageControl); -export const setMessage = { - presenterText: (payload: string) => socket.emit('set-timer-message-text', payload), - presenterVisible: (payload: boolean) => socket.emit('set-timer-message-visible', payload), - publicText: (payload: string) => socket.emit('set-public-message-text', payload), - publicVisible: (payload: boolean) => socket.emit('set-public-message-visible', payload), - lowerText: (payload: string) => socket.emit('set-lower-message-text', payload), - lowerVisible: (payload: boolean) => socket.emit('set-lower-message-visible', payload), - onAir: (payload: boolean) => socket.emit('set-onAir', payload), -}; - -export const emptyPlaybackControl = { - playback: 'stop', - selectedEventId: null, - numEvents: 0, -}; -export const usePlaybackControl = createSocketHook(FEAT_PLAYBACKCONTROL, emptyPlaybackControl); -export const resetPlayback = () => { - const cacheData = queryClient.getQueryData([FEAT_PLAYBACKCONTROL]) as Record; - queryClient.setQueryData([FEAT_PLAYBACKCONTROL], { - ...cacheData, - playback: 'stop', - selectedEventId: null, +export const useRundownEditor = () => { + const featureSelector = (state: RuntimeStore) => ({ + playback: state.playback, + selectedEventId: state.loaded.selectedEventId, + nextEventId: state.loaded.nextEventId, }); + + return useRuntimeStore(featureSelector, deepCompare); }; + +export const useMessageControl = () => { + const featureSelector = (state: RuntimeStore) => ({ + timerMessage: state.timerMessage, + publicMessage: state.publicMessage, + lowerMessage: state.lowerMessage, + onAir: state.onAir, + }); + + return useRuntimeStore(featureSelector, deepCompare); +}; + +export const setMessage = { + presenterText: (payload: string) => socketSendJson('set-timer-message-text', payload), + presenterVisible: (payload: boolean) => socketSendJson('set-timer-message-visible', payload), + publicText: (payload: string) => socketSendJson('set-public-message-text', payload), + publicVisible: (payload: boolean) => socketSendJson('set-public-message-visible', payload), + lowerText: (payload: string) => socketSendJson('set-lower-message-text', payload), + lowerVisible: (payload: boolean) => socketSendJson('set-lower-message-visible', payload), + onAir: (payload: boolean) => socketSendJson('set-onAir', payload), +}; + +export const usePlaybackControl = () => { + const featureSelector = (state: RuntimeStore) => ({ + playback: state.playback, + numEvents: state.loaded.numEvents, + }); + + return useRuntimeStore(featureSelector, deepCompare); +}; + export const setPlayback = { - start: () => socket.emit('set-start'), - pause: () => socket.emit('set-pause'), - roll: () => socket.emit('set-roll'), + start: () => socketSendJson('start'), + pause: () => socketSendJson('pause'), + roll: () => socketSendJson('roll'), previous: () => { - socket.emit('set-previous'); + socketSendJson('previous'); }, next: () => { - socket.emit('set-next'); + socketSendJson('next'); }, stop: () => { - socket.emit('set-stop'); + socketSendJson('stop'); }, reload: () => { - socket.emit('set-reload'); + socketSendJson('reload'); }, delay: (amount: number) => { - socket.emit('set-delay', amount); + socketSendJson('delay', amount); }, }; -export const emptyInfo = { - titles: { - titleNow: '', - subtitleNow: '', - presenterNow: '', - noteNow: '', - titleNext: '', - subtitleNext: '', - presenterNext: '', - noteNext: '', - }, - playback: 'stop', - selectedEventId: null, - selectedEventIndex: null, - numEvents: 0, +export const useInfoPanel = () => { + const featureSelector = (state: RuntimeStore) => ({ + titles: state.titles, + playback: state.playback, + selectedEventIndex: state.loaded.selectedEventIndex, + numEvents: state.loaded.numEvents, + }); + + return useRuntimeStore(featureSelector, deepCompare); }; -export const useInfoPanel = createSocketHook(FEAT_INFO, emptyInfo); +export const useCuesheet = () => { + const featureSelector = (state: RuntimeStore) => ({ + selectedEventIndex: state.loaded.selectedEventId, + titleNow: state.titles.titleNow, + }); -export const emptyCuesheet = { - selectedEventId: null, - titleNow: '', + return useRuntimeStore(featureSelector, deepCompare); }; -export const useCuesheet = createSocketHook(FEAT_CUESHEET, emptyCuesheet); - export const setEventPlayback = { - loadEvent: (eventId: string) => socket.emit('set-loadid', eventId), - startEvent: (eventId: string) => socket.emit('set-startid', eventId), - pause: () => socket.emit('set-pause'), + loadEvent: (eventId: string) => socketSendJson('loadid', eventId), + startEvent: (eventId: string) => socketSendJson('startid', eventId), + pause: () => socketSendJson('pause'), }; -const emptyTimer = { - clock: 0, - current: 0, - secondaryTimer: null, - duration: null, - startedAt: null, - expectedFinish: null, -}; +export const useTimer = () => { + const featureSelector = (state: RuntimeStore) => ({ + timer: state.timer, + }); -export const useTimer = createSocketHook(TIMER, emptyTimer); + return useRuntimeStore(featureSelector, deepCompare); +}; diff --git a/apps/client/src/common/hooks/useSubscription.tsx b/apps/client/src/common/hooks/useSubscription.tsx deleted file mode 100644 index cda26fa18..000000000 --- a/apps/client/src/common/hooks/useSubscription.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useEffect, useState } from 'react'; - -import socket from '../utils/socket'; - -export default function useSubscription(topic: string, initialState: T, requestString?: string) { - const [state, setState] = useState(initialState); - - useEffect(() => { - if (requestString) { - socket.emit(requestString); - } else { - socket.emit(`get-${topic}`); - } - socket.on(topic, setState); - - return () => { - socket.off(topic); - }; - }, [requestString, topic]); - - return [state, setState] as const; -}; diff --git a/apps/client/src/common/stores/createStore.ts b/apps/client/src/common/stores/createStore.ts new file mode 100644 index 000000000..10df84379 --- /dev/null +++ b/apps/client/src/common/stores/createStore.ts @@ -0,0 +1,16 @@ +export default function createStore(initialState: T) { + let currentState = initialState; + const listeners = new Set<(state: T) => void>(); + + return { + get: () => currentState, + set: (newState: T) => { + currentState = newState; + listeners.forEach((listener) => listener(currentState)); + }, + subscribe: (listener: (state: T) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} diff --git a/apps/client/src/common/stores/logger.ts b/apps/client/src/common/stores/logger.ts new file mode 100644 index 000000000..dd57139c1 --- /dev/null +++ b/apps/client/src/common/stores/logger.ts @@ -0,0 +1,85 @@ +import { useCallback } from 'react'; +import { Log, LogLevel } from 'ontime-types'; +import { generateId, millisToString } from 'ontime-utils'; +import { useStore } from 'zustand'; +import { createStore } from 'zustand/vanilla'; + +import { socketSendJson } from '../utils/socket'; +import { nowInMillis } from '../utils/time'; + +type LogStore = { + logs: Log[]; +}; + +export const logger = createStore(() => ({ + logs: [], +})); + +export const useLogData = () => useStore(logger); + +export const addLog = (log: Log) => + logger.setState((state) => ({ + logs: [...state.logs, log], + })); + +export const clearLogs = () => logger.setState({ logs: [] }); + +export function useEmitLog() { + /** + * Utility function sends message over socket + * @param text + * @param level + * @private + */ + const _emit = useCallback((text: string, level: LogLevel) => { + const log = { + id: generateId(), + origin: 'CLIENT', + time: millisToString(nowInMillis()), + level, + text, + }; + + addLog(log); + socketSendJson('ontime-log', log); + }, []); + + /** + * Sends a message with level INFO + * @param text + */ + const emitInfo = useCallback( + (text: string) => { + _emit(text, LogLevel.Info); + }, + [_emit], + ); + + /** + * Sends a message with level WARN + * @param text + */ + const emitWarning = useCallback( + (text: string) => { + _emit(text, LogLevel.Warn); + }, + [_emit], + ); + + /** + * Sends a message with level ERROR + * @param text + */ + const emitError = useCallback( + (text: string) => { + _emit(text, LogLevel.Error); + }, + [_emit], + ); + + return { + emitInfo, + emitWarning, + emitError, + }; +} diff --git a/apps/client/src/common/stores/runtime.ts b/apps/client/src/common/stores/runtime.ts new file mode 100644 index 000000000..bf2f0767b --- /dev/null +++ b/apps/client/src/common/stores/runtime.ts @@ -0,0 +1,73 @@ +import isEqual from 'react-fast-compare'; +import { Playback, RuntimeStore } from 'ontime-types'; +import { useStore } from 'zustand'; +import { createStore } from 'zustand/vanilla'; + +export const runtimeStorePlaceholder = { + timer: { + clock: 0, + current: null, + elapsed: null, + expectedFinish: null, + addedTime: 0, + startedAt: null, + finishedAt: null, + secondaryTimer: null, + selectedEventId: null, + duration: null, + timerType: null, + }, + playback: Playback.Stop, + timerMessage: { + text: '', + visible: false, + }, + publicMessage: { + text: '', + visible: false, + }, + lowerMessage: { + text: '', + visible: false, + }, + onAir: false, + loaded: { + numEvents: 0, + selectedEventIndex: null, + selectedEventId: null, + selectedPublicEventId: null, + nextEventId: null, + nextPublicEventId: null, + }, + titles: { + titleNow: null, + subtitleNow: null, + presenterNow: null, + noteNow: null, + titleNext: null, + subtitleNext: null, + presenterNext: null, + noteNext: null, + }, + titlesPublic: { + titleNow: null, + subtitleNow: null, + presenterNow: null, + noteNow: null, + titleNext: null, + subtitleNext: null, + presenterNext: null, + noteNext: null, + }, +}; + +export const runtime = createStore(() => ({ + ...runtimeStorePlaceholder, +})); + +export const deepCompare = (a: T, b: T) => isEqual(a, b); + +export const useRuntimeStore = ( + selector: (state: RuntimeStore) => T, + equalityFn?: (a: unknown, b: unknown) => boolean, +) => useStore(runtime, selector, equalityFn); diff --git a/apps/client/src/common/utils/__tests__/dateConfig.test.js b/apps/client/src/common/utils/__tests__/dateConfig.test.js index 61b669bb2..c7058bd41 100644 --- a/apps/client/src/common/utils/__tests__/dateConfig.test.js +++ b/apps/client/src/common/utils/__tests__/dateConfig.test.js @@ -6,7 +6,6 @@ import { millisToSeconds, timeStringToMillis, } from '../dateConfig'; -import { stringFromMillis } from '../time'; describe('test string from formatDisplay function', () => { it('test with null values', () => { @@ -58,7 +57,7 @@ describe('test string from formatDisplay function', () => { describe('test formatDisplay handles partial secs', () => { it('test with 1795829', () => { const t = { val: 1795829, result: '00:29:55' }; - expect(stringFromMillis(t.val)).toBe(t.result); + expect(formatDisplay(t.val)).toBe(t.result); }); }); diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index 632b3ec7b..437f3660f 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -1,17 +1,138 @@ -import { serverURL } from '../api/apiConstants'; -import { io } from 'socket.io-client'; +import { Log } from 'ontime-types'; -const socket = io(serverURL, { transports: ['websocket'] }); -const subscriptions = new Set(); +import { RUNTIME, websocketUrl } from '../api/apiConstants'; +import { ontimeQueryClient } from '../queryClient'; +import { addLog } from '../stores/logger'; +import { runtime } from '../stores/runtime'; -export function subscribeOnce(key: string, callback: (data: T) => void, requestString?: string) { - if (subscriptions.has(key)) { - return; +export let websocket: WebSocket | null = null; +let reconnectTimeout: NodeJS.Timeout | null = null; +const reconnectInterval = 1000; +let shouldReconnect = true; + +export const connectSocket = () => { + websocket = new WebSocket(websocketUrl); + + websocket.onopen = () => { + clearTimeout(reconnectTimeout as NodeJS.Timeout); + }; + + websocket.onclose = () => { + console.warn('WebSocket disconnected'); + if (shouldReconnect) { + reconnectTimeout = setTimeout(() => { + console.warn('WebSocket: attempting reconnect'); + if (websocket && websocket.readyState === WebSocket.CLOSED) { + connectSocket(); + } + }, reconnectInterval); + } + }; + + websocket.onerror = (error) => { + console.error('WebSocket error:', error); + }; + + websocket.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + const { type, payload } = data; + + if (!type) { + return; + } + + // TODO: implement partial store updates + switch (type) { + case 'ontime-log': { + addLog(payload as Log); + break; + } + case 'ontime': { + runtime.setState(payload); + if (import.meta.env.DEV) { + ontimeQueryClient.setQueryData(RUNTIME, data.payload); + } + break; + } + case 'ontime-playback': { + const state = runtime.getState(); + state.playback = payload; + runtime.setState(state); + break; + } + case 'ontime-timer': { + const state = runtime.getState(); + state.timer = payload; + runtime.setState(state); + break; + } + case 'ontime-loaded': { + const state = runtime.getState(); + state.loaded = payload; + runtime.setState(state); + break; + } + case 'ontime-titles': { + const state = runtime.getState(); + state.titles = payload; + runtime.setState(state); + break; + } + case 'ontime-titlesPublic': { + const state = runtime.getState(); + state.titlesPublic = payload; + runtime.setState(state); + break; + } + case 'ontime-timerMessage': { + const state = runtime.getState(); + state.timerMessage = payload; + runtime.setState(state); + break; + } + case 'ontime-publicMessage': { + const state = runtime.getState(); + state.publicMessage = payload; + runtime.setState(state); + break; + } + case 'ontime-lowerMessage': { + const state = runtime.getState(); + state.lowerMessage = payload; + runtime.setState(state); + break; + } + case 'ontime-onAir': { + const state = runtime.getState(); + state.onAir = payload; + runtime.setState(state); + break; + } + } + } catch (_) { + // ignore unhandled + } + }; +}; + +export const disconnectSocket = () => { + shouldReconnect = false; + websocket?.close(); +}; + +export const socketSend = (message: any) => { + if (websocket && websocket.readyState === WebSocket.OPEN) { + websocket.send(message); } - subscriptions.add(key); +}; - requestString ? socket.emit(requestString) : socket.emit(`get-${key}`); - socket.on(key, callback); -} - -export default socket; +export const socketSendJson = (type: string, payload?: any) => { + socketSend( + JSON.stringify({ + type, + payload, + }), + ); +}; diff --git a/apps/client/src/common/utils/time.js b/apps/client/src/common/utils/time.js deleted file mode 100644 index 76df9c98e..000000000 --- a/apps/client/src/common/utils/time.js +++ /dev/null @@ -1,86 +0,0 @@ -import { DateTime } from 'luxon'; - -import { APP_SETTINGS } from '../api/apiConstants'; -import { ontimeQueryClient } from '../queryClient'; - -import { mth, mtm, mts } from './timeConstants'; - - -/** - * Returns current time in milliseconds - * @returns {number} - */ -export const nowInMillis = () => { - const now = new Date(); - - // extract milliseconds since midnight - let elapsed = now.getHours() * 3600000; - elapsed += now.getMinutes() * 60000; - elapsed += now.getSeconds() * 1000; - elapsed += now.getMilliseconds(); - - return elapsed; -}; - -/** - * @description Converts milliseconds to string representing time - * @param {number | null} ms - time in milliseconds - * @param {boolean} showSeconds - weather to show the seconds - * @param {string} delim - character between HH MM SS - * @param {string} ifNull - what to return if value is null - * @returns {string} String representing time 00:12:02 - */ -export const stringFromMillis = (ms, showSeconds = true, delim = ':', ifNull = '...') => { - if (ms == null || isNaN(ms)) return ifNull; - const isNegative = ms < 0 ? '-' : ''; - const millis = Math.abs(ms); - - /** - * @description ensures value is double digit - * @param value - * @return {string|*} - */ - const showWith0 = (value) => (value < 10 ? `0${value}` : value); - const hours = showWith0(Math.floor(((millis / mth) % 60) % 24)); - const minutes = showWith0(Math.floor((millis / mtm) % 60)); - const seconds = showWith0(Math.floor((millis / mts) % 60)); - - return showSeconds - ? `${isNegative}${ - parseInt(hours, 10) ? `${hours}${delim}` : `00${delim}` - }${minutes}${delim}${seconds}` - : `${isNegative}${parseInt(hours, 10) ? `${hours}` : '00'}${delim}${minutes}`; -}; - -/** - * @description Resolves format from url and store - * @return {string|undefined} - */ -export const resolveTimeFormat = () => { - const params = new URL(document.location).searchParams; - const urlOptions = params.get('format'); - const settings = ontimeQueryClient.getQueryData(APP_SETTINGS); - - return urlOptions || settings?.timeFormat; -}; - -/** - /** - * @description utility function to format a date in 12 or 24 hour format - * @param {number} milliseconds - * @param {object} [options] - * @param {boolean} [options.showSeconds] - * @param {string} [options.format] - * @param {function} resolver - * @return {string} - */ -export const formatTime = (milliseconds, options, resolver = resolveTimeFormat) => { - if (milliseconds === null) { - return '...'; - } - const timeFormat = resolver(); - const { showSeconds = false, format: formatString = 'hh:mm a' } = options || {}; - return timeFormat === '12' - ? DateTime.fromMillis(milliseconds).toUTC().toFormat(formatString) - : stringFromMillis(milliseconds, showSeconds); -}; diff --git a/apps/client/src/common/utils/time.ts b/apps/client/src/common/utils/time.ts new file mode 100644 index 000000000..6aeaa50db --- /dev/null +++ b/apps/client/src/common/utils/time.ts @@ -0,0 +1,60 @@ +import { DateTime } from 'luxon'; +import { Settings } from 'ontime-types'; +import { millisToString } from 'ontime-utils'; + +import { APP_SETTINGS } from '../api/apiConstants'; +import { ontimeQueryClient } from '../queryClient'; + +/** + * Returns current time in milliseconds + * @returns {number} + */ +export const nowInMillis = () => { + const now = new Date(); + + // extract milliseconds since midnight + let elapsed = now.getHours() * 3600000; + elapsed += now.getMinutes() * 60000; + elapsed += now.getSeconds() * 1000; + elapsed += now.getMilliseconds(); + + return elapsed; +}; + +/** + * @description Resolves format from url and store + * @return {string|undefined} + */ +export const resolveTimeFormat = () => { + const params = new URL(document.location.href).searchParams; + const urlOptions = params.get('format'); + const settings: Settings | undefined = ontimeQueryClient.getQueryData(APP_SETTINGS); + + return urlOptions || settings?.timeFormat; +}; + +type FormatOptions = { + showSeconds?: boolean; + format?: string; +}; + +/** + /** + * @description utility function to format a date in 12 or 24 hour format + * @param {number | null} milliseconds + * @param {object} [options] + * @param {boolean} [options.showSeconds] + * @param {string} [options.format] + * @param {function} resolver + * @return {string} + */ +export const formatTime = (milliseconds: number | null, options: FormatOptions, resolver = resolveTimeFormat) => { + if (milliseconds === null) { + return '...'; + } + const timeFormat = resolver(); + const { showSeconds = false, format: formatString = 'hh:mm a' } = options || {}; + return timeFormat === '12' + ? DateTime.fromMillis(milliseconds).toUTC().toFormat(formatString) + : millisToString(milliseconds, showSeconds); +}; diff --git a/apps/client/src/features/control/message/MessageControl.tsx b/apps/client/src/features/control/message/MessageControl.tsx index f62906fe3..20d754974 100644 --- a/apps/client/src/features/control/message/MessageControl.tsx +++ b/apps/client/src/features/control/message/MessageControl.tsx @@ -9,40 +9,40 @@ import InputRow from './InputRow'; import style from './MessageControl.module.scss'; export default function MessageControl() { - const { data } = useMessageControl(); + const data = useMessageControl(); return (
setMessage.presenterText(newValue)} - actionHandler={() => setMessage.presenterVisible(!data?.messages.presenter.visible)} + actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)} /> setMessage.publicText(newValue)} - actionHandler={() => setMessage.publicVisible(!data?.messages.public.visible)} + actionHandler={() => setMessage.publicVisible(!data.publicMessage.visible)} /> setMessage.lowerText(newValue)} - actionHandler={() => setMessage.lowerVisible(!data?.messages.lower.visible)} + actionHandler={() => setMessage.lowerVisible(!data.lowerMessage.visible)} />
diff --git a/apps/client/src/features/control/playback/PlaybackButtons.tsx b/apps/client/src/features/control/playback/PlaybackButtons.tsx index 3fcffaefa..c459b0799 100644 --- a/apps/client/src/features/control/playback/PlaybackButtons.tsx +++ b/apps/client/src/features/control/playback/PlaybackButtons.tsx @@ -5,24 +5,15 @@ import Transport from './Transport'; interface PlaybackButtonsProps { playback: Playback; - selectedId: string | null; noEvents: boolean; } export default function PlaybackButtons(props: PlaybackButtonsProps) { - const { playback, selectedId, noEvents } = props; + const { playback, noEvents } = props; return ( <> - - + + ); }; diff --git a/apps/client/src/features/control/playback/PlaybackControl.tsx b/apps/client/src/features/control/playback/PlaybackControl.tsx index aa8115709..4277e1618 100644 --- a/apps/client/src/features/control/playback/PlaybackControl.tsx +++ b/apps/client/src/features/control/playback/PlaybackControl.tsx @@ -8,19 +8,12 @@ import PlaybackTimer from './PlaybackTimer'; import style from './PlaybackControl.module.scss'; export default function PlaybackControl() { - const { data } = usePlaybackControl(); + const data = usePlaybackControl(); return (
- - + +
); } diff --git a/apps/client/src/features/control/playback/PlaybackDisplay.tsx b/apps/client/src/features/control/playback/PlaybackDisplay.tsx index f6f979ab4..d2000022a 100644 --- a/apps/client/src/features/control/playback/PlaybackDisplay.tsx +++ b/apps/client/src/features/control/playback/PlaybackDisplay.tsx @@ -11,23 +11,23 @@ import style from './PlaybackControl.module.scss'; interface PlaybackProps { playback: Playback; - selectedId: string | null; noEvents: boolean; } export default function PlaybackDisplay(props: PlaybackProps) { - const { playback, selectedId, noEvents } = props; - const isRolling = playback === 'roll'; - const isPlaying = playback === 'play'; - const isPaused = playback === 'pause'; - const isArmed = playback === 'armed'; + const { playback, noEvents } = props; + const isRolling = playback === Playback.Roll; + const isPlaying = playback === Playback.Play; + const isPaused = playback === Playback.Pause; + const isArmed = playback === Playback.Armed; + const isStopped = playback === Playback.Stop; return (
setPlayback.start()} - disabled={!selectedId || isRolling} - theme='play' + disabled={isStopped || isRolling} + theme={Playback.Play} active={isPlaying} > @@ -35,8 +35,8 @@ export default function PlaybackDisplay(props: PlaybackProps) { setPlayback.pause()} - disabled={!selectedId || isRolling || isArmed} - theme='pause' + disabled={isStopped || isRolling || isArmed} + theme={Playback.Pause} active={isPaused} > @@ -44,8 +44,8 @@ export default function PlaybackDisplay(props: PlaybackProps) { setPlayback.roll()} - disabled={noEvents} - theme='roll' + disabled={!isStopped || noEvents} + theme={Playback.Roll} active={isRolling} > diff --git a/apps/client/src/features/control/playback/PlaybackTimer.tsx b/apps/client/src/features/control/playback/PlaybackTimer.tsx index c4dd8e2dd..2c382eafc 100644 --- a/apps/client/src/features/control/playback/PlaybackTimer.tsx +++ b/apps/client/src/features/control/playback/PlaybackTimer.tsx @@ -4,33 +4,33 @@ import { Playback } from 'ontime-types'; import TimerDisplay from '../../../common/components/timer-display/TimerDisplay'; import { setPlayback, useTimer } from '../../../common/hooks/useSocket'; import { millisToMinutes } from '../../../common/utils/dateConfig'; -import { stringFromMillis } from '../../../common/utils/time'; import { tooltipDelayMid } from '../../../ontimeConfig'; import TapButton from './TapButton'; import style from './PlaybackControl.module.scss'; +import { millisToString } from 'ontime-utils'; interface PlaybackTimerProps { playback: Playback; - selectedId: string | null; } export default function PlaybackTimer(props: PlaybackTimerProps) { - const { playback, selectedId } = props; - const { data: timerData } = useTimer(); + const { playback } = props; + const data = useTimer(); // TODO: checkout typescript in utilities - const started = stringFromMillis(timerData?.startedAt, true); - const finish = stringFromMillis(timerData.expectedFinish, true); - const isRolling = playback === 'roll'; - const isWaiting = timerData.secondaryTimer !== null && timerData.secondaryTimer > 0 && timerData.current === null; - const disableButtons = selectedId === null || isRolling; - const isOvertime = timerData.current !== null && timerData.current < 0; - const hasAddedTime = Boolean(timerData.addedTime); + const started = millisToString(data.timer.startedAt); + const finish = millisToString(data.timer.expectedFinish); + const isRolling = playback === Playback.Roll; + const isStopped = playback === Playback.Stop; + const isWaiting = data.timer.secondaryTimer !== null && data.timer.secondaryTimer > 0 && data.timer.current === null; + const disableButtons = isStopped || isRolling; + const isOvertime = data.timer.current !== null && data.timer.current < 0; + const hasAddedTime = Boolean(data.timer.addedTime); const rollLabel = isRolling ? 'Roll mode active' : ''; - const addedTimeLabel = hasAddedTime ? `Added ${millisToMinutes(timerData.addedTime)} minutes` : ''; + const addedTimeLabel = hasAddedTime ? `Added ${millisToMinutes(data.timer.addedTime)} minutes` : ''; return (
@@ -44,7 +44,7 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
- +
{isWaiting ? (
diff --git a/apps/client/src/features/control/playback/Transport.tsx b/apps/client/src/features/control/playback/Transport.tsx index 0eb74f38b..918a037f2 100644 --- a/apps/client/src/features/control/playback/Transport.tsx +++ b/apps/client/src/features/control/playback/Transport.tsx @@ -14,46 +14,33 @@ import style from './PlaybackControl.module.scss'; interface TransportProps { playback: Playback; - selectedId: string | null; noEvents: boolean; } export default function Transport(props: TransportProps) { - const { playback, selectedId, noEvents } = props; - const isRolling = playback === 'roll'; + const { playback, noEvents } = props; + const isRolling = playback === Playback.Roll; + const isStopped = playback === Playback.Stop; return (
- setPlayback.previous()} - disabled={isRolling || noEvents} - > + setPlayback.previous()} disabled={isRolling || noEvents}> - setPlayback.next()} - disabled={isRolling || noEvents} - > + setPlayback.next()} disabled={isRolling || noEvents}> - setPlayback.reload()} - disabled={!selectedId || isRolling} - > + setPlayback.reload()} disabled={isStopped || isRolling}> - setPlayback.stop()} - disabled={!selectedId && !isRolling} - theme='stop' - > + setPlayback.stop()} disabled={isStopped && !isRolling} theme={Playback.Stop}> diff --git a/apps/client/src/features/event-editor/EventEditor.tsx b/apps/client/src/features/event-editor/EventEditor.tsx index 5ffc5d8c2..2c5e18569 100644 --- a/apps/client/src/features/event-editor/EventEditor.tsx +++ b/apps/client/src/features/event-editor/EventEditor.tsx @@ -1,20 +1,20 @@ -import { useCallback, useContext, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Button, Select, Switch } from '@chakra-ui/react'; import { IoBan } from '@react-icons/all-files/io5/IoBan'; import { useAtom } from 'jotai'; import { OntimeEvent, TimerType } from 'ontime-types'; +import { millisToString } from 'ontime-utils'; import { editorEventId } from '../../common/atoms/LocalEventSettings'; import CopyTag from '../../common/components/copy-tag/CopyTag'; import ColourInput from '../../common/components/input/colour-input/ColourInput'; import TextInput from '../../common/components/input/text-input/TextInput'; import TimeInput from '../../common/components/input/time-input/TimeInput'; -import { LoggingContext } from '../../common/context/LoggingContext'; import { useEventAction } from '../../common/hooks/useEventAction'; import useRundown from '../../common/hooks-query/useRundown'; +import { useEmitLog } from '../../common/stores/logger'; import { millisToMinutes } from '../../common/utils/dateConfig'; import getDelayTo from '../../common/utils/getDelayTo'; -import { stringFromMillis } from '../../common/utils/time'; import { calculateDuration, TimeEntryField, validateEntry } from '../../common/utils/timesManager'; import style from './EventEditor.module.scss'; @@ -25,7 +25,7 @@ export type EventEditorSubmitActions = keyof OntimeEvent | 'durationOverride'; export default function EventEditor() { const [openId] = useAtom(editorEventId); const { data } = useRundown(); - const { emitWarning, emitError } = useContext(LoggingContext); + const { emitWarning, emitError } = useEmitLog(); const { updateEvent } = useEventAction(); const [event, setEvent] = useState(null); const [delay, setDelay] = useState(0); @@ -121,8 +121,8 @@ export default function EventEditor() { const delayed = delay !== 0; const addedTime = delayed ? `${delay >= 0 ? '+' : '-'} ${millisToMinutes(Math.abs(delay))} minutes` : null; - const newStart = delayed ? `New start ${stringFromMillis(event.timeStart + delay)}` : null; - const newEnd = delayed ? `New end ${stringFromMillis(event.timeEnd + delay)}` : null; + const newStart = delayed ? `New start ${millisToString(event.timeStart + delay)}` : null; + const newEnd = delayed ? `New end ${millisToString(event.timeEnd + delay)}` : null; return (
diff --git a/apps/client/src/features/info/CollapsableInfo.tsx b/apps/client/src/features/info/CollapsableInfo.tsx index 1a2e4929a..207052c4b 100644 --- a/apps/client/src/features/info/CollapsableInfo.tsx +++ b/apps/client/src/features/info/CollapsableInfo.tsx @@ -1,52 +1,21 @@ -import { useState } from 'react'; +import { PropsWithChildren, useState } from 'react'; import CollapseBar from '../../common/components/collapse-bar/CollapseBar'; import style from './Info.module.scss'; -type TitleShape = { - title: string; - presenter: string; - subtitle: string; - note: string; -} - interface CollapsableInfoProps { title: string; - data: TitleShape; } -export default function CollapsableInfo(props: CollapsableInfoProps) { - const { title, data } = props; +export default function CollapsableInfo(props: PropsWithChildren) { + const { title, children } = props; const [collapsed, setCollapsed] = useState(false); return (
- setCollapsed((prev) => !prev)} - /> - {!collapsed && ( -
-
- Title: - {data.title} -
-
- Presenter: - {data.presenter} -
-
- Subtitle: - {data.subtitle} -
-
- Note: - {data.note} -
-
- )} + setCollapsed((prev) => !prev)} /> + {!collapsed && children}
); } diff --git a/apps/client/src/features/info/Info.jsx b/apps/client/src/features/info/Info.jsx deleted file mode 100644 index 4ba685ff6..000000000 --- a/apps/client/src/features/info/Info.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useInfoPanel } from '../../common/hooks/useSocket'; - -import InfoTitle from './CollapsableInfo'; -import InfoLogger from './InfoLogger'; -import InfoNif from './InfoNif'; - -import style from './Info.module.scss'; - -export default function Info() { - const { data } = useInfoPanel(); - - const titlesNow = { - title: data.titles.titleNow, - subtitle: data.titles.subtitleNow, - presenter: data.titles.presenterNow, - note: data.titles.noteNow, - }; - - const titlesNext = { - title: data.titles.titleNext, - subtitle: data.titles.subtitleNext, - presenter: data.titles.presenterNext, - note: data.titles.noteNext, - }; - - const selected = !data.numEvents - ? 'No events' - : `Event ${data.selectedEventIndex != null ? data.selectedEventIndex + 1 : '-'} / ${ - data.numEvents ? data.numEvents : '-' - }`; - - return ( - <> -
- Ontime running on port 4001 - {selected} -
- - - - - - ); -} diff --git a/apps/client/src/features/info/Info.tsx b/apps/client/src/features/info/Info.tsx new file mode 100644 index 000000000..406ed85c6 --- /dev/null +++ b/apps/client/src/features/info/Info.tsx @@ -0,0 +1,53 @@ +import { useInfoPanel } from '../../common/hooks/useSocket'; + +import CollapsableInfo from './CollapsableInfo'; +import InfoLogger from './InfoLogger'; +import InfoNif from './InfoNif'; +import InfoTitles from './InfoTitles'; + +import style from './Info.module.scss'; + +export default function Info() { + const data = useInfoPanel(); + + const titlesNow = { + title: data.titles.titleNow || '', + subtitle: data.titles.subtitleNow || '', + presenter: data.titles.presenterNow || '', + note: data.titles.noteNow || '', + }; + + const titlesNext = { + title: data.titles.titleNext || '', + subtitle: data.titles.subtitleNext || '', + presenter: data.titles.presenterNext || '', + note: data.titles.noteNext || '', + }; + + const selected = !data.numEvents + ? 'No events' + : `Event ${data.selectedEventIndex !== null ? data.selectedEventIndex + 1 : '-'} / ${ + data.numEvents ? data.numEvents : '-' + }`; + + return ( + <> +
+ Ontime running on port 4001 + {selected} +
+ + + + + + + + + + + + + + ); +} diff --git a/apps/client/src/features/info/InfoLogger.module.scss b/apps/client/src/features/info/InfoLogger.module.scss index d1e86e4a9..33ba38860 100644 --- a/apps/client/src/features/info/InfoLogger.module.scss +++ b/apps/client/src/features/info/InfoLogger.module.scss @@ -6,12 +6,7 @@ $info-hover: $section-white; .infoLoggerContainer { max-height: 80%; - margin-top: 32px; - - &.expanded { - min-height: 50%; - height: 100% - } + height: 100% } .log { @@ -24,6 +19,7 @@ $info-hover: $section-white; .logEntry { display: flex; margin-bottom: 2px; + &.INFO { color: $info-gray; } diff --git a/apps/client/src/features/info/InfoLogger.tsx b/apps/client/src/features/info/InfoLogger.tsx index 9ded93754..5abab2ada 100644 --- a/apps/client/src/features/info/InfoLogger.tsx +++ b/apps/client/src/features/info/InfoLogger.tsx @@ -1,24 +1,22 @@ -import { useCallback, useContext, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import { Button } from '@chakra-ui/react'; -import CollapseBar from '../../common/components/collapse-bar/CollapseBar'; -import { Log, LoggingContext } from '../../common/context/LoggingContext'; +import { clearLogs, useLogData } from '../../common/stores/logger'; import style from './InfoLogger.module.scss'; -enum LOG_FILTER { - USER = 'USER', - CLIENT = 'CLIENT', - SERVER = 'SERVER', +enum LogFilter { + User = 'USER', + Client = 'CLIENT', + Server = 'SERVER', RX = 'RX', TX = 'TX', - PLAYBACK = 'PLAYBACK', + Playback = 'PLAYBACK', } export default function InfoLogger() { - const { logData, clearLog } = useContext(LoggingContext); - const [data, setData] = useState([]); - const [collapsed, setCollapsed] = useState(false); + const { logs: logData } = useLogData(); + const [showClient, setShowClient] = useState(true); const [showServer, setShowServer] = useState(true); const [showRx, setShowRx] = useState(true); @@ -26,123 +24,107 @@ export default function InfoLogger() { const [showPlayback, setShowPlayback] = useState(true); const [showUser, setShowUser] = useState(true); - useEffect(() => { - if (!logData) { - return; - } + const matchers: LogFilter[] = []; + if (showUser) { + matchers.push(LogFilter.User); + } + if (showClient) { + matchers.push(LogFilter.Client); + } + if (showServer) { + matchers.push(LogFilter.Server); + } + if (showRx) { + matchers.push(LogFilter.RX); + } + if (showTx) { + matchers.push(LogFilter.TX); + } + if (showPlayback) { + matchers.push(LogFilter.Playback); + } - const matchers: LOG_FILTER[] = []; - if (showUser) { - matchers.push(LOG_FILTER.USER); - } - if (showClient) { - matchers.push(LOG_FILTER.CLIENT); - } - if (showServer) { - matchers.push(LOG_FILTER.SERVER); - } - if (showRx) { - matchers.push(LOG_FILTER.RX); - } - if (showTx) { - matchers.push(LOG_FILTER.TX); - } - if (showPlayback) { - matchers.push(LOG_FILTER.PLAYBACK); - } + const filteredData = logData.filter((entry) => matchers.some((match) => entry.origin === match)); - const filteredData = logData.filter((entry) => matchers.some((match) => entry.origin === match)); - setData(filteredData); - }, [logData, showUser, showClient, showServer, showPlayback, showRx, showTx]); - - const disableOthers = useCallback((toEnable: LOG_FILTER) => { - toEnable === LOG_FILTER.USER ? setShowUser(true) : setShowUser(false); - toEnable === LOG_FILTER.CLIENT ? setShowClient(true) : setShowClient(false); - toEnable === LOG_FILTER.SERVER ? setShowServer(true) : setShowServer(false); - toEnable === LOG_FILTER.RX ? setShowRx(true) : setShowRx(false); - toEnable === LOG_FILTER.TX ? setShowTx(true) : setShowTx(false); - toEnable === LOG_FILTER.PLAYBACK ? setShowPlayback(true) : setShowPlayback(false); + const disableOthers = useCallback((toEnable: LogFilter) => { + toEnable === LogFilter.User ? setShowUser(true) : setShowUser(false); + toEnable === LogFilter.Client ? setShowClient(true) : setShowClient(false); + toEnable === LogFilter.Server ? setShowServer(true) : setShowServer(false); + toEnable === LogFilter.RX ? setShowRx(true) : setShowRx(false); + toEnable === LogFilter.TX ? setShowTx(true) : setShowTx(false); + toEnable === LogFilter.Playback ? setShowPlayback(true) : setShowPlayback(false); }, []); return ( -
- setCollapsed((prev) => !prev)} /> - {!collapsed && ( - <> -
- - - - - - - -
-
    - {data.map((logEntry) => ( -
  • - {logEntry.time} - {logEntry.origin} - {logEntry.text} -
  • - ))} -
- - )} +
+
+ + + + + + + +
+
    + {filteredData.map((logEntry) => ( +
  • + {logEntry.time} + {logEntry.origin} + {logEntry.text} +
  • + ))} +
); } diff --git a/apps/client/src/features/info/InfoNif.tsx b/apps/client/src/features/info/InfoNif.tsx index 907c5c04c..3b33aba7c 100644 --- a/apps/client/src/features/info/InfoNif.tsx +++ b/apps/client/src/features/info/InfoNif.tsx @@ -1,7 +1,5 @@ -import { useState } from 'react'; import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; -import CollapseBar from '../../common/components/collapse-bar/CollapseBar'; import useInfo from '../../common/hooks-query/useInfo'; import { openLink } from '../../common/utils/linkUtils'; @@ -9,7 +7,6 @@ import style from './Info.module.scss'; export default function InfoNif() { const { data } = useInfo(); - const [collapsed, setCollapsed] = useState(false); const handleClick = (address: string) => { const baseURL = 'http://__IP__:4001'; @@ -17,18 +14,13 @@ export default function InfoNif() { }; return ( -
- setCollapsed((prev) => !prev)} /> - {!collapsed && ( -
- {data?.networkInterfaces.map((nif) => ( - handleClick(nif.address)} className={style.interface}> - {`${nif.name} - ${nif.address}`} - - - ))} -
- )} +
+ {data?.networkInterfaces.map((nif) => ( + handleClick(nif.address)} className={style.interface}> + {`${nif.name} - ${nif.address}`} + + + ))}
); } diff --git a/apps/client/src/features/info/InfoTitles.tsx b/apps/client/src/features/info/InfoTitles.tsx new file mode 100644 index 000000000..20b7bdb39 --- /dev/null +++ b/apps/client/src/features/info/InfoTitles.tsx @@ -0,0 +1,36 @@ +import style from './Info.module.scss'; + +type TitleShape = { + title: string; + presenter: string; + subtitle: string; + note: string; +}; + +interface InfoTitleProps { + data: TitleShape; +} + +export default function InfoTitles(props: InfoTitleProps) { + const { data } = props; + return ( +
+
+ Title: + {data.title} +
+
+ Presenter: + {data.presenter} +
+
+ Subtitle: + {data.subtitle} +
+
+ Note: + {data.note} +
+
+ ); +} diff --git a/apps/client/src/features/modals/AliasesModal.jsx b/apps/client/src/features/modals/AliasesModal.jsx index c7957750e..92222d9f1 100644 --- a/apps/client/src/features/modals/AliasesModal.jsx +++ b/apps/client/src/features/modals/AliasesModal.jsx @@ -1,13 +1,14 @@ /* eslint-disable jsx-a11y/anchor-has-content */ -import { useCallback, useContext, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Button, IconButton, Input, ModalBody, Tooltip } from '@chakra-ui/react'; import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline'; import { IoRemove } from '@react-icons/all-files/io5/IoRemove'; import { IoSunny } from '@react-icons/all-files/io5/IoSunny'; +import { useEmitLog } from '@/common/stores/logger'; + import { viewerLocations } from '../../appConstants'; import { postAliases } from '../../common/api/ontimeApi'; -import { LoggingContext } from '../../common/context/LoggingContext'; import useAliases from '../../common/hooks-query/useAliases'; import { validateAlias } from '../../common/utils/aliases'; import { handleLinks, host } from '../../common/utils/linkUtils'; @@ -19,7 +20,7 @@ import style from './Modals.module.scss'; export default function AliasesModal() { const { data, status, refetch } = useAliases(); - const { emitError } = useContext(LoggingContext); + const { emitError } = useEmitLog(); const [changed, setChanged] = useState(false); const [submitting, setSubmitting] = useState(false); const [aliases, setAliases] = useState([]); @@ -111,29 +112,32 @@ export default function AliasesModal() { * @param {string} id - object id * @param {boolean} isEnabled - whether to enable / disable flag */ - const setEnabled = useCallback((id, isEnabled) => { - const aliasesState = [...aliases]; - for (const a of aliasesState) { - if (a.id === id) { - if (isEnabled) { - if (a.alias === '' || a.pathAndParams === '') { - emitError('Alias incomplete'); - break; - } + const setEnabled = useCallback( + (id, isEnabled) => { + const aliasesState = [...aliases]; + for (const a of aliasesState) { + if (a.id === id) { + if (isEnabled) { + if (a.alias === '' || a.pathAndParams === '') { + emitError('Alias incomplete'); + break; + } - const isRepeated = aliases.some((r) => a.alias === r.alias && r.enabled); - if (isRepeated) { - emitError('There is already an alias with this name'); - break; + const isRepeated = aliases.some((r) => a.alias === r.alias && r.enabled); + if (isRepeated) { + emitError('There is already an alias with this name'); + break; + } } + a.enabled = isEnabled; + break; } - a.enabled = isEnabled; - break; } - } - setChanged(true); - setAliases(aliasesState); - }, [aliases, emitError]); + setChanged(true); + setAliases(aliasesState); + }, + [aliases, emitError], + ); /** * Reverts local state equals to server state @@ -194,16 +198,16 @@ export default function AliasesModal() { eg. a lower third url with some custom parameters - - - - - - - - + + + + + + + +
- Alias - Page URL
mylowerlower?bg=ff2&text=f00&size=0.6&transition=5
+ Alias + Page URL
mylowerlower?bg=ff2&text=f00&size=0.6&transition=5

@@ -212,16 +216,16 @@ export default function AliasesModal() { eg. an unattended screen that you would need to change route from the app - - - - - - - - + + + + + + + +
- Alias - Page URL
thirdfloorpublic
+ Alias + Page URL
thirdfloorpublic
@@ -254,12 +258,7 @@ export default function AliasesModal() { onChange={(event) => handleChange(index, 'pathAndParams', event.target.value)} /> - handleLinks(e, alias.pathAndParams)} - /> + handleLinks(e, alias.pathAndParams)} />
- {alias.aliasError ? ( -
{`Alias error: ${alias.aliasError}`}
- ) : null} - {alias.urlError ? ( -
{`URL error: ${alias.urlError}`}
- ) : null} + {alias.aliasError ?
{`Alias error: ${alias.aliasError}`}
: null} + {alias.urlError ?
{`URL error: ${alias.urlError}`}
: null}
))} @@ -296,12 +291,7 @@ export default function AliasesModal() {
- + ); diff --git a/apps/client/src/features/modals/AppSettingsModal.jsx b/apps/client/src/features/modals/AppSettingsModal.jsx index 69d62f63d..b22f844c9 100644 --- a/apps/client/src/features/modals/AppSettingsModal.jsx +++ b/apps/client/src/features/modals/AppSettingsModal.jsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import isEqual from 'react-fast-compare'; import { Button, @@ -16,11 +16,12 @@ import { FiEye } from '@react-icons/all-files/fi/FiEye'; import { FiX } from '@react-icons/all-files/fi/FiX'; import { useAtom } from 'jotai'; +import { useEmitLog } from '@/common/stores/logger'; + import { version } from '../../../package.json'; import { getLatestVersion, postSettings } from '../../common/api/ontimeApi'; import { eventSettingsAtom } from '../../common/atoms/LocalEventSettings'; import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn'; -import { LoggingContext } from '../../common/context/LoggingContext'; import useSettings from '../../common/hooks-query/useSettings'; import { ontimePlaceholderSettings } from '../../common/models/OntimeSettings'; @@ -31,7 +32,7 @@ import style from './Modals.module.scss'; export default function AppSettingsModal() { const { data, status, refetch } = useSettings(); - const { emitError, emitWarning } = useContext(LoggingContext); + const { emitError, emitWarning } = useEmitLog(); const [formData, setFormData] = useState(ontimePlaceholderSettings); const [changed, setChanged] = useState(false); const [submitting, setSubmitting] = useState(false); diff --git a/apps/client/src/features/modals/EventSettingsModal.jsx b/apps/client/src/features/modals/EventSettingsModal.jsx index b6f15c3af..d449ecc1d 100644 --- a/apps/client/src/features/modals/EventSettingsModal.jsx +++ b/apps/client/src/features/modals/EventSettingsModal.jsx @@ -1,8 +1,9 @@ -import { useCallback, useContext, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { FormLabel, Input, ModalBody, Textarea } from '@chakra-ui/react'; +import { useEmitLog } from '@/common/stores/logger'; + import { postEventData } from '../../common/api/eventDataApi'; -import { LoggingContext } from '../../common/context/LoggingContext'; import useEventData from '../../common/hooks-query/useEventData'; import { eventDataPlaceholder } from '../../common/models/EventData'; @@ -13,7 +14,7 @@ import style from './Modals.module.scss'; export default function SettingsModal() { const { data, status, refetch } = useEventData(); - const { emitError } = useContext(LoggingContext); + const { emitError } = useEmitLog(); const [formData, setFormData] = useState(eventDataPlaceholder); const [changed, setChanged] = useState(false); const [submitting, setSubmitting] = useState(false); diff --git a/apps/client/src/features/modals/TableOptionsModal.jsx b/apps/client/src/features/modals/TableOptionsModal.jsx index fd7193a9d..bd8df8281 100644 --- a/apps/client/src/features/modals/TableOptionsModal.jsx +++ b/apps/client/src/features/modals/TableOptionsModal.jsx @@ -1,9 +1,10 @@ -import { useCallback, useContext, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Input, ModalBody } from '@chakra-ui/react'; import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline'; +import { useEmitLog } from '@/common/stores/logger'; + import { postUserFields } from '../../common/api/ontimeApi'; -import { LoggingContext } from '../../common/context/LoggingContext'; import useUserFields from '../../common/hooks-query/useUserFields'; import { userFieldsPlaceholder } from '../../common/models/UserFields'; import { handleLinks, host } from '../../common/utils/linkUtils'; @@ -14,7 +15,7 @@ import style from './Modals.module.scss'; export default function TableOptionsModal() { const { data, status, refetch } = useUserFields(); - const { emitError } = useContext(LoggingContext); + const { emitError } = useEmitLog(); const [changed, setChanged] = useState(false); const [submitting, setSubmitting] = useState(false); const [userFields, setUserFields] = useState(userFieldsPlaceholder); diff --git a/apps/client/src/features/modals/ViewsSettingsModal.jsx b/apps/client/src/features/modals/ViewsSettingsModal.jsx index 5b2ab4f92..bfca2194b 100644 --- a/apps/client/src/features/modals/ViewsSettingsModal.jsx +++ b/apps/client/src/features/modals/ViewsSettingsModal.jsx @@ -1,11 +1,12 @@ -import { useCallback, useContext, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { FormControl, FormLabel, ModalBody } from '@chakra-ui/react'; import { IoCheckmarkSharp } from '@react-icons/all-files/io5/IoCheckmarkSharp'; import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline'; +import { useEmitLog } from '@/common/stores/logger'; + import { postView } from '../../common/api/ontimeApi'; import EnableBtn from '../../common/components/buttons/EnableBtn'; -import { LoggingContext } from '../../common/context/LoggingContext'; import useViewSettings from '../../common/hooks-query/useViewSettings'; import { viewsSettingsPlaceholder } from '../../common/models/ViewSettings.type'; import { openLink } from '../../common/utils/linkUtils'; @@ -17,7 +18,7 @@ import style from './Modals.module.scss'; export default function ViewsSettingsModal() { const { data, status, refetch } = useViewSettings(); - const { emitError } = useContext(LoggingContext); + const { emitError } = useEmitLog(); const [formData, setFormData] = useState(viewsSettingsPlaceholder); const [changed, setChanged] = useState(false); const [submitting, setSubmitting] = useState(false); diff --git a/apps/client/src/features/modals/integration-modal/OscIntegrationSettings.tsx b/apps/client/src/features/modals/integration-modal/OscIntegrationSettings.tsx index 917e7d02b..7e98b74a4 100644 --- a/apps/client/src/features/modals/integration-modal/OscIntegrationSettings.tsx +++ b/apps/client/src/features/modals/integration-modal/OscIntegrationSettings.tsx @@ -1,18 +1,17 @@ -import { useContext } from 'react'; import { useForm } from 'react-hook-form'; import { Button, FormControl, Input, ModalBody, ModalFooter, Switch } from '@chakra-ui/react'; import { postOSC } from '../../../common/api/ontimeApi'; -import { LoggingContext } from '../../../common/context/LoggingContext'; import useOscSettings from '../../../common/hooks-query/useOscSettings'; import { PlaceholderSettings } from '../../../common/models/OscSettings'; +import { useEmitLog } from '../../../common/stores/logger'; import { isIPAddress, isOnlyNumbers } from '../../../common/utils/regex'; import styles from '../Modal.module.scss'; export default function OscIntegrationSettings() { const { data } = useOscSettings(); - const { emitError } = useContext(LoggingContext); + const { emitError } = useEmitLog(); const { handleSubmit, register, diff --git a/apps/client/src/features/modals/integration-modal/OscSettingsModal.jsx b/apps/client/src/features/modals/integration-modal/OscSettingsModal.jsx index a6d4f64e2..e65702a68 100644 --- a/apps/client/src/features/modals/integration-modal/OscSettingsModal.jsx +++ b/apps/client/src/features/modals/integration-modal/OscSettingsModal.jsx @@ -1,10 +1,11 @@ -import { useCallback, useContext, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { FormControl, FormLabel, Input, ModalBody } from '@chakra-ui/react'; import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline'; +import { useEmitLog } from '@/common/stores/logger'; + import { postOSC } from '../../../common/api/ontimeApi'; import EnableBtn from '../../../common/components/buttons/EnableBtn'; -import { LoggingContext } from '../../../common/context/LoggingContext'; import useOscSettings from '../../../common/hooks-query/useOscSettings'; import { oscPlaceholderSettings } from '../../../common/models/OscSettings'; import { inputProps, portInputProps } from '../modalHelper'; @@ -76,7 +77,7 @@ const oscTriggerEndpoints = [ export default function OscSettingsModal() { const { data, status, refetch } = useOscSettings(); - const { emitError } = useContext(LoggingContext); + const { emitError } = useEmitLog(); const [formData, setFormData] = useState(oscPlaceholderSettings); const [changed, setChanged] = useState(false); const [submitting, setSubmitting] = useState(false); @@ -122,8 +123,8 @@ export default function OscSettingsModal() { } else { try { await postOSC(formData); - } catch (error){ - emitError(`Error setting OSC: ${error}`) + } catch (error) { + emitError(`Error setting OSC: ${error}`); } finally { await refetch(); setChanged(false); @@ -131,7 +132,7 @@ export default function OscSettingsModal() { } setSubmitting(false); }, - [emitError, formData, refetch] + [emitError, formData, refetch], ); /** @@ -154,7 +155,7 @@ export default function OscSettingsModal() { setFormData(temp); setChanged(true); }, - [formData] + [formData], ); return ( @@ -283,12 +284,7 @@ export default function OscSettingsModal() {
- + ); diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 862341f83..355ef1b1c 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -23,7 +23,7 @@ interface RundownProps { export default function Rundown(props: RundownProps) { const { entries } = props; - const { data } = useRundownEditor(); + const data = useRundownEditor(); const { cursor, moveCursorUp, moveCursorDown, moveCursorTo, isCursorLocked } = useContext(CursorContext); const startTimeIsLastEnd = useAtomValue(startTimeIsLastEndAtom); const defaultPublic = useAtomValue(defaultPublicAtom); diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 534ac88a5..3eb0f1efa 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -4,8 +4,8 @@ import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontim import { defaultPublicAtom, editorEventId, startTimeIsLastEndAtom } from '../../common/atoms/LocalEventSettings'; import { CursorContext } from '../../common/context/CursorContext'; -import { LoggingContext } from '../../common/context/LoggingContext'; import { useEventAction } from '../../common/hooks/useEventAction'; +import { useEmitLog } from '../../common/stores/logger'; import { cloneEvent } from '../../common/utils/eventsManager'; import { calculateDuration } from '../../common/utils/timesManager'; @@ -31,7 +31,7 @@ interface RundownEntryProps { export default function RundownEntry(props: RundownEntryProps) { const { index, eventIndex, data, selected, hasCursor, next, delay, previousEnd, previousEventId, playback } = props; - const { emitError } = useContext(LoggingContext); + const { emitError } = useEmitLog(); const startTimeIsLastEnd = useAtomValue(startTimeIsLastEndAtom); const defaultPublic = useAtomValue(defaultPublicAtom); const { addEvent, updateEvent, deleteEvent } = useEventAction(); diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index dd101ddcb..30c332755 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -111,7 +111,7 @@ export default function EventBlock(props: EventBlockProps) { [title, updateEvent, eventId], ); - const eventIsPlaying = selected && playback === 'play'; + const eventIsPlaying = selected && playback === Playback.Play; const playBtnStyles = { _hover: {} }; if (!skip && eventIsPlaying) { playBtnStyles._hover = { bg: '#c05621' }; diff --git a/apps/client/src/features/rundown/event-block/composite/EventBlockTimers.jsx b/apps/client/src/features/rundown/event-block/composite/EventBlockTimers.jsx index 11d8afb58..0d2c24f61 100644 --- a/apps/client/src/features/rundown/event-block/composite/EventBlockTimers.jsx +++ b/apps/client/src/features/rundown/event-block/composite/EventBlockTimers.jsx @@ -1,20 +1,21 @@ -import { useCallback, useContext } from 'react'; +import { useCallback } from 'react'; +import { millisToString } from 'ontime-utils'; import PropTypes from 'prop-types'; +import { useEmitLog } from '@/common/stores/logger'; + import TimeInput from '../../../../common/components/input/time-input/TimeInput'; -import { LoggingContext } from '../../../../common/context/LoggingContext'; import { millisToMinutes } from '../../../../common/utils/dateConfig'; -import { stringFromMillis } from '../../../../common/utils/time'; import { validateEntry } from '../../../../common/utils/timesManager'; import style from '../EventBlock.module.scss'; export default function EventBlockTimers(props) { const { timeStart, timeEnd, duration, delay, actionHandler, previousEnd } = props; - const { emitWarning } = useContext(LoggingContext); + const { emitWarning } = useEmitLog(); const delayTime = `${delay >= 0 ? '+' : '-'} ${millisToMinutes(Math.abs(delay))}`; - const newTime = stringFromMillis(timeStart + delay); + const newTime = millisToString(timeStart + delay); /** * @description Validates a time input against its pair diff --git a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx index cb534c0c3..bb287f0d2 100644 --- a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx +++ b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx @@ -1,11 +1,11 @@ -import { useCallback, useContext, useRef } from 'react'; +import { useCallback, useRef } from 'react'; import { Button, Checkbox, Tooltip } from '@chakra-ui/react'; import { useAtomValue } from 'jotai'; import { SupportedEvent } from 'ontime-types'; import { defaultPublicAtom, startTimeIsLastEndAtom } from '../../../common/atoms/LocalEventSettings'; -import { LoggingContext } from '../../../common/context/LoggingContext'; import { useEventAction } from '../../../common/hooks/useEventAction'; +import { useEmitLog } from '../../../common/stores/logger'; import { tooltipDelayMid } from '../../../ontimeConfig'; import style from './QuickAddBlock.module.scss'; @@ -21,7 +21,7 @@ interface QuickAddBlockProps { export default function QuickAddBlock(props: QuickAddBlockProps) { const { showKbd, eventId, previousEventId, disableAddDelay = true, disableAddBlock } = props; const { addEvent } = useEventAction(); - const { emitError } = useContext(LoggingContext); + const { emitError } = useEmitLog(); const startTimeIsLastEnd = useAtomValue(startTimeIsLastEndAtom); const defaultPublic = useAtomValue(defaultPublicAtom); const doStartTime = useRef(null); diff --git a/apps/client/src/features/table/columns.jsx b/apps/client/src/features/table/columns.jsx index 52792e995..081a01a3b 100644 --- a/apps/client/src/features/table/columns.jsx +++ b/apps/client/src/features/table/columns.jsx @@ -1,10 +1,9 @@ import { FiCheck } from '@react-icons/all-files/fi/FiCheck'; -import { stringFromMillis } from '../../common/utils/time.js'; - import EditableCell from './tableElements/EditableCell'; import style from './Table.module.scss'; +import { millisToString } from 'ontime-utils'; /** * React - Table column object @@ -22,19 +21,19 @@ export const makeColumns = (sizes, userFields) => { { Header: 'Start', accessor: 'timeStart', - Cell: ({ cell: { value, delayed } }) => stringFromMillis(delayed || value), + Cell: ({ cell: { value, delayed } }) => millisToString(delayed || value), width: sizes?.timeStart || 90, }, { Header: 'End', accessor: 'timeEnd', - Cell: ({ cell: { value, delayed } }) => stringFromMillis(delayed || value), + Cell: ({ cell: { value, delayed } }) => millisToString(delayed || value), width: sizes?.timeEnd || 90, }, { Header: 'Duration', accessor: 'duration', - Cell: ({ cell: { value } }) => stringFromMillis(value), + Cell: ({ cell: { value } }) => millisToString(value), width: sizes?.duration || 90, }, { Header: 'Title', accessor: 'title', width: sizes?.title || 400 }, diff --git a/apps/client/src/features/table/tableElements/PlaybackIcon.jsx b/apps/client/src/features/table/tableElements/PlaybackIcon.tsx similarity index 77% rename from apps/client/src/features/table/tableElements/PlaybackIcon.jsx rename to apps/client/src/features/table/tableElements/PlaybackIcon.tsx index fcf66fcc8..72e33366a 100644 --- a/apps/client/src/features/table/tableElements/PlaybackIcon.jsx +++ b/apps/client/src/features/table/tableElements/PlaybackIcon.tsx @@ -3,14 +3,18 @@ import { IoPause } from '@react-icons/all-files/io5/IoPause'; import { IoPlay } from '@react-icons/all-files/io5/IoPlay'; import { IoStop } from '@react-icons/all-files/io5/IoStop'; import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline'; -import PropTypes from 'prop-types'; +import { Playback } from 'ontime-types'; import { tooltipDelayFast } from '../../../ontimeConfig'; -export default function PlaybackIcon(props) { +interface PlaybackIconProps { + state: Playback; +} + +export default function PlaybackIcon(props: PlaybackIconProps) { const { state } = props; - if (state === 'stop') { + if (state === Playback.Stop) { return ( @@ -18,7 +22,7 @@ export default function PlaybackIcon(props) { ); } - if (state === 'start') { + if (state === Playback.Play) { return ( @@ -26,7 +30,7 @@ export default function PlaybackIcon(props) { ); } - if (state === 'pause') { + if (state === Playback.Pause) { return ( @@ -34,7 +38,7 @@ export default function PlaybackIcon(props) { ); } - if (state === 'roll') { + if (state === Playback.Roll) { return ( @@ -44,7 +48,3 @@ export default function PlaybackIcon(props) { return ''; } - -PlaybackIcon.propTypes = { - state: PropTypes.string, -}; diff --git a/apps/client/src/features/table/utils.js b/apps/client/src/features/table/utils.js index 28d9c5cd5..1d9968e17 100644 --- a/apps/client/src/features/table/utils.js +++ b/apps/client/src/features/table/utils.js @@ -1,4 +1,5 @@ import { stringify } from 'csv-stringify/browser/esm/sync'; +import { millisToString } from 'ontime-utils'; /** * @description parses a field for export @@ -6,14 +7,13 @@ import { stringify } from 'csv-stringify/browser/esm/sync'; * @param {*} data * @return {string} */ -import { stringFromMillis } from '../../common/utils/time'; export const parseField = (field, data) => { let val; switch (field) { case 'timeStart': case 'timeEnd': - val = stringFromMillis(data); + val = millisToString(data); break; case 'isPublic': val = data ? 'x' : ''; diff --git a/apps/client/src/features/viewers/ViewWrapper.jsx b/apps/client/src/features/viewers/ViewWrapper.tsx similarity index 54% rename from apps/client/src/features/viewers/ViewWrapper.jsx rename to apps/client/src/features/viewers/ViewWrapper.tsx index 3c01454ae..88419f361 100644 --- a/apps/client/src/features/viewers/ViewWrapper.jsx +++ b/apps/client/src/features/viewers/ViewWrapper.tsx @@ -1,68 +1,33 @@ -/* eslint-disable react/display-name */ -import { useEffect, useMemo, useState } from 'react'; +import { ReactNode, useMemo } from 'react'; +import { Playback } from 'ontime-types'; -import { useMessageControl } from '../../common/hooks/useSocket'; -import useSubscription from '../../common/hooks/useSubscription'; import useEventData from '../../common/hooks-query/useEventData'; import useRundown from '../../common/hooks-query/useRundown'; import useViewSettings from '../../common/hooks-query/useViewSettings'; -import socket from '../../common/utils/socket'; +import { useRuntimeStore } from '../../common/stores/runtime'; -const withSocket = (Component) => { +const withData = (Component: ReactNode) => { return (props) => { + + // HTTP API data const { data: eventsData } = useRundown(); const { data: genData } = useEventData(); const { data: viewSettings } = useViewSettings(); - const { data: messageControl } = useMessageControl(); - - const [publicSelectedId, setPublicSelectedId] = useState(null); - - const [timer] = useSubscription('timer', { - clock: null, - current: null, - elapsed: null , - expectedFinish: null, - addedTime: 0, - startedAt: null, - finishedAt: null, - secondaryTimer: null, - }); - const [titles] = useSubscription('titles', { - titleNow: '', - subtitleNow: '', - presenterNow: '', - titleNext: '', - subtitleNext: '', - presenterNext: '', - }); - const [publicTitles] = useSubscription('titlesPublic', { - titleNow: '', - subtitleNow: '', - presenterNow: '', - titleNext: '', - subtitleNext: '', - presenterNext: '', - }); - const [selectedId] = useSubscription('selected-id', null); - const [nextId] = useSubscription('next-id', null); - const [playback] = useSubscription('playback', null); - - // Ask for update on load - useEffect(() => { - // todo: remove - socket.on('publicselected-id', (data) => { - setPublicSelectedId(data); - }); - }, []); - const publicEvents = useMemo(() => { if (Array.isArray(eventsData)) { - return eventsData.filter((d) => d.type === 'event' && d.title !== '' && d.isPublic); + return eventsData.filter((e) => e.type === 'event' && e.title && e.isPublic); } return []; }, [eventsData]); + // websocket data + const data = useRuntimeStore(); + const { timer, titles, titlesPublic, publicMessage, timerMessage, lowerMessage, playback, onAir } = data; + const publicSelectedId = data.loaded.selectedPublicEventId; + const selectedId = data.loaded.selectedEventId; + const nextId = data.loaded.nextEventId; + /********************************************/ /*** + titleManager ***/ /*** WRAP INFORMATION RELATED TO TITLES ***/ @@ -85,16 +50,14 @@ const withSocket = (Component) => { /********************************************/ // is there a now field? let showPublicNow = true; - if (!publicTitles.titleNow && !publicTitles.subtitleNow && !publicTitles.presenterNow) - showPublicNow = false; + if (!titlesPublic.titleNow && !titlesPublic.subtitleNow && !titlesPublic.presenterNow) showPublicNow = false; // is there a next field? let showPublicNext = true; - if (!publicTitles.titleNext && !publicTitles.subtitleNext && !publicTitles.presenterNext) - showPublicNext = false; + if (!titlesPublic.titleNext && !titlesPublic.subtitleNext && !titlesPublic.presenterNext) showPublicNext = false; const publicTitleManager = { - ...publicTitles, + ...titlesPublic, showNow: showPublicNow, showNext: showPublicNext, }; @@ -110,7 +73,7 @@ const withSocket = (Component) => { // get clock string const TimeManagerType = { ...timer, - finished: playback === 'play' && timer.current < 0 && timer.startedAt, + finished: playback === Playback.Play && (timer.current ?? 0) < 0 && timer.startedAt, playback, }; @@ -119,13 +82,12 @@ const withSocket = (Component) => { return null; } - Component.displayName = 'ComponentWithData'; return ( { viewSettings={viewSettings} nextId={nextId} general={genData} - onAir={messageControl.onAir} + onAir={onAir} /> ); }; }; -export default withSocket; +export default withData; diff --git a/apps/client/src/features/viewers/countdown/Countdown.tsx b/apps/client/src/features/viewers/countdown/Countdown.tsx index c7d457d08..ad250f0ca 100644 --- a/apps/client/src/features/viewers/countdown/Countdown.tsx +++ b/apps/client/src/features/viewers/countdown/Countdown.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useAtom } from 'jotai'; -import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from 'ontime-types'; +import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types'; import PropTypes from 'prop-types'; import { overrideStylesURL } from '../../../common/api/apiConstants'; @@ -91,7 +91,7 @@ export default function Countdown(props) { return null; } - const standby = time.playback !== 'play' && selectedId === follow?.id; + const standby = time.playback !== Playback.Play && selectedId === follow?.id; const isRunningFinished = time.finished && runningMessage === TimerMessage.running; const isSelected = runningMessage === TimerMessage.running; const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : ''; diff --git a/apps/client/src/features/viewers/countdown/countdown.helpers.ts b/apps/client/src/features/viewers/countdown/countdown.helpers.ts index a39e193f8..ebdd81e8f 100644 --- a/apps/client/src/features/viewers/countdown/countdown.helpers.ts +++ b/apps/client/src/features/viewers/countdown/countdown.helpers.ts @@ -1,4 +1,4 @@ -import { OntimeEvent } from 'ontime-types'; +import { OntimeEvent, Playback } from 'ontime-types'; import { TimeManagerType } from '../../../common/models/TimeManager.type'; @@ -25,7 +25,7 @@ export const fetchTimerData = (time: TimeManagerType, follow: OntimeEvent, selec if (selectedId === follow.id) { // check that is not running - message = time.playback === 'pause' ? TimerMessage.waiting : TimerMessage.running; + message = time.playback === Playback.Pause ? TimerMessage.waiting : TimerMessage.running; timer = time.current ?? 0; } else if (time.clock < follow.timeStart) { diff --git a/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx b/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx index 555b3a0c8..69eb3b2fb 100644 --- a/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx +++ b/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useAtom } from 'jotai'; -import { EventData, Message, TimerType, ViewSettings } from 'ontime-types'; +import { EventData, Message, Playback, TimerType, ViewSettings } from 'ontime-types'; import { overrideStylesURL } from '../../../common/api/apiConstants'; import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings'; @@ -127,7 +127,7 @@ export default function MinimalTimer(props: MinimalTimerProps) { userOptions.hideEndMessage = Boolean(hideEndMessage); const showOverlay = pres.text !== '' && pres.visible; - const isPlaying = time.playback !== 'pause'; + const isPlaying = time.playback !== Playback.Pause; const isNegative = (time.current ?? 0) < 0 && time.timerType !== TimerType.Clock && time.timerType !== TimerType.CountUp; const showEndMessage = time.current < 0 && general.endMessage && !hideEndMessage; diff --git a/apps/client/src/features/viewers/studio/StudioClock.jsx b/apps/client/src/features/viewers/studio/StudioClock.jsx index fc64b2c9c..35e323cf1 100644 --- a/apps/client/src/features/viewers/studio/StudioClock.jsx +++ b/apps/client/src/features/viewers/studio/StudioClock.jsx @@ -10,9 +10,10 @@ import useFitText from '../../../common/hooks/useFitText'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { formatDisplay } from '../../../common/utils/dateConfig'; import { formatEventList, getEventsWithDelay, trimEventlist } from '../../../common/utils/eventsManager'; -import { formatTime, stringFromMillis } from '../../../common/utils/time'; +import { formatTime } from '../../../common/utils/time'; import './StudioClock.scss'; +import { millisToString } from 'ontime-utils'; const formatOptions = { showSeconds: false, @@ -68,7 +69,7 @@ export default function StudioClock(props) { }, [backstageEvents, nextId, selectedId]); const clock = formatTime(time.clock, formatOptions); - const [, , secondsNow] = stringFromMillis(time.clock).split(':'); + const [, , secondsNow] = millisToString(time.clock).split(':'); const isNegative = (time.current ?? 0) < 0; return ( diff --git a/apps/client/src/features/viewers/timer/Timer.tsx b/apps/client/src/features/viewers/timer/Timer.tsx index 51e909d9a..94de478eb 100644 --- a/apps/client/src/features/viewers/timer/Timer.tsx +++ b/apps/client/src/features/viewers/timer/Timer.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { useAtom } from 'jotai'; -import { TimerType } from 'ontime-types'; +import { Playback, TimerType } from 'ontime-types'; import PropTypes from 'prop-types'; import { overrideStylesURL } from '../../../common/api/apiConstants'; @@ -61,12 +61,12 @@ export default function Timer(props) { const clock = formatTime(time.clock, formatOptions); const showOverlay = pres.text !== '' && pres.visible; - const isPlaying = time.playback !== 'pause'; + const isPlaying = time.playback !== Playback.Pause; const isNegative = (time.current ?? 0) < 0 && time.timerType !== TimerType.Clock && time.timerType !== TimerType.CountUp; const showEndMessage = time.current < 0 && general.endMessage; - const showProgress = time.playback !== 'stop'; + const showProgress = time.playback !== Playback.Stop; const showFinished = time.finished && (time.timerType !== TimerType.Clock || showEndMessage); const showClock = time.timerType !== TimerType.Clock; const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`; diff --git a/apps/server/package.json b/apps/server/package.json index 3192b820e..bd9c088ec 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -21,12 +21,13 @@ "ontime-utils": "workspace:*", "passport": "^0.6.0", "passport-local": "~1.0.0", - "socket.io": "^4.5.4" + "ws": "^8.12.1" }, "devDependencies": { "@types/express": "^4.17.17", "@types/node": "^16.11.7", "@types/node-osc": "^6.0.0", + "@types/websocket": "^1.0.5", "@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/parser": "^5.48.1", "esbuild": "^0.17.5", diff --git a/apps/server/src/adapters/IAdapter.ts b/apps/server/src/adapters/IAdapter.ts new file mode 100644 index 000000000..2b58f4edf --- /dev/null +++ b/apps/server/src/adapters/IAdapter.ts @@ -0,0 +1,3 @@ +export interface IAdapter { + shutdown: () => void; +} diff --git a/apps/server/src/adapters/OscAdapter.ts b/apps/server/src/adapters/OscAdapter.ts new file mode 100644 index 000000000..0d9688ec5 --- /dev/null +++ b/apps/server/src/adapters/OscAdapter.ts @@ -0,0 +1,54 @@ +import { Server } from 'node-osc'; +import { OSCSettings } from 'ontime-types'; + +import { IAdapter } from './IAdapter.js'; +import { dispatchFromAdapter } from '../controllers/integrationController.js'; +import { logger } from '../classes/Logger.js'; + +export class OscServer implements IAdapter { + private osc: Server; + + constructor(config: OSCSettings) { + this.osc = new Server(config.portIn, '0.0.0.0'); + + this.osc.on('error', console.error); + + this.osc.on('message', (msg) => { + // message should look like /ontime/{path} {args} where + // ontime: fixed message for app + // path: command to be called + // args: extra data, only used on some API entries (delay, goto) + + // split message + const [, address, path] = msg[0].split('/'); + const args = msg[1]; + + // get first part before (ontime) + if (address !== 'ontime') { + logger.error('RX', `OSC IN: OSC messages to ontime must start with /ontime/, received: ${msg}`); + return; + } + + // get second part (command) + if (!path) { + logger.error('RX', 'OSC IN: No path found'); + return; + } + + try { + const reply = dispatchFromAdapter(path, args, 'osc'); + if (reply) { + const { topic, payload } = reply; + this.osc.emit(topic, payload); + } + } catch (error) { + logger.error('RX', `OSC IN: ${error}`); + } + }); + } + + shutdown() { + console.log('Shutting down OSC Server'); + this.osc?.close(); + } +} diff --git a/apps/server/src/adapters/WebsocketAdapter.ts b/apps/server/src/adapters/WebsocketAdapter.ts new file mode 100644 index 000000000..04c053171 --- /dev/null +++ b/apps/server/src/adapters/WebsocketAdapter.ts @@ -0,0 +1,124 @@ +/** + * DESIGN BY CONTRACT + * =================== + * All websocket calls are expected to follow the defined format, + * otherwise they will be ignored by Ontime server + * + * Messages should be in JSON format with two top level objects + * { + * type: ... + * payload: ... + * } + * + * Type: describes the action to be performed as enumerated in the API design + * Payload: adds necessary payload for the request to be completed + */ + +import { WebSocket, WebSocketServer } from 'ws'; + +import getRandomName from '../utils/getRandomName.js'; +import { IAdapter } from './IAdapter.js'; +import { eventStore } from '../stores/EventStore.js'; +import { dispatchFromAdapter } from '../controllers/integrationController.js'; +import { logger } from '../classes/Logger.js'; + +let instance; + +export class SocketServer implements IAdapter { + private readonly MAX_PAYLOAD = 1024 * 256; // 256Kb + + private wss: WebSocketServer | null; + private clientIds: Set; + + constructor() { + if (instance) { + throw new Error('There can be only one'); + } + + // eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton + instance = this; + this.clientIds = new Set(); + this.wss = null; + } + + init(server) { + this.wss = new WebSocketServer({ path: '/ws', server }); + + this.wss.on('connection', (ws) => { + const clientId = getRandomName(); + this.clientIds.add(clientId); + logger.info('RX', `${this.wss.clients.size} Connections with new: ${clientId}`); + + // send store payload on connect + ws.send( + JSON.stringify({ + type: 'ontime', + payload: eventStore.poll(), + }), + ); + + ws.on('error', console.error); + + ws.on('close', () => { + logger.info('RX', `${this.wss.clients.size} Connections with disconnected: ${clientId}`); + this.clientIds.delete(clientId); + }); + + ws.on('message', (data) => { + if (data.length > this.MAX_PAYLOAD) { + ws.close(); + } + + // TODO: protocol specific stuff should be handled here + // eg: rename-client + // socket.on('rename-client', (newName) => { + // if (newName) { + // const previousName = this._clientNames[socket.id]; + // this._clientNames[socket.id] = newName; + // this.info('CLIENT', `Client ${previousName} renamed to ${newName}`); + // } + // }); + + try { + const message = JSON.parse(data); + const { type, payload } = message; + + if (type === 'hello') { + ws.send('hi'); + } + + if (type === 'ontime-log') { + console.log('attempted adding to log'); + } + + try { + const reply = dispatchFromAdapter(type, payload, 'ws'); + if (reply) { + const { topic, payload } = reply; + ws.send(topic, payload); + } + } catch (error) { + logger.error('RX', `WS IN: ${error}`); + } + } catch (_) { + // we ignore unknown + } + }); + }); + } + + // message is any serializable value + send(message: any) { + this.wss?.clients.forEach((client) => { + if (client !== this.wss && client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify(message)); + } + }); + } + + shutdown() { + this.wss?.close(); + } +} + +export const socket = new SocketServer(); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index d97cdb6af..6900f4c16 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -6,8 +6,7 @@ import cors from 'cors'; // import utils import { join, resolve } from 'path'; -import { initiateOSC, shutdownOSCServer } from './controllers/OscController.js'; -import { initSentry } from './modules/sentry.js'; +import { initSentry, reportSentryException } from './modules/sentry.js'; import { currentDirectory, environment, isProduction, resolvedPath } from './setup.js'; import { ONTIME_VERSION } from './ONTIME_VERSION.js'; import { OSCSettings } from 'ontime-types'; @@ -18,13 +17,18 @@ import { router as eventDataRouter } from './routes/eventDataRouter.js'; import { router as ontimeRouter } from './routes/ontimeRouter.js'; import { router as playbackRouter } from './routes/playbackRouter.js'; -// Services +// Import adapters +import { OscServer } from './adapters/OscAdapter.js'; +import { socket } from './adapters/WebsocketAdapter.js'; import { DataProvider } from './classes/data-provider/DataProvider.js'; -import { socketProvider } from './classes/socket/SocketController.js'; -import { eventTimer } from './services/TimerService.js'; import { dbLoadingProcess } from './modules/loadDb.js'; + +// Services +import { eventTimer } from './services/TimerService.js'; import { integrationService } from './services/integration-service/IntegrationService.js'; import { OscIntegration } from './services/integration-service/OscIntegration.js'; +import { logger } from './classes/Logger.js'; +import { eventLoader } from './classes/event-loader/EventLoader.js'; console.log(`Starting Ontime version ${ONTIME_VERSION}`); @@ -33,10 +37,7 @@ if (!isProduction) { console.log(`Ontime directory at ${currentDirectory} `); } -initSentry(environment); - -// import socket provider -const socketServer = socketProvider; +initSentry(isProduction); // Create express APP const app = express(); @@ -100,6 +101,9 @@ enum OntimeStartOrder { } let step = OntimeStartOrder.InitDB; +let expressServer = null; +let oscServer = null; + const checkStart = (currentState: OntimeStartOrder) => { if (step !== currentState) { step = OntimeStartOrder.Error; @@ -116,9 +120,6 @@ export const startDb = async () => { await dbLoadingProcess; }; -// create HTTP server -const expressServer = http.createServer(app); - /** * Starts servers * @return {Promise} @@ -128,11 +129,13 @@ export const startServer = async () => { const serverPort = 4001; // hardcoded for now const returnMessage = `Ontime is listening on port ${serverPort}`; - expressServer.listen(serverPort, '0.0.0.0'); - socketServer.initServer(expressServer); - socketServer.info('SERVER', returnMessage); - socketServer.startListener(); + expressServer = http.createServer(app); + + socket.init(expressServer); + eventLoader.init(); + + expressServer.listen(serverPort, '0.0.0.0'); return returnMessage; }; @@ -149,7 +152,7 @@ export const startOSCServer = async (overrideConfig = null) => { const { osc } = DataProvider.getData(); if (!osc.enabledIn) { - socketServer.info('RX', 'OSC Input Disabled'); + logger.info('RX', 'OSC Input Disabled'); return; } @@ -160,8 +163,8 @@ export const startOSCServer = async (overrideConfig = null) => { }; // Start OSC Server - socketServer.info('RX', `Starting OSC Server on port: ${oscSettings.portIn}`); - initiateOSC(oscSettings); + logger.info('RX', `Starting OSC Server on port: ${oscSettings.portIn}`); + oscServer = new OscServer(oscSettings); }; /** @@ -178,7 +181,7 @@ export const startIntegrations = async (config?: { osc: OSCSettings }) => { const oscIntegration = new OscIntegration(); const { success, message } = oscIntegration.init(osc); - socketServer.info('RX', message); + logger.info('RX', message); if (success) { integrationService.register(oscIntegration); @@ -193,25 +196,26 @@ export const startIntegrations = async (config?: { osc: OSCSettings }) => { export const shutdown = async (exitCode = 0) => { console.log(`Ontime shutting down with code ${exitCode}`); - expressServer.close(); - shutdownOSCServer(); + expressServer?.close(); + oscServer?.shutdown(); eventTimer.shutdown(); - socketServer.shutdown(); integrationService.shutdown(); + logger.shutdown(); + socket.shutdown(); process.exit(exitCode); }; process.on('exit', (code) => console.log(`Ontime exited with code: ${code}`)); -process.on('unhandledRejection', async (error, promise) => { - console.error(error, 'Error: unhandled rejection', promise); - socketServer.error('SERVER', 'Error: unhandled rejection'); +process.on('unhandledRejection', async (error) => { + reportSentryException(error); + logger.error('SERVER', 'Error: unhandled rejection'); await shutdown(1); }); -process.on('uncaughtException', async (error, promise) => { - console.error(error, 'Error: uncaught exception', promise); - socketServer.error('SERVER', 'Error: uncaught exception'); +process.on('uncaughtException', async (error) => { + reportSentryException(error); + logger.error('SERVER', 'Error: uncaught exception'); await shutdown(1); }); diff --git a/apps/server/src/classes/Logger.ts b/apps/server/src/classes/Logger.ts new file mode 100644 index 000000000..d4318a127 --- /dev/null +++ b/apps/server/src/classes/Logger.ts @@ -0,0 +1,104 @@ +import { Log, LogLevel } from 'ontime-types'; +import { generateId, millisToString } from 'ontime-utils'; + +import { clock } from '../services/Clock.js'; +import { isProduction } from '../setup.js'; +import { socket } from '../adapters/WebsocketAdapter.js'; + +class Logger { + private queue: Log[]; + + constructor() { + this.queue = []; + } + + /** + * Enabling setup logger after init + */ + init() { + this.queue.forEach((log) => { + this._push(log); + }); + this.queue = []; + } + + private addToQueue(log: Log) { + this.queue.push(log); + if (this.queue.length > 100) { + this.queue.pop(); + } + } + + /** + * Internal safe push method, adds log to queue if callback not available + * @param log + */ + private _push(log: Log) { + if (!isProduction) { + console.log(`[${log.level}] \t ${log.origin} \t ${log.text}`); + } + + try { + socket.send({ + type: 'ontime-log', + payload: log, + }); + } catch (_e) { + this.addToQueue(log); + } + } + + /** + * Emits logging message + * @param level + * @param origin + * @param text + */ + emit(level, origin: string, text: string) { + const log = { + id: generateId(), + level, + origin, + text, + time: millisToString(clock.getSystemTime() || 0), + }; + this._push(log); + } + + /** + * Utility to emit logging message of type INFO + * @param origin + * @param text + */ + info(origin: string, text: string) { + this.emit(LogLevel.Info, origin, text); + } + + /** + * Utility to emit logging message of type WARN + * @param origin + * @param text + */ + warning(origin: string, text: string) { + this.emit(LogLevel.Warn, origin, text); + } + + /** + * Utility to emit logging message of type ERROR + * @param origin + * @param text + */ + error(origin: string, text: string) { + this.emit(LogLevel.Error, origin, text); + } + + /** + * Shutdown logger + */ + shutdown() { + console.log('Shutting down logger'); + this.queue = []; + } +} + +export const logger = new Logger(); diff --git a/apps/server/src/classes/event-loader/EventLoader.ts b/apps/server/src/classes/event-loader/EventLoader.ts index 1f278be38..7660ed28d 100644 --- a/apps/server/src/classes/event-loader/EventLoader.ts +++ b/apps/server/src/classes/event-loader/EventLoader.ts @@ -1,31 +1,17 @@ +import { OntimeEvent, TitleBlock, Loaded } from 'ontime-types'; + import { DataProvider } from '../data-provider/DataProvider.js'; import { getRollTimers } from '../../services/rollUtils.js'; import { eventStore } from '../../stores/EventStore.js'; let instance; -type TitleBlock = { - titleNow: string | null; - subtitleNow: string | null; - presenterNow: string | null; - noteNow: string | null; - titleNext: string | null; - subtitleNext: string | null; - presenterNext: string | null; - noteNext: string | null; -}; - /** * Manages business logic around loading events */ export class EventLoader { - loadedEvent: object | null; - numEvents: number | null; - selectedEventIndex: number | null; - selectedEventId: string | null; - selectedPublicEventId: string | null; - nextEventId: string | null; - nextPublicEventId: string | null; + loadedEvent: OntimeEvent | null; + loaded: Loaded; titles: TitleBlock; titlesPublic: TitleBlock; @@ -36,7 +22,10 @@ export class EventLoader { // eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton instance = this; - this.reset(false); + } + + init() { + this.reset(); this.loadedEvent = null; } @@ -122,16 +111,16 @@ export class EventLoader { */ findPrevious() { const timedEvents = EventLoader.getPlayableEvents(); - if (timedEvents === null || !timedEvents.length || this.selectedEventIndex === 0) { + if (timedEvents === null || !timedEvents.length || this.loaded.selectedEventIndex === 0) { return null; } // if there is no event running, go to first - if (this.selectedEventIndex === null) { + if (this.loaded.selectedEventIndex === null) { return timedEvents[0]; } - const newIndex = this.selectedEventIndex - 1; + const newIndex = this.loaded.selectedEventIndex - 1; return timedEvents?.[newIndex]; } @@ -141,15 +130,15 @@ export class EventLoader { */ findNext() { const timedEvents = EventLoader.getPlayableEvents(); - if (timedEvents === null || !timedEvents.length || this.selectedEventIndex === this.numEvents - 1) { + if (timedEvents === null || !timedEvents.length || this.loaded.selectedEventIndex === this.loaded.numEvents - 1) { return null; } // if there is no event running, go to first - if (this.selectedEventIndex === null) { + if (this.loaded.selectedEventIndex === null) { return timedEvents[0]; } - const newIndex = this.selectedEventIndex + 1; + const newIndex = this.loaded.selectedEventIndex + 1; return timedEvents?.[newIndex]; } @@ -167,9 +156,9 @@ export class EventLoader { getRollTimers(timedEvents, timeNow); this.loadedEvent = currentEvent; - this.selectedEventIndex = nowIndex; - this.selectedEventId = currentEvent?.id || null; - this.numEvents = timedEvents.length; + this.loaded.selectedEventIndex = nowIndex; + this.loaded.selectedEventId = currentEvent?.id || null; + this.loaded.numEvents = timedEvents.length; // titles this._loadThisTitles(currentEvent, 'now-private'); @@ -187,28 +176,33 @@ export class EventLoader { getLoaded() { return { loadedEvent: this.loadedEvent, - selectedEventIndex: this.selectedEventIndex, - selectedEventId: this.selectedEventId, - selectedPublicEventId: this.selectedPublicEventId, - nextEventId: this.nextEventId, - nextPublicEventId: this.nextPublicEventId, - numEvents: this.numEvents, + loaded: this.loaded, titles: this.titles, titlesPublic: this.titlesPublic, }; } + /** + * Forces event loader to update the event count + */ + updateNumEvents() { + this.loaded.numEvents = EventLoader.getPlayableEvents().length; + eventStore.set('loaded', this.loaded); + } + /** * Resets instance state */ - reset(emit?: boolean) { + reset(emit = true) { this.loadedEvent = null; - this.selectedEventIndex = null; - this.selectedEventId = null; - this.selectedPublicEventId = null; - this.nextEventId = null; - this.nextPublicEventId = null; - this.numEvents = null; + this.loaded = { + selectedEventIndex: null, + selectedEventId: null, + selectedPublicEventId: null, + nextEventId: null, + nextPublicEventId: null, + numEvents: EventLoader.getPlayableEvents().length, + }; this.titles = { titleNow: null, subtitleNow: null, @@ -250,9 +244,9 @@ export class EventLoader { // we know some stuff now this.loadedEvent = event; - this.selectedEventIndex = eventIndex; - this.selectedEventId = event.id; - this.numEvents = timedEvents.length; + this.loaded.selectedEventIndex = eventIndex; + this.loaded.selectedEventId = event.id; + this.loaded.numEvents = timedEvents.length; // this.nextEventId = playableEvents[eventIndex + 1].id; this._loadTitlesNow(event, playableEvents); this._loadTitlesNext(playableEvents); @@ -266,6 +260,7 @@ export class EventLoader { * Handle side effects from event loading */ private _loadEvent() { + eventStore.set('loaded', this.loaded); eventStore.set('titles', this.titles); eventStore.set('titlesPublic', this.titlesPublic); } @@ -288,13 +283,13 @@ export class EventLoader { this.titlesPublic.titleNow = null; this.titlesPublic.subtitleNow = null; this.titlesPublic.presenterNow = null; - this.selectedPublicEventId = null; + this.loaded.selectedPublicEventId = null; // if there is nothing before, return - if (this.selectedEventIndex === 0) return; + if (this.loaded.selectedEventIndex === 0) return; // iterate backwards to find it - for (let i = this.selectedEventIndex; i >= 0; i--) { + for (let i = this.loaded.selectedEventIndex; i >= 0; i--) { if (rundown[i].isPublic) { this._loadThisTitles(rundown[i], 'now-public'); break; @@ -309,27 +304,27 @@ export class EventLoader { */ private _loadTitlesNext(rundown) { // maybe there is nothing to load - if (this.selectedEventIndex === null) return; + if (this.loaded.selectedEventIndex === null) return; // assume there is no next event this.titles.titleNext = null; this.titles.subtitleNext = null; this.titles.presenterNext = null; this.titles.noteNext = null; - this.nextEventId = null; + this.loaded.nextEventId = null; this.titlesPublic.titleNext = null; this.titlesPublic.subtitleNext = null; this.titlesPublic.presenterNext = null; - this.nextPublicEventId = null; + this.loaded.nextPublicEventId = null; const numEvents = rundown.length; - if (this.selectedEventIndex < numEvents - 1) { + if (this.loaded.selectedEventIndex < numEvents - 1) { let nextPublic = false; let nextPrivate = false; - for (let i = this.selectedEventIndex + 1; i < numEvents; i++) { + for (let i = this.loaded.selectedEventIndex + 1; i < numEvents; i++) { // if we have not set private if (!nextPrivate) { this._loadThisTitles(rundown[i], 'next-private'); @@ -367,14 +362,14 @@ export class EventLoader { this.titlesPublic.subtitleNow = event.subtitle; this.titlesPublic.presenterNow = event.presenter; this.titlesPublic.noteNow = event.note; - this.selectedPublicEventId = event.id; + this.loaded.selectedPublicEventId = event.id; // private this.titles.titleNow = event.title; this.titles.subtitleNow = event.subtitle; this.titles.presenterNow = event.presenter; this.titles.noteNow = event.note; - this.selectedEventId = event.id; + this.loaded.selectedEventId = event.id; break; case 'now-public': @@ -382,7 +377,7 @@ export class EventLoader { this.titlesPublic.subtitleNow = event.subtitle; this.titlesPublic.presenterNow = event.presenter; this.titlesPublic.noteNow = event.note; - this.selectedPublicEventId = event.id; + this.loaded.selectedPublicEventId = event.id; break; case 'now-private': @@ -390,7 +385,7 @@ export class EventLoader { this.titles.subtitleNow = event.subtitle; this.titles.presenterNow = event.presenter; this.titles.noteNow = event.note; - this.selectedEventId = event.id; + this.loaded.selectedEventId = event.id; break; // next, load to both public and private @@ -400,14 +395,14 @@ export class EventLoader { this.titlesPublic.subtitleNext = event.subtitle; this.titlesPublic.presenterNext = event.presenter; this.titlesPublic.noteNext = event.note; - this.nextPublicEventId = event.id; + this.loaded.nextPublicEventId = event.id; // private this.titles.titleNext = event.title; this.titles.subtitleNext = event.subtitle; this.titles.presenterNext = event.presenter; this.titles.noteNext = event.note; - this.nextEventId = event.id; + this.loaded.nextEventId = event.id; break; case 'next-public': @@ -415,7 +410,7 @@ export class EventLoader { this.titlesPublic.subtitleNext = event.subtitle; this.titlesPublic.presenterNext = event.presenter; this.titlesPublic.noteNext = event.note; - this.nextPublicEventId = event.id; + this.loaded.nextPublicEventId = event.id; break; case 'next-private': @@ -423,7 +418,7 @@ export class EventLoader { this.titles.subtitleNext = event.subtitle; this.titles.presenterNext = event.presenter; this.titles.noteNext = event.note; - this.nextEventId = event.id; + this.loaded.nextEventId = event.id; break; default: diff --git a/apps/server/src/classes/socket/SocketController.js b/apps/server/src/classes/socket/SocketController.js deleted file mode 100644 index 2644a6841..000000000 --- a/apps/server/src/classes/socket/SocketController.js +++ /dev/null @@ -1,446 +0,0 @@ -import { Server } from 'socket.io'; -import { generateId } from 'ontime-utils'; - -import getRandomName from '../../utils/getRandomName.js'; -import { stringFromMillis } from '../../utils/time.js'; -import { messageManager } from '../message-manager/MessageManager.js'; -import { PlaybackService } from '../../services/PlaybackService.js'; - -import { eventTimer } from '../../services/TimerService.js'; -import { EventLoader, eventLoader } from '../event-loader/EventLoader.js'; -import { clock } from '../../services/Clock.js'; - -class SocketController { - constructor() { - this.numClients = 0; - this.messageStack = []; - this._MAX_MESSAGES = 100; - this._clientNames = {}; - this.socket = null; - } - - initServer(httpServer) { - this.socket = new Server(httpServer, { - cors: { - origin: '*', - methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', - preflightContinue: false, - optionsSuccessStatus: 204, - }, - }); - } - - startListener() { - this._socketMessageHandler(); - } - - shutdown() { - this.info('SERVER', 'Shutting down ontime'); - if (this.socket) { - this.info('TX', '... Closing socket server'); - this.socket.close(); - } - } - - /** - * Handle socket io connections - * @private - */ - _socketMessageHandler() { - this.socket.on('connection', (socket) => { - /*******************************/ - /*** HANDLE NEW CONNECTION ***/ - /*** --------------------- ***/ - /*******************************/ - // keep track of connections - this.numClients++; - this._clientNames[socket.id] = getRandomName(); - const message = `${this.numClients} Clients with new connection: ${this._clientNames[socket.id]}`; - this.info('CLIENT', message); - - // Todo: review in favour of features - // send state - socket.emit('timer', eventTimer.timer); - socket.emit('playback', eventTimer.playback); - socket.emit('selected', { - id: eventLoader.selectedEventId, - index: eventLoader.selectedEventIndex, - total: eventLoader.numEvents, - }); - socket.emit('next-id', eventLoader.nextEventId); - socket.emit('publicselected-id', eventLoader.selectedPublicEventId); - socket.emit('publicnext-id', eventLoader.nextPublicEventId); - - /** - * @description handle disconnecting a user - */ - socket.on('disconnect', () => { - this.numClients--; - const message = `${this.numClients} Clients with disconnection: ${this._clientNames[socket.id]}`; - delete this._clientNames[socket.id]; - this.info('CLIENT', message); - }); - - /** - * @description utility for renaming a user - */ - socket.on('rename-client', (newName) => { - if (newName) { - const previousName = this._clientNames[socket.id]; - this._clientNames[socket.id] = newName; - this.info('CLIENT', `Client ${previousName} renamed to ${newName}`); - } - }); - - /***************************************/ - /*** TIMER STATE GETTERS / SETTERS ***/ - /*** ------- WEBSOCKET API ------- ***/ - /*** ----------------------------- ***/ - /***************************************/ - - /*******************************************/ - socket.on('ontime-test', () => { - socket.emit('hello', socket.id); - }); - - socket.on('set-start', () => { - PlaybackService.start(); - }); - - socket.on('set-startid', (data) => { - if (data) { - PlaybackService.startById(data); - } - }); - - socket.on('set-startindex', (data) => { - const eventIndex = Number(data); - if (!isNaN(eventIndex)) { - PlaybackService.startByIndex(eventIndex); - } - }); - - socket.on('set-loadid', (data) => { - if (data) { - PlaybackService.loadById(data); - } - }); - - socket.on('set-loadindex', (data) => { - const eventIndex = Number(data); - if (!isNaN(eventIndex)) { - PlaybackService.loadByIndex(eventIndex - 1); - } - }); - - socket.on('set-pause', () => { - PlaybackService.pause(); - }); - - socket.on('set-stop', () => { - PlaybackService.stop(); - }); - - socket.on('set-reload', () => { - PlaybackService.reload(); - }); - - socket.on('set-previous', () => { - PlaybackService.loadPrevious(); - }); - - socket.on('set-next', () => { - PlaybackService.loadNext(); - }); - - socket.on('set-roll', () => { - PlaybackService.roll(); - }); - - socket.on('set-delay', (data) => { - const delayTime = Number(data); - if (!isNaN(delayTime)) { - PlaybackService.setDelay(delayTime); - } - }); - - /*******************************************/ - // general playback state, useful for external sync - // Todo: add delayed value (will come from rundownService) - socket.on('ontime-poll', () => { - const timerPoll = eventTimer.timer; - const isDelayed = false; - const colour = ''; - socket.emit('ontime-poll', { isDelayed, colour, ...timerPoll }); - }); - - /*******************************************/ - socket.on('get-playback', () => { - socket.emit('playback', eventTimer.playback); - }); - - socket.on('get-onAir', () => { - socket.emit('onAir', messageManager.onAir); - }); - - /*******************************************/ - socket.on('get-selected', () => { - socket.emit('selected', { - id: eventLoader.selectedEventId, - index: eventLoader.selectedEventIndex, - total: eventLoader.numEvents, - }); - }); - - socket.on('get-titles', () => { - socket.emit('titles', eventLoader.titles); - }); - - socket.on('get-publictitles', () => { - socket.emit('publictitles', eventLoader.titlesPublic); - }); - - /***********************************/ - /*** MESSAGE GETTERS / SETTERS ***/ - /*** ------------------------- ***/ - /***********************************/ - - // On Air - socket.on('set-onAir', (data) => { - if (typeof data === 'boolean') { - try { - const featureData = messageManager.setOnAir(data); - this.info('PLAYBACK', featureData.onAir ? 'Going On Air' : 'Going Off Air'); - } catch (error) { - this.error('RX', `Failed to parse message ${data} : ${error}`); - } - } - }); - - // Presenter message - socket.on('set-timer-message-text', (data) => { - if (typeof data !== 'string') { - return; - } - messageManager.setTimerText(data); - }); - - socket.on('set-timer-message-visible', (data) => { - if (typeof data !== 'boolean') { - return; - } - messageManager.setTimerVisibility(data); - }); - - /*******************************************/ - // Public message - socket.on('set-public-message-text', (data) => { - if (typeof data !== 'string') { - return; - } - messageManager.setPublicText(data); - }); - - socket.on('set-public-message-visible', (data) => { - if (typeof data !== 'boolean') { - return; - } - messageManager.setPublicVisibility(data); - }); - - /*******************************************/ - // Lower third message - socket.on('set-lower-message-text', (data) => { - if (typeof data !== 'string') { - return; - } - messageManager.setLowerText(data); - }); - - socket.on('set-lower-message-visible', (data) => { - if (typeof data !== 'boolean') { - return; - } - messageManager.setLowerVisibility(data); - }); - - /* MOLECULAR ENDPOINTS - * ===================== - * 1. RUNDOWN - * 2. MESSAGE CONTROL - * 3. PLAYBACK CONTROL - * 4. INFO - * 5. CUE SHEET - * 6. TIMER OBJECT - * */ - - // 1. RUNDOWN - socket.on('get-feat-rundown', () => { - this.broadcastFeatureRundown(); - }); - - // 2. MESSAGE CONTROL - socket.on('get-feat-messagecontrol', () => { - this.broadcastFeatureMessageControl(); - }); - - // 3. PLAYBACK CONTROL - socket.on('get-feat-playbackcontrol', () => { - this.broadcastFeaturePlaybackControl(); - }); - - // 4. INFO - socket.on('get-feat-info', () => { - this.broadcastFeatureInfo(); - }); - - // 5. CUE SHEET - socket.on('get-feat-cuesheet', () => { - this.broadcastFeatureCuesheet(); - }); - - // 6. TIMER - socket.on('get-timer', () => { - // TODO: Not ideal workaround - socket.emit('timer', eventTimer.timer); - }); - }); - } - - send(topic, payload) { - this.socket?.emit(topic, payload); - } - - /****************************************************************************/ - - /** - * Logger logic - * ------------- - */ - - /** - * Utility method, sends message and pushes into stack - * @param {string} level - * @param {string} origin - * @param {string} text - */ - _push(level, origin, text) { - const logMessage = { - id: generateId(), - level, - origin, - text, - time: stringFromMillis(clock.getSystemTime() || 0), - }; - - this.messageStack.unshift(logMessage); - this.socket?.emit('logger', logMessage); - - if (process.env.NODE_ENV !== 'production') { - console.log(`[${logMessage.level}] \t ${logMessage.origin} \t ${logMessage.text}`); - } - - if (this.messageStack.length > this._MAX_MESSAGES) { - this.messageStack.pop(); - } - } - - /** - * Broadcast data for Event List feature - */ - broadcastFeatureRundown() { - const featureData = { - selectedEventId: eventLoader.selectedEventId, - nextEventId: eventLoader.nextEventId, - playback: eventTimer.playback, - }; - this.send('feat-rundown', featureData); - } - - /** - * Broadcast data for Message Control feature - */ - broadcastFeatureMessageControl() { - const featureData = messageManager.getAll(); - this.send('feat-messagecontrol', featureData); - } - - /** - * Broadcast data for Playback Control feature - */ - broadcastFeaturePlaybackControl() { - const featureData = { - playback: eventTimer.playback, - selectedEventId: eventLoader.selectedEventId, - numEvents: EventLoader.getNumEvents(), - }; - this.send('feat-playbackcontrol', featureData); - } - - /** - * Broadcast data for Info feature - */ - broadcastFeatureInfo() { - const featureData = { - titles: eventLoader.titles, - playback: eventTimer.playback, - selectedEventId: eventLoader.selectedEventId, - selectedEventIndex: eventLoader.selectedEventIndex, - numEvents: EventLoader.getNumEvents(), - }; - this.send('feat-info', featureData); - } - - /** - * Broadcast data for Cuesheet feature - */ - broadcastFeatureCuesheet() { - const featureData = { - playback: eventTimer.playback, - selectedEventId: eventLoader.selectedEventId, - selectedEventIndex: eventLoader.selectedEventIndex, - numEvents: EventLoader.getNumEvents(), - titleNow: eventLoader.titles.titleNow, - }; - this.send('feat-cuesheet', featureData); - } - - // TODO: ouch, services should update the store - // make middleware to maintain the features OR remove the feature endpoints - broadcastState() { - this.broadcastFeatureRundown(); - this.broadcastFeatureMessageControl(); - this.broadcastFeaturePlaybackControl(); - this.broadcastFeatureInfo(); - this.broadcastFeatureCuesheet(); - } - - /** - * Sends a message with level LOG - * @param {string} origin - * @param {string} text - */ - info(origin, text) { - this._push('INFO', origin, text); - } - - /** - * Sends a message with level WARN - * @param {string} origin - * @param {string} text - */ - warning(origin, text) { - this._push('WARN', origin, text); - } - - /** - * Sends a message with level ERROR - * @param {string} origin - * @param {string} text - */ - error(origin, text) { - this._push('ERROR', origin, text); - } -} - -export const socketProvider = new SocketController(); diff --git a/apps/server/src/controllers/OscController.ts b/apps/server/src/controllers/OscController.ts deleted file mode 100644 index bd2bc41eb..000000000 --- a/apps/server/src/controllers/OscController.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { Server } from 'node-osc'; -import { OSCSettings } from 'ontime-types'; - -import { PlaybackService } from '../services/PlaybackService.js'; -import { messageManager } from '../classes/message-manager/MessageManager.js'; -import { socketProvider } from '../classes/socket/SocketController.js'; - -let oscServer = null; - -/** - * @description utility function to shut down osc server - */ -export const shutdownOSCServer = () => { - if (oscServer != null) oscServer.close(); -}; - -/** - * Initialises OSC server - */ -export const initiateOSC = (config: OSCSettings) => { - oscServer = new Server(config.portIn, '0.0.0.0'); - - oscServer.on('error', console.error); - - oscServer.on('message', function (msg) { - // message should look like /ontime/{path} {args} where - // ontime: fixed message for app - // path: command to be called - // args: extra data, only used on some API entries (delay, goto) - - // split message - const [, address, path] = msg[0].split('/'); - const args = msg[1]; - - // get first part before (ontime) - if (address !== 'ontime') { - console.error('RX', `OSC IN: Message address ${address} not recognised`, msg); - return; - } - - // get second part (command) - if (!path) { - console.error('RX', 'OSC IN: No path found'); - return; - } - - switch (path.toLowerCase()) { - case 'onair': { - messageManager.setOnAir(true); - break; - } - case 'offair': { - messageManager.setOnAir(false); - break; - } - case 'play': { - PlaybackService.start(); - break; - } - case 'start': { - try { - const eventIndex = Number(args); - if (isNaN(eventIndex)) { - socketProvider.error('RX', `OSC IN: event index not recognised ${args}`); - return; - } - PlaybackService.startByIndex(eventIndex); - } catch (error) { - console.log('Error loading event: ', error); - } - break; - } - case 'startid': { - if (!args) { - socketProvider.error('RX', `OSC IN: No ID in request`); - return; - } - PlaybackService.loadById(args); - break; - } - case 'pause': { - PlaybackService.pause(); - break; - } - case 'prev': { - PlaybackService.loadPrevious(); - break; - } - case 'next': { - PlaybackService.loadNext(); - break; - } - case 'unload': - case 'stop': { - PlaybackService.stop(); - break; - } - case 'reload': { - PlaybackService.reload(); - break; - } - case 'roll': { - PlaybackService.roll(); - break; - } - case 'delay': { - try { - const delayTime = Number(args); - if (isNaN(delayTime)) { - socketProvider.error('RX', `OSC IN: delay time not recognised ${args}`); - return; - } - PlaybackService.setDelay(delayTime); - } catch (error) { - console.log('Error adding delay: ', error); - } - break; - } - case 'goto': - case 'load': { - try { - const eventIndex = Number(args); - if (isNaN(eventIndex) || eventIndex <= 0) { - socketProvider.error('RX', `OSC IN: event index not recognised or out of range ${eventIndex}`); - } else { - PlaybackService.loadByIndex(eventIndex - 1); - } - } catch (error) { - socketProvider.error('RX', `OSC IN: error calling goto ${error}`); - } - break; - } - case 'gotoid': - case 'loadid': { - if (!args) { - socketProvider.error('RX', `OSC IN: event ID not recognised: ${args}}`); - return; - } - try { - PlaybackService.loadById(args.toString().toLowerCase()); - } catch (error) { - socketProvider.error('RX', `OSC IN: error calling goto ${error}`); - } - break; - } - - case 'get-playback': { - const playback = global.timer.state; - global.timer.sendOsc('playback', playback); - break; - } - - default: { - socketProvider.warning('RX', `OSC IN: unhandled message ${path}`); - break; - } - } - }); -}; diff --git a/apps/server/src/controllers/integrationController.ts b/apps/server/src/controllers/integrationController.ts new file mode 100644 index 000000000..0ad2dc513 --- /dev/null +++ b/apps/server/src/controllers/integrationController.ts @@ -0,0 +1,188 @@ +import { messageService } from '../services/message-service/MessageService.js'; +import { PlaybackService } from '../services/PlaybackService.js'; +import { eventStore } from '../stores/EventStore.js'; + +export function dispatchFromAdapter(type: string, payload: unknown, source?: 'osc' | 'ws') { + switch (type.toLowerCase()) { + case 'test-ontime': { + return { topic: 'hello' }; + } + + case 'ontime-poll': { + return { + topic: 'poll', + payload: eventStore.poll(), + }; + } + + case 'set-onair': { + if (typeof payload !== 'undefined') { + messageService.setOnAir(Boolean(payload)); + } + break; + } + + case 'onair': { + messageService.setOnAir(true); + break; + } + + case 'offair': { + messageService.setOnAir(false); + break; + } + + case 'set-timer-message-text': { + if (typeof payload !== 'string') { + throw new Error(`Unable to parse payload: ${payload}`); + } + messageService.setTimerText(payload); + break; + } + case 'set-timer-message-visible': { + if (typeof payload === 'undefined') { + throw new Error(`Unable to parse payload: ${payload}`); + } + messageService.setTimerVisibility(Boolean(payload)); + break; + } + + case 'set-public-message-text': { + if (typeof payload !== 'string') { + throw new Error(`Unable to parse payload: ${payload}`); + } + messageService.setPublicText(payload); + break; + } + case 'set-public-message-visible': { + if (typeof payload === 'undefined') { + throw new Error(`Unable to parse payload: ${payload}`); + } + messageService.setPublicVisibility(Boolean(payload)); + break; + } + + case 'set-lower-message-text': { + if (typeof payload !== 'string') { + throw new Error(`Unable to parse payload: ${payload}`); + } + messageService.setLowerText(payload); + break; + } + case 'set-lower-message-visible': { + if (typeof payload === 'undefined') { + throw new Error(`Unable to parse payload: ${payload}`); + } + messageService.setLowerVisibility(Boolean(payload)); + break; + } + + case 'start': { + PlaybackService.start(); + break; + } + + case 'startindex': { + const eventIndex = Number(payload); + if (isNaN(eventIndex) || eventIndex <= 0) { + throw new Error(`Event index not recognised or out of range ${eventIndex}`); + } + + try { + // Indexes in frontend are 1 based + PlaybackService.startByIndex(eventIndex - 1); + } catch (error) { + throw new Error(`Error loading event:: ${error}`); + } + break; + } + + case 'startid': { + if (!payload) { + throw new Error(`Event ID not recognised: ${payload}`); + } + PlaybackService.startById(payload); + break; + } + case 'pause': { + PlaybackService.pause(); + break; + } + case 'previous': { + PlaybackService.loadPrevious(); + break; + } + case 'next': { + PlaybackService.loadNext(); + break; + } + case 'unload': + case 'stop': { + PlaybackService.stop(); + break; + } + case 'reload': { + PlaybackService.reload(); + break; + } + case 'roll': { + PlaybackService.roll(); + break; + } + case 'delay': { + const delayTime = Number(payload); + if (isNaN(delayTime)) { + throw new Error(`Delay time not recognised ${payload}`); + } + + try { + PlaybackService.setDelay(delayTime); + } catch (error) { + throw new Error(`Could not add delay: ${error}`); + } + break; + } + case 'gotoindex': + case 'loadindex': { + const eventIndex = Number(payload); + if (isNaN(eventIndex) || eventIndex <= 0) { + throw new Error(`Event index not recognised or out of range ${eventIndex}`); + } + + try { + // Indexes in frontend are 1 based + PlaybackService.loadByIndex(eventIndex - 1); + } catch (error) { + throw new Error(`Event index not recognised or out of range ${error}`); + } + break; + } + case 'gotoid': + case 'loadid': { + if (!payload) { + throw new Error(`Event ID not recognised: ${payload}`); + } + + try { + PlaybackService.loadById(payload.toString().toLowerCase()); + } catch (error) { + throw new Error(`OSC IN: error calling goto ${error}`); + } + break; + } + + case 'get-playback': { + const playback = eventStore.get('playback'); + return { topic: 'playback', payload: playback }; + } + + case 'get-timer': { + const timer = eventStore.get('timer'); + return { topic: 'timer', payload: timer }; + } + + default: { + throw new Error(`Unhandled message ${type}`); + } + } +} diff --git a/apps/server/src/controllers/playbackController.js b/apps/server/src/controllers/playbackController.js index e9f76e68e..fe71f19c1 100644 --- a/apps/server/src/controllers/playbackController.js +++ b/apps/server/src/controllers/playbackController.js @@ -1,11 +1,10 @@ -// Create controller for GET request to '/playback' -// Returns ACK message import { PlaybackService } from '../services/PlaybackService.js'; +import { eventStore } from '../stores/EventStore.js'; // Create controller for POST request to '/playback' // Returns playback state export const pbGet = async (req, res) => { - res.send({ playback: global.timer.state }); + res.send({ playback: eventStore.get('playback') }); }; // Create controller for POST request to '/playback/start' diff --git a/apps/server/src/modules/sentry.js b/apps/server/src/modules/sentry.js index 6ccfe231a..fb4a8cf43 100644 --- a/apps/server/src/modules/sentry.js +++ b/apps/server/src/modules/sentry.js @@ -2,8 +2,8 @@ import * as Sentry from '@sentry/node'; let shouldReport; -export function initSentry(environment) { - shouldReport = environment === 'production'; +export function initSentry(doReport) { + shouldReport = doReport; Sentry.init({ dsn: 'https://ceb6abdce7374857bb50b65636cbaed1@o4504288369836032.ingest.sentry.io/4504288555565056', tracesSampleRate: 1.0, diff --git a/apps/server/src/services/PlaybackService.js b/apps/server/src/services/PlaybackService.ts similarity index 71% rename from apps/server/src/services/PlaybackService.js rename to apps/server/src/services/PlaybackService.ts index 1cfd90ff7..ca3929afd 100644 --- a/apps/server/src/services/PlaybackService.js +++ b/apps/server/src/services/PlaybackService.ts @@ -1,11 +1,10 @@ -/** - * starts loaded timer - */ -import { socketProvider } from '../classes/socket/SocketController.js'; +import { Playback } from 'ontime-types'; + import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js'; import { eventStore } from '../stores/EventStore.js'; import { eventTimer } from './TimerService.js'; import { clock } from './Clock.js'; +import { logger } from '../classes/Logger.js'; /** * Service manages playback status of app @@ -20,9 +19,9 @@ export class PlaybackService { static loadEvent(event) { let success = false; if (!event) { - socketProvider.error('PLAYBACK', 'No event found'); + logger.error('PLAYBACK', 'No event found'); } else if (event.skip) { - socketProvider.warning('PLAYBACK', `Refused playback of skipped event ID ${event.id}`); + logger.warning('PLAYBACK', `Refused playback of skipped event ID ${event.id}`); } else { eventLoader.loadEvent(event); eventTimer.load(event); @@ -41,7 +40,7 @@ export class PlaybackService { const event = EventLoader.getEventWithId(eventId); const success = PlaybackService.loadEvent(event); if (success) { - socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`); + logger.info('PLAYBACK', `Loaded event with ID ${event.id}`); PlaybackService.start(); } return success; @@ -56,7 +55,7 @@ export class PlaybackService { const event = EventLoader.getEventAtIndex(eventIndex); const success = PlaybackService.loadEvent(event); if (success) { - socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`); + logger.info('PLAYBACK', `Loaded event with ID ${event.id}`); PlaybackService.start(); } return success; @@ -71,7 +70,7 @@ export class PlaybackService { const event = EventLoader.getEventWithId(eventId); const success = PlaybackService.loadEvent(event); if (success) { - socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`); + logger.info('PLAYBACK', `Loaded event with ID ${event.id}`); } return success; } @@ -85,7 +84,7 @@ export class PlaybackService { const event = EventLoader.getEventAtIndex(eventIndex); const success = PlaybackService.loadEvent(event); if (success) { - socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`); + logger.info('PLAYBACK', `Loaded event with ID ${event.id}`); } return success; } @@ -98,7 +97,7 @@ export class PlaybackService { if (previousEvent) { const success = PlaybackService.loadEvent(previousEvent); if (success) { - socketProvider.info('PLAYBACK', `Loaded event with ID ${previousEvent.id}`); + logger.info('PLAYBACK', `Loaded event with ID ${previousEvent.id}`); } } } @@ -111,7 +110,7 @@ export class PlaybackService { if (nextEvent) { const success = PlaybackService.loadEvent(nextEvent); if (success) { - socketProvider.info('PLAYBACK', `Loaded event with ID ${nextEvent.id}`); + logger.info('PLAYBACK', `Loaded event with ID ${nextEvent.id}`); } } } @@ -120,10 +119,10 @@ export class PlaybackService { * Starts playback on selected event */ static start() { - if (eventLoader.selectedEventId) { + if (eventTimer.playback === Playback.Armed || eventTimer.playback === Playback.Pause) { eventTimer.start(); const newState = eventTimer.playback; - socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`); + logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`); } } @@ -131,10 +130,10 @@ export class PlaybackService { * Pauses playback on selected event */ static pause() { - if (eventLoader.selectedEventId) { + if (eventTimer.playback === Playback.Play) { eventTimer.pause(); const newState = eventTimer.playback; - socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`); + logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`); } } @@ -142,11 +141,11 @@ export class PlaybackService { * Stops timer and unloads any events */ static stop() { - if (eventLoader.selectedEventId || eventTimer.playback === 'roll') { + if (eventTimer.playback !== Playback.Stop) { eventLoader.reset(); eventTimer.stop(); const newState = eventTimer.playback; - socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`); + logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`); } } @@ -154,8 +153,8 @@ export class PlaybackService { * Reloads current event */ static reload() { - if (eventLoader.selectedEventId) { - this.loadById(eventLoader.selectedEventId); + if (eventTimer.loadedTimerId) { + this.loadById(eventTimer.loadedTimerId); } } @@ -168,14 +167,14 @@ export class PlaybackService { // nothing to play if (rollTimers === null) { - socketProvider.error('SERVER', 'Roll: no events found'); + logger.warning('SERVER', 'Roll: no events found'); PlaybackService.stop(); return; } const { currentEvent, nextEvent, timers } = rollTimers; if (!currentEvent && !nextEvent) { - socketProvider.error('SERVER', 'Roll: no events found'); + logger.warning('SERVER', 'Roll: no events found'); PlaybackService.stop(); return; } @@ -183,7 +182,7 @@ export class PlaybackService { eventTimer.roll(currentEvent, nextEvent, timers); const newState = eventTimer.playback; - socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`); + logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`); } } @@ -192,10 +191,10 @@ export class PlaybackService { * @param {number} delayTime time in minutes */ static setDelay(delayTime) { - if (eventLoader.selectedEventId) { + if (eventTimer.loadedTimerId) { const delayInMs = delayTime * 1000 * 60; eventTimer.delay(delayInMs); - socketProvider.info('PLAYBACK', `Added ${delayTime} min delay`); + logger.info('PLAYBACK', `Added ${delayTime} min delay`); } } } diff --git a/apps/server/src/services/RundownService.ts b/apps/server/src/services/RundownService.ts index 73fad810f..1887fc50e 100644 --- a/apps/server/src/services/RundownService.ts +++ b/apps/server/src/services/RundownService.ts @@ -5,16 +5,15 @@ import { block as blockDef, delay as delayDef, event as eventDef } from '../mode import { MAX_EVENTS } from '../settings.js'; import { EventLoader, eventLoader } from '../classes/event-loader/EventLoader.js'; import { eventTimer } from './TimerService.js'; -import { eventStore } from '../stores/EventStore.js'; /** * Checks if a list of IDs is in the current selection */ const affectedLoaded = (affectedIds: string[]) => { - const now = eventLoader.selectedEventId; - const nowPublic = eventLoader.selectedPublicEventId; - const next = eventLoader.nextEventId; - const nextPublic = eventLoader.nextPublicEventId; + const now = eventLoader.loaded.selectedEventId; + const nowPublic = eventLoader.loaded.selectedPublicEventId; + const next = eventLoader.loaded.nextEventId; + const nextPublic = eventLoader.loaded.nextPublicEventId; return ( affectedIds.includes(now) || affectedIds.includes(nowPublic) || @@ -28,8 +27,8 @@ const affectedLoaded = (affectedIds: string[]) => { */ const isNewNext = () => { const timedEvents = EventLoader.getTimedEvents(); - const now = eventLoader.selectedEventId; - const next = eventLoader.nextEventId; + const now = eventLoader.loaded.selectedEventId; + const next = eventLoader.loaded.nextEventId; // check whether the index of now and next are consecutive const indexNow = timedEvents.findIndex((event) => event.id === now); @@ -39,8 +38,8 @@ const isNewNext = () => { return true; } // iterate through timed events and see if there are public events between nowPublic and nextPublic - const nowPublic = eventLoader.selectedPublicEventId; - const nextPublic = eventLoader.nextPublicEventId; + const nowPublic = eventLoader.loaded.selectedPublicEventId; + const nextPublic = eventLoader.loaded.nextPublicEventId; let foundNew = false; let isAfter = false; @@ -67,7 +66,7 @@ const isNewNext = () => { * Updates timer object */ export function updateTimer(affectedIds?: string[]) { - const runningEventId = eventLoader.selectedEventId; + const runningEventId = eventLoader.loaded.selectedEventId; if (runningEventId === null) { return false; @@ -112,7 +111,7 @@ export function updateTimer(affectedIds?: string[]) { * @param {object} eventData * @return {unknown[]} */ -export async function addEvent(eventData) { +export async function addEvent(eventData: Partial | Partial | Partial) { const numEvents = DataProvider.getRundownLength(); if (numEvents > MAX_EVENTS) { throw new Error(`ERROR: Reached limit number of ${MAX_EVENTS} events`); @@ -145,7 +144,7 @@ export async function addEvent(eventData) { throw new Error(error); } updateTimer([id]); - eventStore.broadcast(); + updateChangeNumEvents(); return newEvent; } @@ -157,7 +156,6 @@ export async function editEvent(eventData) { } const newEvent = await DataProvider.updateEventById(eventId, eventData); updateTimer([eventId]); - eventStore.broadcast(); return newEvent; } @@ -169,7 +167,7 @@ export async function editEvent(eventData) { export async function deleteEvent(eventId) { await DataProvider.deleteEvent(eventId); updateTimer([eventId]); - eventStore.broadcast(); + updateChangeNumEvents(); } /** @@ -179,7 +177,7 @@ export async function deleteEvent(eventId) { export async function deleteAllEvents() { await DataProvider.clearRundown(); updateTimer(); - eventStore.broadcast(); + updateChangeNumEvents(); } /** @@ -204,7 +202,6 @@ export async function reorderEvent(eventId, from, to) { // save rundown await DataProvider.setRundown(rundown); updateTimer(); - return reorderedItem; } @@ -260,5 +257,12 @@ export async function applyDelay(eventId) { // update rundown await DataProvider.setRundown(rundown); updateTimer(); - eventStore.broadcast(); +} + +/** + * Forces update in the store + * Called when we make changes to the rundown object + */ +function updateChangeNumEvents() { + eventLoader.updateNumEvents(); } diff --git a/apps/server/src/services/TimerService.ts b/apps/server/src/services/TimerService.ts index 1e5ef1e6a..4ee90ddc6 100644 --- a/apps/server/src/services/TimerService.ts +++ b/apps/server/src/services/TimerService.ts @@ -1,4 +1,4 @@ -import { TimerLifeCycle, TimerType } from 'ontime-types'; +import { Playback, TimerLifeCycle, TimerState } from 'ontime-types'; import { eventStore } from '../stores/EventStore.js'; import { PlaybackService } from './PlaybackService.js'; @@ -11,27 +11,14 @@ import { clock } from './Clock.js'; export class TimerService { private readonly _interval: NodeJS.Timer; - playback: string; + playback: Playback; + timer: TimerState; loadedTimerId: null; private pausedTime: number; private pausedAt: number | null; private secondaryTarget: number | null; - timer: { - clock: number; // realtime clock - current: number | null; // running countdown - elapsed: number | null; // elapsed time in current timer - expectedFinish: number | null; - addedTime: number; // time added by user, can be negative - startedAt: number | null; - finishedAt: number | null; // only if timer has already finished - secondaryTimer: number | null; // used for roll mode - selectedEventId: string | null; - duration: number | null; - timerType: TimerType | null; - }; - /** * @constructor * @param {object} [timerConfig] @@ -47,7 +34,7 @@ export class TimerService { * @private */ _clear() { - this.playback = 'stop'; + this.playback = Playback.Stop; this.timer = { clock: clock.timeNow(), current: null, @@ -128,7 +115,7 @@ export class TimerService { this.loadedTimerId = timer.id; this.timer.duration = timer.duration; this.timer.current = timer.duration; - this.playback = 'armed'; + this.playback = Playback.Armed; this.timer.timerType = timer.timerType; this.pausedTime = 0; this.pausedAt = 0; @@ -151,7 +138,7 @@ export class TimerService { return; } - if (this.playback === 'play') { + if (this.playback === Playback.Play) { return; } @@ -166,7 +153,7 @@ export class TimerService { this.timer.startedAt = this.timer.clock; } - this.playback = 'play'; + this.playback = Playback.Play; this.timer.expectedFinish = getExpectedFinish( this.timer.startedAt, this.timer.finishedAt, @@ -188,11 +175,11 @@ export class TimerService { } pause() { - if (this.playback !== 'play') { + if (this.playback !== Playback.Play) { return; } - this.playback = 'pause'; + this.playback = Playback.Pause; this.timer.clock = clock.timeNow(); this.pausedAt = this.timer.clock; this._onPause(); @@ -205,7 +192,7 @@ export class TimerService { } stop() { - if (this.playback === 'stop') { + if (this.playback === Playback.Stop) { return; } @@ -248,7 +235,7 @@ export class TimerService { update() { this.timer.clock = clock.timeNow(); - if (this.playback === 'roll') { + if (this.playback === Playback.Roll) { const tempCurrentTimer = { selectedEventId: this.loadedTimerId, current: this.timer.current, @@ -279,11 +266,11 @@ export class TimerService { } else { // we only update timer if a timer has been started if (this.timer.startedAt !== null) { - if (this.playback === 'pause') { + if (this.playback === Playback.Pause) { this.pausedTime = this.timer.clock - this.pausedAt; } - if (this.playback === 'play' && this.timer.current <= 0 && this.timer.finishedAt === null) { + if (this.playback === Playback.Play && this.timer.current <= 0 && this.timer.finishedAt === null) { this.timer.finishedAt = this.timer.clock; this._onFinish(); } else { @@ -338,7 +325,7 @@ export class TimerService { this.secondaryTarget = nextEvent.timeStart; } - this.playback = 'roll'; + this.playback = Playback.Roll; this._onRoll(); this.update(); } diff --git a/apps/server/src/classes/message-manager/MessageManager.ts b/apps/server/src/services/message-service/MessageService.ts similarity index 50% rename from apps/server/src/classes/message-manager/MessageManager.ts rename to apps/server/src/services/message-service/MessageService.ts index 12b54b315..91c418950 100644 --- a/apps/server/src/classes/message-manager/MessageManager.ts +++ b/apps/server/src/services/message-service/MessageService.ts @@ -1,11 +1,13 @@ -import { MessageControl } from 'ontime-types'; +import { Message } from 'ontime-types'; import { eventStore } from '../../stores/EventStore.js'; let instance; class MessageService { - messages: MessageControl; + timerMessage: Message; + publicMessage: Message; + lowerMessage: Message; onAir: boolean; constructor() { @@ -16,20 +18,21 @@ class MessageService { // eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton instance = this; - this.messages = { - presenter: { - text: '', - visible: false, - }, - public: { - text: '', - visible: false, - }, - lower: { - text: '', - visible: false, - }, + this.timerMessage = { + text: '', + visible: false, }; + + this.publicMessage = { + text: '', + visible: false, + }; + + this.lowerMessage = { + text: '', + visible: false, + }; + this.onAir = false; } @@ -37,8 +40,8 @@ class MessageService { * @description sets message on stage timer screen */ setTimerText(payload: string) { - this.messages.presenter.text = payload; - eventStore.set('feat-messagecontrol', { messages: this.messages }); + this.timerMessage.text = payload; + eventStore.set('timerMessage', this.timerMessage); return this.getAll(); } @@ -46,8 +49,8 @@ class MessageService { * @description sets message visibility on stage timer screen */ setTimerVisibility(status: boolean) { - this.messages.presenter.visible = status; - eventStore.set('feat-messagecontrol', { messages: this.messages }); + this.timerMessage.visible = status; + eventStore.set('timerMessage', this.timerMessage); return this.getAll(); } @@ -55,8 +58,8 @@ class MessageService { * @description sets message on public screen */ setPublicText(payload: string) { - this.messages.public.text = payload; - eventStore.set('feat-messagecontrol', { messages: this.messages }); + this.publicMessage.text = payload; + eventStore.set('publicMessage', this.publicMessage); return this.getAll(); } @@ -64,8 +67,8 @@ class MessageService { * @description sets message visibility on public screen */ setPublicVisibility(status: boolean) { - this.messages.public.visible = status; - eventStore.set('feat-messagecontrol', { messages: this.messages }); + this.publicMessage.visible = status; + eventStore.set('publicMessage', this.publicMessage); return this.getAll(); } @@ -73,8 +76,8 @@ class MessageService { * @description sets message on lower third screen */ setLowerText(payload: string) { - this.messages.lower.text = payload; - eventStore.set('feat-messagecontrol', { messages: this.messages }); + this.lowerMessage.text = payload; + eventStore.set('lowerMessage', this.lowerMessage); return this.getAll(); } @@ -82,16 +85,21 @@ class MessageService { * @description sets message visibility on lower third screen */ setLowerVisibility(status: boolean) { - this.messages.lower.visible = status; - eventStore.set('feat-messagecontrol', { messages: this.messages }); + this.lowerMessage.visible = status; + eventStore.set('lowerMessage', this.lowerMessage); return this.getAll(); } /** - * @description set state of onAir + * @description set state of onAir, toggles if parameters are offered */ - setOnAir(status: boolean) { - this.onAir = status; + setOnAir(status?: boolean) { + if (typeof status === 'undefined') { + this.onAir = !this.onAir; + } else { + this.onAir = status; + } + eventStore.set('onAir', this.onAir); return this.getAll(); } @@ -100,10 +108,12 @@ class MessageService { */ getAll() { return { - messages: this.messages, + timerMessage: this.timerMessage, + publicMessage: this.publicMessage, + lowerMessage: this.lowerMessage, onAir: this.onAir, }; } } -export const messageManager = new MessageService(); +export const messageService = new MessageService(); diff --git a/apps/server/src/stores/EventStore.js b/apps/server/src/stores/EventStore.js deleted file mode 100644 index b63be9a5c..000000000 --- a/apps/server/src/stores/EventStore.js +++ /dev/null @@ -1,23 +0,0 @@ -import { socketProvider } from '../classes/socket/SocketController.js'; - -const store = {}; - -/** - * A runtime store that broadcasts its payload - */ -export const eventStore = { - get(key) { - return store[key]; - }, - set(key, value) { - store[key] = value; - socketProvider.send(key, value); - }, - poll() { - return store; - }, - broadcast() { - socketProvider.send(store); - socketProvider.broadcastState(); - }, -}; diff --git a/apps/server/src/stores/EventStore.ts b/apps/server/src/stores/EventStore.ts new file mode 100644 index 000000000..f6ba15983 --- /dev/null +++ b/apps/server/src/stores/EventStore.ts @@ -0,0 +1,31 @@ +import { RuntimeStore } from 'ontime-types'; +import { socket } from '../adapters/WebsocketAdapter.js'; + +const store: Partial = {}; + +/** + * A runtime store that broadcasts its payload + */ +export const eventStore = { + get(key: T) { + return store[key]; + }, + set(key: T, value: RuntimeStore[T]) { + store[key] = value; + // TODO: Partial updates seems to cause issues on the client + // socket.send({ + // type: `ontime-${key}`, + // payload: value, + // }); + this.broadcast(); + }, + poll() { + return store; + }, + broadcast() { + socket.send({ + type: 'ontime', + payload: store, + }); + }, +}; diff --git a/apps/server/src/utils/time.js b/apps/server/src/utils/time.js index e4cedadd3..0ad212451 100644 --- a/apps/server/src/utils/time.js +++ b/apps/server/src/utils/time.js @@ -26,37 +26,6 @@ export const isTimeString = (string) => { return regex.test(string); }; -/** - * @description Converts milliseconds to string representing time - * @param {number} ms - time in milliseconds - * @param {boolean} showSeconds - weather to show the seconds - * @param {string} delim - character between HH MM SS - * @param {string} ifNull - what to return if value is null - * @returns {string} String representing time 00:12:02 - */ - -export const stringFromMillis = (ms, showSeconds = true, delim = ':', ifNull = '...') => { - if (ms == null || isNaN(ms)) return ifNull; - const isNegative = ms < 0 ? '-' : ''; - const millis = Math.abs(ms); - - /** - * @description ensures value is double digit - * @param value - * @return {string|*} - */ - const showWith0 = (value) => (value < 10 ? `0${value}` : value); - const hours = showWith0(Math.floor(((millis / mth) % 60) % 24)); - const minutes = showWith0(Math.floor((millis / mtm) % 60)); - const seconds = showWith0(Math.floor((millis / mts) % 60)); - - return showSeconds - ? `${isNegative}${ - parseInt(hours, 10) ? `${hours}${delim}` : `00${delim}` - }${minutes}${delim}${seconds}` - : `${isNegative}${parseInt(hours, 10) ? `${hours}` : '00'}${delim}${minutes}`; -}; - /** * @description Converts an excel date to milliseconds * @argument {string} date - excel string date diff --git a/packages/types/src/definitions/runtime/Logger.type.ts b/packages/types/src/definitions/runtime/Logger.type.ts new file mode 100644 index 000000000..1ff25e82f --- /dev/null +++ b/packages/types/src/definitions/runtime/Logger.type.ts @@ -0,0 +1,18 @@ +export enum LogLevel { + Info = 'INFO', + Warn = 'WARN', + Error = 'ERROR', +} + +export type Log = { + id: string; + origin: string; + time: string; + level: LogLevel; + text: string; +}; + +export type LogMessage = { + type: 'ontime-log'; + payload: Log; +}; diff --git a/packages/types/src/definitions/runtime/MessageControl.type.ts b/packages/types/src/definitions/runtime/MessageControl.type.ts index 0b2d6bf2c..bbedf6828 100644 --- a/packages/types/src/definitions/runtime/MessageControl.type.ts +++ b/packages/types/src/definitions/runtime/MessageControl.type.ts @@ -2,9 +2,3 @@ export type Message = { text: string; visible: boolean; }; - -export type MessageControl = { - presenter: Message; - public: Message; - lower: Message; -}; diff --git a/packages/types/src/definitions/runtime/Playback.type.ts b/packages/types/src/definitions/runtime/Playback.type.ts index ab86abefb..0dc7af01a 100644 --- a/packages/types/src/definitions/runtime/Playback.type.ts +++ b/packages/types/src/definitions/runtime/Playback.type.ts @@ -1 +1,7 @@ -export type Playback = 'roll' | 'play' | 'pause' | 'stop' | 'armed'; +export enum Playback { + Roll = 'roll', + Play = 'play', + Pause = 'pause', + Stop = 'stop', + Armed = 'armed', +} diff --git a/packages/types/src/definitions/runtime/Playlist.type.ts b/packages/types/src/definitions/runtime/Playlist.type.ts new file mode 100644 index 000000000..2967ae555 --- /dev/null +++ b/packages/types/src/definitions/runtime/Playlist.type.ts @@ -0,0 +1,8 @@ +export type Loaded = { + numEvents: number; + selectedEventIndex: number | null; + selectedEventId: string | null; + selectedPublicEventId: string | null; + nextEventId: string | null; + nextPublicEventId: string | null; +}; diff --git a/packages/types/src/definitions/runtime/RuntimeStore.type.ts b/packages/types/src/definitions/runtime/RuntimeStore.type.ts new file mode 100644 index 000000000..f5cba42d6 --- /dev/null +++ b/packages/types/src/definitions/runtime/RuntimeStore.type.ts @@ -0,0 +1,22 @@ +import { Playback } from './Playback.type.js'; +import { Message } from './MessageControl.type.js'; +import { TimerState } from './TimerState.type.js'; +import { TitleBlock } from './TitleBlock.type.js'; +import { Loaded } from './Playlist.type.js'; + +export type RuntimeStore = { + // timer service + timer: TimerState; + playback: Playback; + + // messages service + timerMessage: Message; + publicMessage: Message; + lowerMessage: Message; + onAir: boolean; + + // event loader + loaded: Loaded; + titles: TitleBlock; + titlesPublic: TitleBlock; +}; diff --git a/packages/types/src/definitions/runtime/TimerState.type.ts b/packages/types/src/definitions/runtime/TimerState.type.ts new file mode 100644 index 000000000..8d2b33981 --- /dev/null +++ b/packages/types/src/definitions/runtime/TimerState.type.ts @@ -0,0 +1,15 @@ +import { TimerType } from '../TimerType.type.js'; + +export type TimerState = { + clock: number; // realtime clock + current: number | null; // running countdown + elapsed: number | null; // elapsed time in current timer + expectedFinish: number | null; + addedTime: number; // time added by user, can be negative + startedAt: number | null; + finishedAt: number | null; // only if timer has already finished + secondaryTimer: number | null; // used for roll mode + selectedEventId: string | null; + duration: number | null; + timerType: TimerType | null; +}; diff --git a/packages/types/src/definitions/runtime/TitleBlock.type.ts b/packages/types/src/definitions/runtime/TitleBlock.type.ts new file mode 100644 index 000000000..bf8836f98 --- /dev/null +++ b/packages/types/src/definitions/runtime/TitleBlock.type.ts @@ -0,0 +1,10 @@ +export type TitleBlock = { + titleNow: string | null; + subtitleNow: string | null; + presenterNow: string | null; + noteNow: string | null; + titleNext: string | null; + subtitleNext: string | null; + presenterNext: string | null; + noteNext: string | null; +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f07fd7389..d3aeab9be 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,7 +1,7 @@ import { Alias } from './definitions/core/Alias.type.js'; import { DatabaseModel } from './definitions/DataModel.type.js'; import { EventData } from './definitions/core/EventData.type.js'; -import { Message, MessageControl } from './definitions/runtime/MessageControl.type.js'; +import { Message } from './definitions/runtime/MessageControl.type.js'; import { OntimeBaseEvent, OntimeBlock, @@ -12,9 +12,14 @@ import { import { OntimeRundown, OntimeRundownEntry } from './definitions/core/Rundown.type.js'; import { OSCSettings, OscSubscription } from './definitions/core/OscSettings.type.js'; import { Playback } from './definitions/runtime/Playback.type.js'; +import { Loaded } from './definitions/runtime/Playlist.type.js'; +import { Log, LogLevel, LogMessage } from './definitions/runtime/Logger.type.js'; +import { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js'; import { Settings } from './definitions/core/Settings.type.js'; import { TimerLifeCycle } from './definitions/core/TimerLifecycle.type.js'; +import { TimerState } from './definitions/runtime/TimerState.type.js'; import { TimerType } from './definitions/TimerType.type.js'; +import { TitleBlock } from './definitions/runtime/TitleBlock.type.js'; import { UserFields } from './definitions/core/UserFields.type.js'; import { ViewSettings } from './definitions/core/Views.type.js'; @@ -47,10 +52,16 @@ export type { OscSubscription, OSCSettings }; // ---> HTTP -// SERVER +// SERVER RUNTIME +export { LogLevel }; +export type { Log, LogMessage }; +export { Playback }; export { TimerLifeCycle }; -export type { Playback }; + export type { Message }; -export type { MessageControl }; +export type { Loaded }; +export type { RuntimeStore }; +export type { TimerState }; +export type { TitleBlock }; // CLIENT diff --git a/packages/utils/index.ts b/packages/utils/index.ts index f7ea126ca..dbf9e00ed 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -1 +1,2 @@ +export { millisToString } from './src/date-utils/millisToString.js'; export { generateId } from './src/generate-id/generateId.js'; diff --git a/packages/utils/package.json b/packages/utils/package.json index 84ca4d9c0..02774b23e 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -12,9 +12,11 @@ "cleanup": "rm -rf .turbo && rm -rf node_modules" }, "dependencies": { + "luxon": "^3.3.0", "nanoid": "^4.0.0" }, "devDependencies": { + "@types/luxon": "^3.2.0", "@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/parser": "^5.48.1", "eslint": "^8.31.0", diff --git a/packages/utils/src/date-utils/millisToString.test.ts b/packages/utils/src/date-utils/millisToString.test.ts new file mode 100644 index 000000000..915d6c71a --- /dev/null +++ b/packages/utils/src/date-utils/millisToString.test.ts @@ -0,0 +1,71 @@ +import { expect } from 'vitest'; + +import { millisToString } from './millisToString'; + +describe('millisToString()', () => { + it('returns fallback if millis is null', () => { + const fallback = 'testFallback'; + expect(millisToString(null, true, fallback)).toBe(fallback); + }); + + it('returns 00:00:00 if 0 is passed', () => { + expect(millisToString(0)).toBe('00:00:00'); + }); + + it('shows negative timers', () => { + const testScenarios = [ + { millis: -300, expected: '-00:00:00' }, + { millis: -1000, expected: '-00:00:01' }, + { millis: -1500, expected: '-00:00:01' }, + { millis: -60000, expected: '-00:01:00' }, + { millis: -600000, expected: '-00:10:00' }, + { millis: -3600000, expected: '-01:00:00' }, + { millis: -36000000, expected: '-10:00:00' }, + { millis: -86399000, expected: '-23:59:59' }, + { millis: -86400000, expected: '-00:00:00' }, + { millis: -86401000, expected: '-00:00:01' }, + ]; + + testScenarios.forEach((scenario) => { + expect(millisToString(scenario.millis)).toBe(scenario.expected); + }); + }); + + test('random properties', () => { + const testScenarios = [ + { millis: 300, expected: '00:00:00' }, + { millis: 1000, expected: '00:00:01' }, + { millis: 1500, expected: '00:00:01' }, + { millis: 60000, expected: '00:01:00' }, + { millis: 600000, expected: '00:10:00' }, + { millis: 3600000, expected: '01:00:00' }, + { millis: 36000000, expected: '10:00:00' }, + { millis: 86399000, expected: '23:59:59' }, + { millis: 86400000, expected: '00:00:00' }, + { millis: 86401000, expected: '00:00:01' }, + ]; + + testScenarios.forEach((scenario) => { + expect(millisToString(scenario.millis)).toBe(scenario.expected); + }); + }); + + test('random properties without seconds', () => { + const testScenarios = [ + { millis: 300, expected: '00:00' }, + { millis: 1000, expected: '00:00' }, + { millis: 1500, expected: '00:00' }, + { millis: 60000, expected: '00:01' }, + { millis: 600000, expected: '00:10' }, + { millis: 3600000, expected: '01:00' }, + { millis: 36000000, expected: '10:00' }, + { millis: 86399000, expected: '23:59' }, + { millis: 86400000, expected: '00:00' }, + { millis: 86401000, expected: '00:00' }, + ]; + + testScenarios.forEach((scenario) => { + expect(millisToString(scenario.millis, false)).toBe(scenario.expected); + }); + }); +}); diff --git a/packages/utils/src/date-utils/millisToString.ts b/packages/utils/src/date-utils/millisToString.ts new file mode 100644 index 000000000..7820eba17 --- /dev/null +++ b/packages/utils/src/date-utils/millisToString.ts @@ -0,0 +1,19 @@ +import { DateTime } from 'luxon'; + +/** + * @description Converts milliseconds to string representing time + * @param {number | null} millis - time in milliseconds + * @param {boolean} showSeconds - weather to show the seconds + * @param {string} fallback - what to return if value is null + * @returns {string} String representing time 00:12:02 + */ +export function millisToString(millis: number | null, showSeconds = true, fallback = '...') { + if (millis === null) { + return fallback; + } + + const isNegative = millis < 0; + + const format = `HH:mm${showSeconds ? ':ss' : ''}`; + return `${isNegative ? '-' : ''}${DateTime.fromMillis(Math.abs(millis)).toUTC().toFormat(format)}`; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b68c55e5..324715252 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,7 @@ importers: '@testing-library/react': ^13.1.1 '@testing-library/user-event': ^14.1.1 '@types/color': ^3.0.3 + '@types/luxon': ^3.2.0 '@types/prop-types': ^15.7.5 '@types/react': ^18.0.26 '@types/react-beautiful-dnd': ^13.1.3 @@ -59,6 +60,7 @@ importers: axios: ^1.2.0 color: ^4.2.3 csv-stringify: ^6.2.3 + deepmerge: ^4.3.0 eslint: ^8.31.0 eslint-config-prettier: ^8.6.0 eslint-plugin-jest: ^27.1.7 @@ -70,7 +72,7 @@ importers: framer-motion: ^8.0.2 jotai: ^1.10.0 jsdom: ^21.1.0 - luxon: ^3.1.0 + luxon: ^3.3.0 ontime-types: workspace:* ontime-utils: workspace:* prettier: ^2.8.3 @@ -83,8 +85,8 @@ importers: react-qr-code: ^2.0.11 react-router-dom: ^6.3.0 react-table: ^7.7.0 + react-use-websocket: ^4.3.1 sass: ^1.57.1 - socket.io-client: ^4.5.4 stylelint: ^14.16.1 stylelint-config-prettier: ^9.0.4 stylelint-config-standard-scss: ^6.1.0 @@ -94,6 +96,7 @@ importers: vite-plugin-svgr: ^2.4.0 vite-tsconfig-paths: ^4.0.3 web-vitals: ^3.1.1 + zustand: ^4.3.6 dependencies: '@chakra-ui/react': 2.4.8_loo4skotrnm7icurwgkplqpnwq '@dnd-kit/core': 6.0.7_biqbaboplfbrettd7655fr4n2y @@ -110,9 +113,10 @@ importers: axios: 1.2.2 color: 4.2.3 csv-stringify: 6.2.3 + deepmerge: 4.3.0 framer-motion: 8.4.3_biqbaboplfbrettd7655fr4n2y jotai: 1.13.0_react@18.2.0 - luxon: 3.2.1 + luxon: 3.3.0 react: 18.2.0 react-beautiful-dnd: 13.1.1_biqbaboplfbrettd7655fr4n2y react-dom: 18.2.0_react@18.2.0 @@ -121,9 +125,10 @@ importers: react-qr-code: 2.0.11_react@18.2.0 react-router-dom: 6.6.2_biqbaboplfbrettd7655fr4n2y react-table: 7.8.0_react@18.2.0 - socket.io-client: 4.5.4 + react-use-websocket: 4.3.1_biqbaboplfbrettd7655fr4n2y typeface-open-sans: 1.1.13 web-vitals: 3.1.1 + zustand: 4.3.6_react@18.2.0 devDependencies: '@sentry/vite-plugin': 0.3.0 '@tanstack/eslint-plugin-query': 4.21.0 @@ -131,6 +136,7 @@ importers: '@testing-library/react': 13.4.0_biqbaboplfbrettd7655fr4n2y '@testing-library/user-event': 14.4.3 '@types/color': 3.0.3 + '@types/luxon': 3.2.0 '@types/prop-types': 15.7.5 '@types/react': 18.0.26 '@types/react-beautiful-dnd': 13.1.3 @@ -182,6 +188,7 @@ importers: '@types/express': ^4.17.17 '@types/node': ^16.11.7 '@types/node-osc': ^6.0.0 + '@types/websocket': ^1.0.5 '@typescript-eslint/eslint-plugin': ^5.48.1 '@typescript-eslint/parser': ^5.48.1 body-parser: ^1.20.0 @@ -205,10 +212,10 @@ importers: passport-local: ~1.0.0 prettier: ^2.8.3 shx: ^0.3.4 - socket.io: ^4.5.4 ts-node: ^10.9.1 typescript: ^4.9.4 vitest: ^0.27.1 + ws: ^8.12.1 dependencies: '@sentry/node': 7.30.0 '@sentry/tracing': 7.30.0 @@ -226,11 +233,12 @@ importers: ontime-utils: link:../../packages/utils passport: 0.6.0 passport-local: 1.0.0 - socket.io: 4.5.4 + ws: 8.12.1 devDependencies: '@types/express': 4.17.17 '@types/node': 16.18.11 '@types/node-osc': 6.0.0 + '@types/websocket': 1.0.5 '@typescript-eslint/eslint-plugin': 5.48.1_3jon24igvnqaqexgwtxk6nkpse '@typescript-eslint/parser': 5.48.1_iukboom6ndih5an6iafl45j2fe esbuild: 0.17.5 @@ -258,19 +266,23 @@ importers: packages/utils: specifiers: + '@types/luxon': ^3.2.0 '@typescript-eslint/eslint-plugin': ^5.48.1 '@typescript-eslint/parser': ^5.48.1 eslint: ^8.31.0 eslint-config-prettier: ^8.6.0 eslint-plugin-prettier: ^4.2.1 eslint-plugin-simple-import-sort: ^8.0.0 + luxon: ^3.3.0 nanoid: ^4.0.0 prettier: ^2.8.3 typescript: ^4.9.4 vitest: ^0.27.1 dependencies: + luxon: 3.3.0 nanoid: 4.0.0 devDependencies: + '@types/luxon': 3.2.0 '@typescript-eslint/eslint-plugin': 5.48.1_3jon24igvnqaqexgwtxk6nkpse '@typescript-eslint/parser': 5.48.1_iukboom6ndih5an6iafl45j2fe eslint: 8.31.0 @@ -2608,10 +2620,6 @@ packages: engines: {node: '>=6'} dev: true - /@socket.io/component-emitter/3.1.0: - resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==} - dev: false - /@svgr/babel-plugin-add-jsx-attribute/6.5.1_@babel+core@7.20.12: resolution: {integrity: sha512-9PYGcXrAxitycIjRmZB+Q0JaN07GZIWaTBIGQzfaZv+qr1n8X1XUEJ5rZ/vx6OVD9RRYlrNnXWExQXcmZeD/BQ==} engines: {node: '>=10'} @@ -2906,16 +2914,6 @@ packages: '@types/node': 18.11.18 dev: true - /@types/cookie/0.4.1: - resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} - dev: false - - /@types/cors/2.8.13: - resolution: {integrity: sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==} - dependencies: - '@types/node': 18.11.18 - dev: false - /@types/debug/4.1.7: resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==} dependencies: @@ -3008,6 +3006,10 @@ packages: resolution: {integrity: sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==} dev: false + /@types/luxon/3.2.0: + resolution: {integrity: sha512-lGmaGFoaXHuOLXFvuju2bfvZRqxAqkHPx9Y9IQdQABrinJJshJwfNCKV+u7rR3kJbiqfTF/NhOkcxxAFrObyaA==} + dev: true + /@types/mime/3.0.1: resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==} dev: true @@ -3035,6 +3037,7 @@ packages: /@types/node/18.11.18: resolution: {integrity: sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==} + dev: true /@types/normalize-package-data/2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} @@ -3127,6 +3130,12 @@ packages: dev: true optional: true + /@types/websocket/1.0.5: + resolution: {integrity: sha512-NbsqiNX9CnEfC1Z0Vf4mE1SgAJ07JnRYcNex7AJ9zAVzmiGHmjKFEk7O4TJIsgv2B1sLEb6owKFZrACwdYngsQ==} + dependencies: + '@types/node': 18.11.18 + dev: true + /@types/yargs-parser/21.0.0: resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==} dev: true @@ -3764,11 +3773,6 @@ packages: requiresBuild: true dev: true - /base64id/2.0.0: - resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} - engines: {node: ^4.5.0 || >= 5.9} - dev: false - /binary-extensions/2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} @@ -4423,6 +4427,11 @@ packages: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /deepmerge/4.3.0: + resolution: {integrity: sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==} + engines: {node: '>=0.10.0'} + dev: false + /defer-to-connect/1.1.3: resolution: {integrity: sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==} dev: true @@ -4667,45 +4676,6 @@ packages: once: 1.4.0 dev: true - /engine.io-client/6.2.3: - resolution: {integrity: sha512-aXPtgF1JS3RuuKcpSrBtimSjYvrbhKW9froICH4s0F3XQWLxsKNxqzG39nnvQZQnva4CMvUK63T7shevxRyYHw==} - dependencies: - '@socket.io/component-emitter': 3.1.0 - debug: 4.3.4 - engine.io-parser: 5.0.5 - ws: 8.2.3 - xmlhttprequest-ssl: 2.0.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - dev: false - - /engine.io-parser/5.0.5: - resolution: {integrity: sha512-mjEyaa4zhuuRhaSLOdjEb57X0XPP9JEsnXI4E+ivhwT0GgzUogARx4MqoY1jQyB+4Bkz3BUOmzL7t9RMKmlG3g==} - engines: {node: '>=10.0.0'} - dev: false - - /engine.io/6.2.1: - resolution: {integrity: sha512-ECceEFcAaNRybd3lsGQKas3ZlMVjN3cyWwMP25D2i0zWfyiytVbTpRPa34qrr+FHddtpBVOmq4H/DCv1O0lZRA==} - engines: {node: '>=10.0.0'} - dependencies: - '@types/cookie': 0.4.1 - '@types/cors': 2.8.13 - '@types/node': 18.11.18 - accepts: 1.3.8 - base64id: 2.0.0 - cookie: 0.4.2 - cors: 2.8.5 - debug: 4.3.4 - engine.io-parser: 5.0.5 - ws: 8.2.3 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - dev: false - /entities/4.4.0: resolution: {integrity: sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==} engines: {node: '>=0.12'} @@ -6534,8 +6504,8 @@ packages: /lru_map/0.3.3: resolution: {integrity: sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==} - /luxon/3.2.1: - resolution: {integrity: sha512-QrwPArQCNLAKGO/C+ZIilgIuDnEnKx5QYODdDtbFaxzsbZcc/a7WFq7MhsVYgRlwawLtvOUESTlfJ+hc/USqPg==} + /luxon/3.3.0: + resolution: {integrity: sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==} engines: {node: '>=12'} dev: false @@ -7613,6 +7583,16 @@ packages: react: 18.2.0 dev: false + /react-use-websocket/4.3.1_biqbaboplfbrettd7655fr4n2y: + resolution: {integrity: sha512-zHPLWrgcqydJaak2O5V9hiz4q2dwkwqNQqpgFVmSuPxLZdsZlnDs8DVHy3WtHH+A6ms/8aHIyX7+7ulOcrnR0Q==} + peerDependencies: + react: '>= 18.0.0' + react-dom: '>= 18.0.0' + dependencies: + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + dev: false + /react/18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} @@ -8025,50 +8005,6 @@ packages: dev: true optional: true - /socket.io-adapter/2.4.0: - resolution: {integrity: sha512-W4N+o69rkMEGVuk2D/cvca3uYsvGlMwsySWV447y99gUPghxq42BxqLNMndb+a1mm/5/7NeXVQS7RLa2XyXvYg==} - dev: false - - /socket.io-client/4.5.4: - resolution: {integrity: sha512-ZpKteoA06RzkD32IbqILZ+Cnst4xewU7ZYK12aS1mzHftFFjpoMz69IuhP/nL25pJfao/amoPI527KnuhFm01g==} - engines: {node: '>=10.0.0'} - dependencies: - '@socket.io/component-emitter': 3.1.0 - debug: 4.3.4 - engine.io-client: 6.2.3 - socket.io-parser: 4.2.1 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - dev: false - - /socket.io-parser/4.2.1: - resolution: {integrity: sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g==} - engines: {node: '>=10.0.0'} - dependencies: - '@socket.io/component-emitter': 3.1.0 - debug: 4.3.4 - transitivePeerDependencies: - - supports-color - dev: false - - /socket.io/4.5.4: - resolution: {integrity: sha512-m3GC94iK9MfIEeIBfbhJs5BqFibMtkRk8ZpKwG2QwxV0m/eEhPIV4ara6XCF1LWNAus7z58RodiZlAH71U3EhQ==} - engines: {node: '>=10.0.0'} - dependencies: - accepts: 1.3.8 - base64id: 2.0.0 - debug: 4.3.4 - engine.io: 6.2.1 - socket.io-adapter: 2.4.0 - socket.io-parser: 4.2.1 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - dev: false - /source-map-js/1.0.2: resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} engines: {node: '>=0.10.0'} @@ -9307,12 +9243,12 @@ packages: optional: true dev: true - /ws/8.2.3: - resolution: {integrity: sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==} + /ws/8.12.1: + resolution: {integrity: sha512-1qo+M9Ba+xNhPB+YTWUlK6M17brTut5EXbcBaMRN5pH5dFrXz7lzz1ChFSUq3bOUl8yEvSenhHmYUNJxFzdJew==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 + utf-8-validate: '>=5.0.2' peerDependenciesMeta: bufferutil: optional: true @@ -9349,11 +9285,6 @@ packages: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} dev: true - /xmlhttprequest-ssl/2.0.0: - resolution: {integrity: sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==} - engines: {node: '>=0.4.0'} - dev: false - /xtend/4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -9415,3 +9346,19 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} dev: true + + /zustand/4.3.6_react@18.2.0: + resolution: {integrity: sha512-6J5zDxjxLE+yukC2XZWf/IyWVKnXT9b9HUv09VJ/bwGCpKNcaTqp7Ws28Xr8jnbvnZcdRaidztAPsXFBIqufiw==} + engines: {node: '>=12.7.0'} + peerDependencies: + immer: '>=9.0' + react: '>=16.8' + peerDependenciesMeta: + immer: + optional: true + react: + optional: true + dependencies: + react: 18.2.0 + use-sync-external-store: 1.2.0_react@18.2.0 + dev: false