mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 10:53:51 +00:00
V2 ws store (#310)
* Update TimerService.ts * refactor: message service publishes to store * refactor: several type improvements * V2 ws store wss (#309) * refactor: shared logging types * refactor: simplify message service consumption * refactor: create discrete logging system * refactor: move socket.io > websocket
This commit is contained in:
@@ -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`;
|
||||
|
||||
@@ -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<HTMLButtonElement | null>(null);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<HTMLInputElement | null>(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();
|
||||
}
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
@@ -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<LoggingProviderState>({
|
||||
logData: [],
|
||||
emitInfo: notInitialised,
|
||||
emitWarning: notInitialised,
|
||||
emitError: notInitialised,
|
||||
clearLog: notInitialised,
|
||||
});
|
||||
|
||||
export const LoggingProvider = ({ children }: LoggingProviderProps) => {
|
||||
const MAX_MESSAGES = 100;
|
||||
const [logData, setLogData] = useState<Log[]>([]);
|
||||
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 (
|
||||
<LoggingContext.Provider value={{ emitInfo, logData, emitWarning, emitError, clearLog }}>
|
||||
{children}
|
||||
</LoggingContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -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<OntimeRundownEntry>, options?: AddOptions) => {
|
||||
async (event: Partial<OntimeRundownEntry>, options?: EventOptions) => {
|
||||
const newEvent: Partial<OntimeRundownEntry> = { ...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)) {
|
||||
|
||||
@@ -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<T>(key: string, defaultValue: T | null = null) {
|
||||
subscribeOnce<T>(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<string, unknown>;
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import socket from '../utils/socket';
|
||||
|
||||
export default function useSubscription<T>(topic: string, initialState: T, requestString?: string) {
|
||||
const [state, setState] = useState<T>(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;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
export default function createStore<T>(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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<LogStore>(() => ({
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<RuntimeStore>(() => ({
|
||||
...runtimeStorePlaceholder,
|
||||
}));
|
||||
|
||||
export const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
|
||||
|
||||
export const useRuntimeStore = <T>(
|
||||
selector: (state: RuntimeStore) => T,
|
||||
equalityFn?: (a: unknown, b: unknown) => boolean,
|
||||
) => useStore(runtime, selector, equalityFn);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<T>(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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user