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:
Carlos Valente
2023-03-15 22:06:47 +01:00
committed by GitHub
parent 11648ee546
commit 76c8f8a4d5
86 changed files with 1876 additions and 1936 deletions
+27 -27
View File
@@ -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 (
<ChakraProvider resetCSS theme={theme}>
<LoggingProvider>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<BrowserRouter>
<div className='App'>
<ErrorBoundary>
<Suspense fallback={null}>
<AppRouter />
</Suspense>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
</div>
</BrowserRouter>
</AppContextProvider>
</QueryClientProvider>
</LoggingProvider>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<BrowserRouter>
<div className='App'>
<ErrorBoundary>
<Suspense fallback={null}>
<AppRouter />
</Suspense>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
</div>
</BrowserRouter>
</AppContextProvider>
</QueryClientProvider>
</ChakraProvider>
);
}
+9 -9
View File
@@ -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'));
+5 -7
View File
@@ -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>
);
};
+17 -14
View File
@@ -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)) {
+73 -118
View File
@@ -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);
},
};
}
+85
View File
@@ -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,
};
}
+73
View File
@@ -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);
});
});
+134 -13
View File
@@ -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,
}),
);
};
-86
View File
@@ -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);
};
+60
View File
@@ -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);
};
@@ -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 (
<div className={style.messageContainer}>
<InputRow
label='Timer screen message'
placeholder='Shown in stage timer'
text={data?.messages.presenter.text || ''}
visible={data?.messages.presenter.visible || false}
text={data.timerMessage.text || ''}
visible={data.timerMessage.visible || false}
changeHandler={(newValue) => setMessage.presenterText(newValue)}
actionHandler={() => setMessage.presenterVisible(!data?.messages.presenter.visible)}
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
/>
<InputRow
label='Public / Backstage screen message'
placeholder='Shown in public and backstage screens'
text={data?.messages.public.text || ''}
visible={data?.messages.public.visible || false}
text={data.publicMessage.text || ''}
visible={data.publicMessage.visible || false}
changeHandler={(newValue) => setMessage.publicText(newValue)}
actionHandler={() => setMessage.publicVisible(!data?.messages.public.visible)}
actionHandler={() => setMessage.publicVisible(!data.publicMessage.visible)}
/>
<InputRow
label='Lower third message'
placeholder='Shown in lower third'
text={data?.messages.lower.text || ''}
visible={data?.messages.lower.visible || false}
text={data.lowerMessage.text || ''}
visible={data.lowerMessage.visible || false}
changeHandler={(newValue) => setMessage.lowerText(newValue)}
actionHandler={() => setMessage.lowerVisible(!data?.messages.lower.visible)}
actionHandler={() => setMessage.lowerVisible(!data.lowerMessage.visible)}
/>
<div className={style.onAirSection}>
<label className={style.label}>Toggle On Air state</label>
<Button
variant={data?.onAir ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={data?.onAir ? <IoMicSharp size='24px' /> : <IoMicOffOutline size='24px' />}
onClick={() => setMessage.onAir(!data?.onAir)}
variant={data.onAir ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={data.onAir ? <IoMicSharp size='24px' /> : <IoMicOffOutline size='24px' />}
onClick={() => setMessage.onAir(!data.onAir)}
>
{data?.onAir ? 'Ontime is On Air' : 'Ontime is Off Air'}
</Button>
@@ -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 (
<>
<PlaybackDisplay
playback={playback}
selectedId={selectedId}
noEvents={noEvents}
/>
<Transport
playback={playback}
selectedId={selectedId}
noEvents={noEvents}
/>
<PlaybackDisplay playback={playback} noEvents={noEvents} />
<Transport playback={playback} noEvents={noEvents} />
</>
);
};
@@ -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 (
<div className={style.mainContainer}>
<PlaybackTimer
playback={data.playback as Playback}
selectedId={data.selectedEventId}
/>
<PlaybackButtons
playback={data.playback}
selectedId={data.selectedEventId}
noEvents={data.numEvents < 1}
/>
<PlaybackTimer playback={data.playback as Playback} />
<PlaybackButtons playback={data.playback} noEvents={data.numEvents < 1} />
</div>
);
}
@@ -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 (
<div className={style.playbackContainer}>
<TapButton
onClick={() => setPlayback.start()}
disabled={!selectedId || isRolling}
theme='play'
disabled={isStopped || isRolling}
theme={Playback.Play}
active={isPlaying}
>
<IoPlay />
@@ -35,8 +35,8 @@ export default function PlaybackDisplay(props: PlaybackProps) {
<TapButton
onClick={() => setPlayback.pause()}
disabled={!selectedId || isRolling || isArmed}
theme='pause'
disabled={isStopped || isRolling || isArmed}
theme={Playback.Pause}
active={isPaused}
>
<IoPause />
@@ -44,8 +44,8 @@ export default function PlaybackDisplay(props: PlaybackProps) {
<TapButton
onClick={() => setPlayback.roll()}
disabled={noEvents}
theme='roll'
disabled={!isStopped || noEvents}
theme={Playback.Roll}
active={isRolling}
>
<IoTimeOutline />
@@ -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 (
<div className={style.timeContainer}>
@@ -44,7 +44,7 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
</Tooltip>
</div>
<div className={style.timer}>
<TimerDisplay time={isWaiting ? timerData.secondaryTimer : timerData.current} />
<TimerDisplay time={isWaiting ? data.timer.secondaryTimer : data.timer.current} />
</div>
{isWaiting ? (
<div className={style.roll}>
@@ -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 (
<div className={style.playbackContainer}>
<Tooltip label='Previous event' openDelay={tooltipDelayMid}>
<TapButton
onClick={() => setPlayback.previous()}
disabled={isRolling || noEvents}
>
<TapButton onClick={() => setPlayback.previous()} disabled={isRolling || noEvents}>
<IoPlaySkipBack />
</TapButton>
</Tooltip>
<Tooltip label='Next event' openDelay={tooltipDelayMid}>
<TapButton
onClick={() => setPlayback.next()}
disabled={isRolling || noEvents}
>
<TapButton onClick={() => setPlayback.next()} disabled={isRolling || noEvents}>
<IoPlaySkipForward />
</TapButton>
</Tooltip>
<Tooltip label='Reload event' openDelay={tooltipDelayMid}>
<TapButton
onClick={() => setPlayback.reload()}
disabled={!selectedId || isRolling}
>
<TapButton onClick={() => setPlayback.reload()} disabled={isStopped || isRolling}>
<IoReload className={style.invertX} />
</TapButton>
</Tooltip>
<Tooltip label='Unload Event' openDelay={tooltipDelayMid}>
<TapButton
onClick={() => setPlayback.stop()}
disabled={!selectedId && !isRolling}
theme='stop'
>
<TapButton onClick={() => setPlayback.stop()} disabled={isStopped && !isRolling} theme={Playback.Stop}>
<IoStop />
</TapButton>
</Tooltip>
@@ -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<OntimeEvent | null>(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 (
<div className={style.eventEditor}>
@@ -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<CollapsableInfoProps>) {
const { title, children } = props;
const [collapsed, setCollapsed] = useState(false);
return (
<div className={style.container}>
<CollapseBar
title={title}
isCollapsed={collapsed}
onClick={() => setCollapsed((prev) => !prev)}
/>
{!collapsed && (
<div className={style.labels}>
<div>
<span className={style.label}>Title:</span>
<span className={style.content}>{data.title}</span>
</div>
<div>
<span className={style.label}>Presenter:</span>
<span className={style.content}>{data.presenter}</span>
</div>
<div>
<span className={style.label}>Subtitle:</span>
<span className={style.content}>{data.subtitle}</span>
</div>
<div>
<span className={style.label}>Note:</span>
<span className={style.content}>{data.note}</span>
</div>
</div>
)}
<CollapseBar title={title} isCollapsed={collapsed} onClick={() => setCollapsed((prev) => !prev)} />
{!collapsed && children}
</div>
);
}
-44
View File
@@ -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 (
<>
<div className={style.panelHeader}>
<span>Ontime running on port 4001</span>
<span>{selected}</span>
</div>
<InfoNif />
<InfoTitle title='Playing Now' data={titlesNow} />
<InfoTitle title='Playing Next' data={titlesNext} />
<InfoLogger />
</>
);
}
+53
View File
@@ -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 (
<>
<div className={style.panelHeader}>
<span>Ontime running on port 4001</span>
<span>{selected}</span>
</div>
<CollapsableInfo title='Network Info'>
<InfoNif />
</CollapsableInfo>
<CollapsableInfo title='Playing Now'>
<InfoTitles data={titlesNow} />
</CollapsableInfo>
<CollapsableInfo title='Playing Next'>
<InfoTitles data={titlesNext} />
</CollapsableInfo>
<CollapsableInfo title='Log'>
<InfoLogger />
</CollapsableInfo>
</>
);
}
@@ -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;
}
+105 -123
View File
@@ -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<Log[]>([]);
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 (
<div className={`${style.infoLoggerContainer} ${collapsed? '' : style.expanded}`}>
<CollapseBar title='Log' isCollapsed={collapsed} onClick={() => setCollapsed((prev) => !prev)} />
{!collapsed && (
<>
<div className={style.buttonBar}>
<Button
variant={showUser ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LOG_FILTER.USER)}
onContextMenu={(e) => e.preventDefault()}
>
USER
</Button>
<Button
variant={showClient ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LOG_FILTER.CLIENT)}
onContextMenu={(e) => e.preventDefault()}
>
CLIENT
</Button>
<Button
variant={showServer ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LOG_FILTER.SERVER)}
onContextMenu={(e) => e.preventDefault()}
>
SERVER
</Button>
<Button
variant={showPlayback ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LOG_FILTER.PLAYBACK)}
onContextMenu={(e) => e.preventDefault()}
>
PLAYBACK
</Button>
<Button
variant={showRx ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LOG_FILTER.RX)}
onContextMenu={(e) => e.preventDefault()}
>
RX
</Button>
<Button
variant={showTx ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LOG_FILTER.TX)}
onContextMenu={(e) => e.preventDefault()}
>
TX
</Button>
<Button
variant='ontime-outlined'
size='xs'
onClick={clearLog}
>
Clear
</Button>
</div>
<ul className={style.log}>
{data.map((logEntry) => (
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]} `}>
<span className={style.time}>{logEntry.time}</span>
<span className={style.origin}>{logEntry.origin}</span>
<span className={style.msg}>{logEntry.text}</span>
</li>
))}
</ul>
</>
)}
<div className={style.infoLoggerContainer}>
<div className={style.buttonBar}>
<Button
variant={showUser ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.User)}
onContextMenu={(e) => e.preventDefault()}
>
{LogFilter.User}
</Button>
<Button
variant={showClient ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.Client)}
onContextMenu={(e) => e.preventDefault()}
>
{LogFilter.Client}
</Button>
<Button
variant={showServer ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.Server)}
onContextMenu={(e) => e.preventDefault()}
>
{LogFilter.Server}
</Button>
<Button
variant={showPlayback ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.Playback)}
onContextMenu={(e) => e.preventDefault()}
>
{LogFilter.Playback}
</Button>
<Button
variant={showRx ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.RX)}
onContextMenu={(e) => e.preventDefault()}
>
{LogFilter.RX}
</Button>
<Button
variant={showTx ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.TX)}
onContextMenu={(e) => e.preventDefault()}
>
{LogFilter.TX}
</Button>
<Button variant='ontime-outlined' size='xs' onClick={clearLogs}>
Clear
</Button>
</div>
<ul className={style.log}>
{filteredData.map((logEntry) => (
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]} `}>
<span className={style.time}>{logEntry.time}</span>
<span className={style.origin}>{logEntry.origin}</span>
<span className={style.msg}>{logEntry.text}</span>
</li>
))}
</ul>
</div>
);
}
+7 -15
View File
@@ -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 (
<div className={style.container}>
<CollapseBar title='Network Info' isCollapsed={collapsed} onClick={() => setCollapsed((prev) => !prev)} />
{!collapsed && (
<div className={style.interfaceList}>
{data?.networkInterfaces.map((nif) => (
<span key={nif.address} onClick={() => handleClick(nif.address)} className={style.interface}>
{`${nif.name} - ${nif.address}`}
<IoArrowUp className={style.linkIcon} />
</span>
))}
</div>
)}
<div className={style.interfaceList}>
{data?.networkInterfaces.map((nif) => (
<span key={nif.address} onClick={() => handleClick(nif.address)} className={style.interface}>
{`${nif.name} - ${nif.address}`}
<IoArrowUp className={style.linkIcon} />
</span>
))}
</div>
);
}
@@ -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 (
<div className={style.labels}>
<div>
<span className={style.label}>Title:</span>
<span className={style.content}>{data.title}</span>
</div>
<div>
<span className={style.label}>Presenter:</span>
<span className={style.content}>{data.presenter}</span>
</div>
<div>
<span className={style.label}>Subtitle:</span>
<span className={style.content}>{data.subtitle}</span>
</div>
<div>
<span className={style.label}>Note:</span>
<span className={style.content}>{data.note}</span>
</div>
</div>
);
}
@@ -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
<table>
<tbody>
<tr>
<td className={style.labelNote} style={{ width: '30%' }}>
Alias
</td>
<td className={style.labelNote}>Page URL</td>
</tr>
<tr>
<td>mylower</td>
<td>lower?bg=ff2&text=f00&size=0.6&transition=5</td>
</tr>
<tr>
<td className={style.labelNote} style={{ width: '30%' }}>
Alias
</td>
<td className={style.labelNote}>Page URL</td>
</tr>
<tr>
<td>mylower</td>
<td>lower?bg=ff2&text=f00&size=0.6&transition=5</td>
</tr>
</tbody>
</table>
<br />
@@ -212,16 +216,16 @@ export default function AliasesModal() {
eg. an unattended screen that you would need to change route from the app
<table>
<tbody>
<tr>
<td className={style.labelNote} style={{ width: '30%' }}>
Alias
</td>
<td className={style.labelNote}>Page URL</td>
</tr>
<tr>
<td>thirdfloor</td>
<td>public</td>
</tr>
<tr>
<td className={style.labelNote} style={{ width: '30%' }}>
Alias
</td>
<td className={style.labelNote}>Page URL</td>
</tr>
<tr>
<td>thirdfloor</td>
<td>public</td>
</tr>
</tbody>
</table>
</div>
@@ -254,12 +258,7 @@ export default function AliasesModal() {
onChange={(event) => handleChange(index, 'pathAndParams', event.target.value)}
/>
<Tooltip label={`Test /${alias.pathAndParams}`} openDelay={tooltipDelayFast}>
<a
href='#!'
target='_blank'
rel='noreferrer'
onClick={(e) => handleLinks(e, alias.pathAndParams)}
/>
<a href='#!' target='_blank' rel='noreferrer' onClick={(e) => handleLinks(e, alias.pathAndParams)} />
</Tooltip>
<Tooltip label='Enable alias' openDelay={tooltipDelayFast}>
<IconButton
@@ -281,12 +280,8 @@ export default function AliasesModal() {
/>
</Tooltip>
</div>
{alias.aliasError ? (
<div className={style.error}>{`Alias error: ${alias.aliasError}`}</div>
) : null}
{alias.urlError ? (
<div className={style.error}>{`URL error: ${alias.urlError}`}</div>
) : null}
{alias.aliasError ? <div className={style.error}>{`Alias error: ${alias.aliasError}`}</div> : null}
{alias.urlError ? <div className={style.error}>{`URL error: ${alias.urlError}`}</div> : null}
</div>
))}
@@ -296,12 +291,7 @@ export default function AliasesModal() {
</Button>
</div>
</div>
<SubmitContainer
revert={revert}
submitting={submitting}
changed={changed}
status={status}
/>
<SubmitContainer revert={revert} submitting={submitting} changed={changed} status={status} />
</form>
</ModalBody>
);
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -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,
@@ -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() {
</table>
</div>
</div>
<SubmitContainer
revert={revert}
submitting={submitting}
changed={changed}
status={status}
/>
<SubmitContainer revert={revert} submitting={submitting} changed={changed} status={status} />
</form>
</ModalBody>
);
+1 -1
View File
@@ -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);
@@ -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();
@@ -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' };
@@ -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
@@ -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<HTMLInputElement | null>(null);
+4 -5
View File
@@ -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 },
@@ -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 (
<Tooltip openDelay={tooltipDelayFast} label='Timer Stopped' shouldWrapChildren>
<IoStop />
@@ -18,7 +22,7 @@ export default function PlaybackIcon(props) {
);
}
if (state === 'start') {
if (state === Playback.Play) {
return (
<Tooltip openDelay={tooltipDelayFast} label='Timer Playing' shouldWrapChildren>
<IoPlay />
@@ -26,7 +30,7 @@ export default function PlaybackIcon(props) {
);
}
if (state === 'pause') {
if (state === Playback.Pause) {
return (
<Tooltip openDelay={tooltipDelayFast} label='Timer Paused' shouldWrapChildren>
<IoPause />
@@ -34,7 +38,7 @@ export default function PlaybackIcon(props) {
);
}
if (state === 'roll') {
if (state === Playback.Roll) {
return (
<Tooltip openDelay={tooltipDelayFast} label='Timer Rolling' shouldWrapChildren>
<IoTimeOutline />
@@ -44,7 +48,3 @@ export default function PlaybackIcon(props) {
return '';
}
PlaybackIcon.propTypes = {
state: PropTypes.string,
};
+2 -2
View File
@@ -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' : '';
@@ -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 (
<Component
{...props}
pres={messageControl.messages.presenter}
publ={messageControl.messages.public}
lower={messageControl.messages.lower}
pres={timerMessage}
publ={publicMessage}
lower={lowerMessage}
title={titleManager}
publicTitle={publicTitleManager}
time={TimeManagerType}
@@ -136,10 +98,10 @@ const withSocket = (Component) => {
viewSettings={viewSettings}
nextId={nextId}
general={genData}
onAir={messageControl.onAir}
onAir={onAir}
/>
);
};
};
export default withSocket;
export default withData;
@@ -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' : '';
@@ -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) {
@@ -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;
@@ -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 (
@@ -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' : ''}`;