Merge remote-tracking branch 'org/master' into feat/http-integration

This commit is contained in:
arc-alex
2023-11-29 11:17:13 +01:00
47 changed files with 832 additions and 278 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint
pnpm lint-staged
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "2.16.2",
"version": "2.21.3",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.7.0",
@@ -41,6 +41,7 @@
"build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "vite build",
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"test": "vitest",
"test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build"
@@ -212,6 +212,7 @@ export default function TimeInput(props: TimeInputProps) {
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
autoComplete='off'
/>
</InputGroup>
);
@@ -10,6 +10,7 @@ $progress-bar-br: 3px;
border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
display: flex;
overflow: hidden;
&--hidden {
display: none;
@@ -31,7 +32,6 @@ $progress-bar-br: 3px;
position: absolute;
height: inherit;
right: 0;
border-radius: $progress-bar-br;
width: 100%;
}
@@ -39,12 +39,10 @@ $progress-bar-br: 3px;
position: absolute;
height: inherit;
right: 0;
border-radius: 0 $progress-bar-br $progress-bar-br 0;
}
.multiprogress-bar__bg-danger {
position: absolute;
height: inherit;
right: 0;
border-radius: 0 $progress-bar-br $progress-bar-br 0;
}
@@ -3,7 +3,7 @@ import { clamp } from '../../utils/math';
import './MultiPartProgressBar.scss';
interface MultiPartProgressBar {
now: number;
now: number | null;
complete: number;
normalColor: string;
warning: number;
@@ -17,23 +17,27 @@ interface MultiPartProgressBar {
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
const percentComplete = 100 - clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
const percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const dangerWidth = clamp((danger / complete) * 100, 0, 100);
const warningWidth = clamp((warning / complete) * 100, 0, 100);
return (
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
<div
className='multiprogress-bar__bg-warning'
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
/>
<div
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
/>
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
{now !== null && (
<>
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
<div
className='multiprogress-bar__bg-warning'
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
/>
<div
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
/>
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
</>
)}
</div>
);
}
@@ -12,7 +12,6 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import useFullscreen from '../../hooks/useFullscreen';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useViewOptionsStore } from '../../stores/viewOptions';
import RenameClientModal from './rename-client-modal/RenameClientModal';
@@ -28,13 +27,14 @@ function NavigationMenu() {
const [searchParams, setSearchParams] = useSearchParams();
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => setShowMenu(false));
const { isOpen, onOpen, onClose } = useDisclosure();
const toggleMenu = () => setShowMenu((prev) => !prev);
useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' || isOpen });
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
@@ -1,5 +1,5 @@
import { useSearchParams } from 'react-router-dom';
import { Input, Select, Switch } from '@chakra-ui/react';
import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react';
import { isStringBoolean } from '../../utils/viewUtils';
@@ -9,16 +9,22 @@ interface EditFormInputProps {
paramField: ParamField;
}
export default function ParamInput({ paramField }: EditFormInputProps) {
export default function ParamInput(props: EditFormInputProps) {
const [searchParams] = useSearchParams();
const { id, type } = paramField;
const { paramField } = props;
const { id, type, defaultValue } = paramField;
if (type === 'option') {
const optionFromParams = searchParams.get(id);
const defaultOptionValue = optionFromParams || undefined;
const defaultOptionValue = optionFromParams || defaultValue;
return (
<Select placeholder='Select an option' variant='ontime' name={id} defaultValue={defaultOptionValue}>
<Select
placeholder={defaultValue ? undefined : 'Select an option'}
variant='ontime'
name={id}
defaultValue={defaultOptionValue}
>
{Object.entries(paramField.values).map(([key, value]) => (
<option key={key} value={key}>
{value}
@@ -29,19 +35,38 @@ export default function ParamInput({ paramField }: EditFormInputProps) {
}
if (type === 'boolean') {
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) ?? false;
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
// checked value should be 'true', so it can be captured by the form event
return <Switch variant='ontime' name={id} defaultChecked={defaultCheckedValue} value='true' />;
}
if (type === 'number') {
const defaultNumberValue = searchParams.get(id) ?? '';
const { prefix, placeholder } = paramField;
const defaultNumberValue = searchParams.get(id) ?? defaultValue;
return <Input type='number' step='any' variant='ontime-filled' name={id} defaultValue={defaultNumberValue} />;
return (
<InputGroup variant='ontime-filled'>
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
<Input
type='number'
step='any'
variant='ontime-filled'
name={id}
defaultValue={defaultNumberValue}
placeholder={placeholder}
/>
</InputGroup>
);
}
const defaultStringValue = searchParams.get(id) ?? '';
const defaultStringValue = searchParams.get(id) ?? defaultValue;
const { prefix, placeholder } = paramField;
return <Input variant='ontime-filled' name={id} defaultValue={defaultStringValue} />;
return (
<InputGroup variant='ontime-filled'>
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
<Input name={id} defaultValue={defaultStringValue} placeholder={placeholder} />
</InputGroup>
);
}
@@ -22,9 +22,15 @@ import style from './ViewParamsEditor.module.scss';
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
type SavedViewParams = Record<string, ViewParamsObj>;
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj) =>
Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
const defaultValues = paramFields.map(({ defaultValue }) => String(defaultValue));
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
if (typeof value === 'string' && value.length) {
if (defaultValues.includes(value)) {
return newSearchParams;
}
newSearchParams.set(id, value);
return newSearchParams;
@@ -32,6 +38,7 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj) =>
return newSearchParams;
}, new URLSearchParams());
};
interface EditFormDrawerProps {
paramFields: ParamField[];
@@ -51,6 +58,12 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
}
}, [searchParams, onOpen]);
/**
* disabling this for now, this feature needs more testing
* - we seem to have a bug where this is conflicting with the aliases
* - I wonder if the logic below needs to be inside an effect,
* both localStorage and searchParams should trigger a component update when they change
useEffect(() => {
const viewParamsObjFromLocalStorage = storedViewParams[pathname];
@@ -64,31 +77,32 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
const onEditDrawerClose = () => {
*/
const onCloseWithoutSaving = () => {
onClose();
searchParams.delete('edit');
setSearchParams(searchParams);
};
const clearParams = () => {
const resetParams = () => {
setStoredViewParams({ ...storedViewParams, [pathname]: {} });
setSearchParams();
onClose();
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
formEvent.preventDefault();
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject);
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
setStoredViewParams({ ...storedViewParams, [pathname]: newParamsObject });
setSearchParams(newSearchParams);
};
return (
<Drawer isOpen={isOpen} placement='right' onClose={onEditDrawerClose} size='lg'>
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader className={style.drawerHeader}>
@@ -111,10 +125,10 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
</DrawerBody>
<DrawerFooter className={style.drawerFooter}>
<Button variant='ontime-ghosted' onClick={clearParams} type='reset'>
Clear
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
Reset
</Button>
<Button variant='ontime-subtle' onClick={onEditDrawerClose}>
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
Cancel
</Button>
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
@@ -1,46 +1,56 @@
import { UserFields } from 'ontime-types';
import { TimeFormat } from 'ontime-types/src/definitions/core/TimeFormat.type';
import { ParamField } from './types';
export const TIME_FORMAT_OPTION: ParamField = {
export const getTimeOption = (timeFormat: TimeFormat): ParamField => ({
id: 'format',
title: '12 / 24 hour timer',
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
type: 'option',
values: { '12': '12 hour AM/PM', '24': '24 hour' },
};
defaultValue: timeFormat,
});
export const CLOCK_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
export const getClockOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
@@ -48,12 +58,14 @@ export const CLOCK_OPTIONS: ParamField[] = [
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
@@ -61,47 +73,94 @@ export const CLOCK_OPTIONS: ParamField[] = [
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
];
export const TIMER_OPTIONS: ParamField[] = [TIME_FORMAT_OPTION];
export const getTimerOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Presenter Message',
description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
@@ -109,12 +168,14 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
@@ -122,145 +183,162 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'hideovertime',
title: 'Hide Overtime',
description: 'Whether to suppress overtime styles (red borders and red text)',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidemessages',
title: 'Hide Message Overlay',
description: 'Whether to hide the overlay from showing timer screen messages',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideendmessage',
title: 'Hide End Message',
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
type: 'boolean',
defaultValue: false,
},
];
export const LOWER_THIRDS_OPTIONS: ParamField[] = [
{
id: 'preset',
title: 'Preset',
description: 'Selects a style preset (0-1)',
type: 'number',
},
{
id: 'size',
title: 'Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 5)',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffffa (default)',
},
{
id: 'bg',
title: 'Text Background',
description: 'Text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000033 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Screen background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000033 (default)',
},
{
id: 'fadeout',
title: 'Fadeout',
description: 'Time (in seconds) the lower third displays before fading out',
type: 'number',
placeholder: '3 (default)',
},
];
export const BACKSTAGE_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
export const getBackstageOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
type: 'number',
placeholder: '7 (default)',
},
];
export const PUBLIC_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
export const getPublicOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
type: 'number',
placeholder: '7 (default)',
},
];
export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
export const getStudioClockOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'seconds',
title: 'Show Seconds',
description: 'Shows seconds in clock',
type: 'boolean',
defaultValue: false,
},
];
export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
export const getOperatorOptions = (userFields: UserFields, timeFormat: TimeFormat): ParamField[] => {
return [
TIME_FORMAT_OPTION,
getTimeOption(timeFormat),
{
id: 'showseconds',
title: 'Show seconds',
description: 'Schedule shows hh:mm:ss',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'main',
@@ -4,9 +4,13 @@ type BaseField = {
description: string;
};
type OptionsField = { type: 'option'; values: Record<string, string> };
type StringField = { type: 'string' };
type BooleanField = { type: 'boolean' };
type NumberField = { type: 'number' };
type OptionsField = {
type: 'option';
values: Record<string, string>;
defaultValue?: string;
};
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean };
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField);
@@ -0,0 +1,64 @@
import { MouseEvent, SyntheticEvent, TouchEvent, useMemo, useRef } from 'react';
type LongPressOptions = {
threshold?: number;
onStart?: (e: SyntheticEvent) => void;
onFinish?: (e: SyntheticEvent) => void;
onCancel?: (e: SyntheticEvent) => void;
};
type LongPressFns = {
onMouseDown: (e: MouseEvent) => void;
onMouseUp: (e: MouseEvent) => void;
onMouseLeave: (e: MouseEvent) => void;
onTouchStart: (e: TouchEvent) => void;
onTouchEnd: (e: TouchEvent) => void;
};
export default function useLongPress(callback: () => void, options: LongPressOptions = {}): LongPressFns {
const { threshold = 400, onStart, onFinish, onCancel } = options;
const isLongPressActive = useRef(false);
const isPressed = useRef(false);
const timerId = useRef<NodeJS.Timer>();
return useMemo(() => {
const start = (event: SyntheticEvent) => {
if (onStart) {
onStart(event);
}
isPressed.current = true;
timerId.current = setTimeout(() => {
callback();
isLongPressActive.current = true;
}, threshold);
};
const cancel = (event: SyntheticEvent) => {
if (isLongPressActive.current) {
if (onFinish) {
onFinish(event);
}
} else if (isPressed.current) {
if (onCancel) {
onCancel(event);
}
}
isLongPressActive.current = false;
isPressed.current = false;
if (timerId.current) {
clearTimeout(timerId.current);
}
};
return {
onMouseDown: start,
onMouseUp: cancel,
onMouseLeave: cancel,
onTouchStart: start,
onTouchEnd: cancel,
};
}, [callback, threshold, onCancel, onFinish, onStart]);
}
@@ -19,6 +19,6 @@
& > * {
border: 1px solid $white-10;
border-radius: 4px;
border-radius: 3px;
}
}
}
@@ -8,6 +8,7 @@ import useRundown from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields';
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
import Cuesheet from './Cuesheet';
import { makeCuesheetColumns } from './cuesheetCols';
@@ -140,6 +141,7 @@ export default function CuesheetWrapper() {
return (
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetTableHeader handleExport={handleOpenModal} featureData={featureData} />
<CuesheetProgress />
<Cuesheet data={rundown} columns={columns} handleUpdate={handleUpdate} selectedId={featureData.selectedEventId} />
<ExportModal isOpen={isModalOpen} onClose={onModalClose} />
</div>
@@ -0,0 +1,3 @@
.progressOverride {
height: 1rem;
}
@@ -0,0 +1,24 @@
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import { useTimer } from '../../../common/hooks/useSocket';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import styles from "./CuesheetProgress.module.scss"
export default function CuesheetProgress() {
const { data } = useViewSettings();
const timer = useTimer();
const totalTime = (timer.duration ?? 0) + (timer.addedTime ?? 0);
return (
<MultiPartProgressBar
now={timer.current}
complete={totalTime}
normalColor={data!.normalColor}
warning={data!.warningThreshold}
warningColor={data!.warningColor}
danger={data!.dangerThreshold}
dangerColor={data!.dangerColor}
className={styles.progressOverride}
/>
);
}
@@ -73,8 +73,8 @@ export default function EventEditor() {
handleSubmit={handleSubmit}
>
<CopyTag label='Event ID'>{event.id}</CopyTag>
<CopyTag label='OSC trigger by id'>{`/ontime/gotoid/${event.id}`}</CopyTag>
<CopyTag label='OSC trigger by cue'>{`/ontime/gotocue/${event.cue}`}</CopyTag>
<CopyTag label='OSC trigger by id'>{`/ontime/gotoid "${event.id}"`}</CopyTag>
<CopyTag label='OSC trigger by cue'>{`/ontime/gotocue "${event.cue}"`}</CopyTag>
</EventEditorDataRight>
</div>
);
@@ -40,6 +40,7 @@ export default function CountedTextInput(props: CountedTextInputProps) {
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
autoComplete='off'
/>
</div>
);
@@ -5,6 +5,7 @@
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: $inner-section-text-size;
.title {
white-space: nowrap;
@@ -14,12 +15,12 @@
.selected {
min-width: max-content;
font-size: $inner-section-text-size;
color: $label-gray;
}
}
.description {
font-size: $inner-section-text-size;
color: $label-gray;
white-space: nowrap;
overflow: hidden;
@@ -22,3 +22,24 @@
.spacer {
min-height: 95vh;
}
.editPrompt {
position: fixed;
z-index: 1;
left: 50%;
transform: translate(-50%, 0);
text-align: center;
background: rgba(black, 0.6);
border-radius: 2px;
padding: 0.5em 2em;
color: gold;
opacity: 0;
transition-property: opacity;
transition-duration: 0.3s;
&.show {
opacity: 1;
}
}
+49 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types';
import { getFirstEvent, getLastEvent } from 'ontime-utils';
@@ -11,10 +11,12 @@ import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import useUserFields from '../../common/hooks-query/useUserFields';
import { debounce } from '../../common/utils/debounce';
import { isStringBoolean } from '../../common/utils/viewUtils';
import EditModal from './edit-modal/EditModal';
import FollowButton from './follow-button/FollowButton';
import OperatorBlock from './operator-block/OperatorBlock';
import OperatorEvent from './operator-event/OperatorEvent';
@@ -25,13 +27,24 @@ import style from './Operator.module.scss';
const selectedOffset = 50;
type TitleFields = Pick<OntimeEvent, 'title' | 'subtitle' | 'presenter'>;
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { fieldLabel?: string; fieldValue: string };
export type PartialEdit = EditEvent & {
field: keyof UserFields;
};
export default function Operator() {
const { data, status } = useRundown();
const { data: userFields, status: userFieldsStatus } = useUserFields();
const { data: projectData, status: projectDataStatus } = useProjectData();
const timeoutId = useRef<NodeJS.Timeout | null>(null);
const featureData = useOperator();
const [searchParams] = useSearchParams();
const { data: settings } = useSettings();
const [showEditPrompt, setShowEditPrompt] = useState(false);
const [editEvent, setEditEvent] = useState<PartialEdit | null>(null);
const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null);
@@ -78,6 +91,30 @@ export default function Operator() {
};
const debouncedHandleScroll = debounce(handleUserScroll, 1000);
const handleScroll = () => {
if (timeoutId.current) {
clearTimeout(timeoutId.current);
}
timeoutId.current = setTimeout(() => {
setShowEditPrompt(false);
}, 700);
setShowEditPrompt(true);
debouncedHandleScroll();
};
const handleEdit = useCallback(
(event: EditEvent) => {
const field = searchParams.get('subscribe') as keyof UserFields | null;
if (field) {
setEditEvent({ ...event, field });
}
},
[searchParams],
);
const missingData = !data || !userFields || !projectData;
const isLoading = status === 'pending' || userFieldsStatus === 'pending' || projectDataStatus === 'pending';
@@ -92,7 +129,7 @@ export default function Operator() {
const subscribedAlias = subscribe ? userFields[subscribe] : '';
const showSeconds = isStringBoolean(searchParams.get('showseconds'));
const operatorOptions = getOperatorOptions(userFields);
const operatorOptions = getOperatorOptions(userFields, settings?.timeFormat ?? '24');
let isPast = Boolean(featureData.selectedEventId);
const hidePast = isStringBoolean(searchParams.get('hidepast'));
@@ -103,6 +140,7 @@ export default function Operator() {
<div className={style.operatorContainer}>
<NavigationMenu />
<ViewParamsEditor paramFields={operatorOptions} />
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
<StatusBar
projectTitle={projectData.title}
@@ -114,12 +152,13 @@ export default function Operator() {
lastId={lastEvent?.id}
/>
<div
className={style.operatorEvents}
onWheel={debouncedHandleScroll}
onTouchMove={debouncedHandleScroll}
ref={scrollRef}
>
{subscribe && (
<div className={`${style.editPrompt} ${showEditPrompt ? style.show : undefined}`}>
Press and hold to edit user field
</div>
)}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{data.map((entry) => {
if (isOntimeEvent(entry)) {
const isSelected = featureData.selectedEventId === entry.id;
@@ -139,6 +178,7 @@ export default function Operator() {
return (
<OperatorEvent
key={entry.id}
id={entry.id}
colour={entry.colour}
cue={entry.cue}
main={mainField}
@@ -153,6 +193,7 @@ export default function Operator() {
showSeconds={showSeconds}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
onLongPress={subscribe ? handleEdit : () => undefined}
/>
);
}
@@ -0,0 +1,30 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
.editModal {
position: absolute;
z-index: 2;
margin: 0 auto;
top: 20%;
left: 50%;
transform: translateX(-50%);
padding: 1rem;
background-color: $gray-1250;
display: flex;
flex-direction: column;
gap: 1rem;
min-width: min(400px, 90vw);
box-shadow: $box-shadow-l1;
.buttonRow {
margin-top: auto;
display: flex;
justify-content: space-between;
gap: 1rem;
button {
width: 100%;
}
}
}
@@ -0,0 +1,57 @@
import { useRef, useState } from 'react';
import { Button, Textarea } from '@chakra-ui/react';
import { OntimeEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction';
import type { PartialEdit } from '../Operator';
import style from './EditModal.module.scss';
interface EditModalProps {
event: PartialEdit;
onClose: () => void;
}
export default function EditModal(props: EditModalProps) {
const { event, onClose } = props;
const { updateEvent } = useEventAction();
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const handleSave = async () => {
setLoading(true);
const newValue = inputRef.current?.value;
const partialEvent: Partial<OntimeEvent> = {
id: event.id,
[event.field]: newValue,
};
await updateEvent(partialEvent);
setLoading(false);
onClose();
};
const fieldLabel = event?.fieldLabel ?? event.field;
return (
<div className={style.editModal}>
<div>{`Editing field ${fieldLabel} in cue ${event.cue}`}</div>
<Textarea
ref={inputRef}
variant='ontime-filled'
placeholder={`Add value for ${fieldLabel} field`}
defaultValue={event.fieldValue}
isDisabled={loading}
/>
<div className={style.buttonRow}>
<Button variant='ontime-subtle' onClick={onClose} isDisabled={loading}>
Cancel
</Button>
<Button variant='ontime-filled' onClick={handleSave} isDisabled={loading}>
Save
</Button>
</div>
</div>
);
}
@@ -2,9 +2,11 @@
@use '../../../../src/theme/v2Styles' as *;
.followButton {
position: relative;
bottom: 12rem;
margin: 0 auto;
position: fixed;
bottom: 1.5rem;
bottom: calc(1.5rem + env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
z-index: 1;
display: flex;
@@ -1,13 +1,16 @@
import { memo, RefObject } from 'react';
import { memo, RefObject, SyntheticEvent } from 'react';
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
import useLongPress from '../../../common/hooks/useLongPress';
import { useTimer } from '../../../common/hooks/useSocket';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import type { EditEvent } from '../Operator';
import style from './OperatorEvent.module.scss';
interface OperatorEventProps {
id: string;
colour: string;
cue: string;
main: string;
@@ -22,6 +25,7 @@ interface OperatorEventProps {
showSeconds: boolean;
isPast: boolean;
selectedRef?: RefObject<HTMLDivElement>;
onLongPress: (event: EditEvent) => void;
}
// extract this to contain re-renders
@@ -32,6 +36,7 @@ function RollingTime() {
function OperatorEvent(props: OperatorEventProps) {
const {
id,
colour,
cue,
main,
@@ -46,8 +51,17 @@ function OperatorEvent(props: OperatorEventProps) {
showSeconds,
isPast,
selectedRef,
onLongPress,
} = props;
const handleLongPress = (event?: SyntheticEvent) => {
// we dont have an event out of useLongPress
event?.preventDefault();
onLongPress({ id, cue, fieldLabel: subscribedAlias, fieldValue: subscribed ?? '' });
};
const mouseHandlers = useLongPress(handleLongPress, { threshold: 800 });
const start = formatTime(timeStart, { showSeconds });
const end = formatTime(timeEnd, { showSeconds });
@@ -61,7 +75,7 @@ function OperatorEvent(props: OperatorEventProps) {
]);
return (
<div className={operatorClasses} ref={selectedRef}>
<div className={operatorClasses} ref={selectedRef} onContextMenu={handleLongPress} {...mouseHandlers}>
<div className={style.binder} style={{ ...cueColours }}>
<span className={style.cue}>{cue}</span>
</div>
@@ -15,16 +15,19 @@
background-color: $gray-1350;
z-index: 2;
padding: 0.5rem 1rem;
border-bottom: 1px solid $white-10;
box-shadow: $large-top-drawer-shadow;
}
.timers {
display: grid;
padding: 0.5rem 1rem;
grid-template-areas:
"playback timer1B timer2B timer3B";
grid-template-columns: 1fr auto auto auto;
column-gap: 1.5rem;
align-items: center;
}
.playbackIcon {
@@ -83,7 +86,7 @@
// tablet
@media (min-width: $min-tablet) {
.statusBar {
.timers {
grid-template-areas:
"playback timer1B timer2A timer3A"
"title title timer2B timer3B";
@@ -103,3 +106,7 @@
display: flex;
}
}
.progressOverride {
border-radius: 0;
}
@@ -1,11 +1,9 @@
import { useMemo } from 'react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import { useTimer } from '../../../common/hooks/useSocket';
import { cx } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import StatusBarProgress from './StatusBarProgress';
import StatusBarTimers from './StatusBarTimers';
import styles from './StatusBar.module.scss';
@@ -22,73 +20,20 @@ interface StatusBarProps {
export default function StatusBar(props: StatusBarProps) {
const { projectTitle, playback, selectedEventId, firstStart, firstId, lastEnd, lastId } = props;
const timer = useTimer();
const getTimeStart = () => {
if (firstStart === undefined) {
return '...';
}
if (selectedEventId) {
if (firstId === selectedEventId) {
return millisToString(timer.expectedFinish);
}
}
return millisToString(firstStart);
};
const getTimeEnd = () => {
if (lastEnd === undefined) {
return '...';
}
if (selectedEventId) {
if (lastId === selectedEventId) {
return millisToString(timer.expectedFinish);
}
}
return millisToString(lastEnd);
};
// use user defined format
const timeNow = formatTime(timer.clock, {
showSeconds: true,
});
const runningTime = millisToString(timer.current);
const elapsedTime = millisToString(timer.elapsed);
const PlaybackIconComponent = useMemo(() => {
const isPlaying = playback === Playback.Play || playback === Playback.Roll;
const classes = cx([styles.playbackIcon, isPlaying ? styles.active : null]);
return <PlaybackIcon state={playback} skipTooltip className={classes} />;
}, [playback]);
const { data } = useViewSettings();
return (
<div className={styles.statusBar}>
{PlaybackIconComponent}
<div className={styles.timeNow}>
<span className={styles.label}>Time now</span>
<span className={styles.timer}>{timeNow}</span>
</div>
<div className={styles.elapsedTime}>
<span className={styles.label}>Elapsed time</span>
<span className={styles.timer}>{elapsedTime}</span>
</div>
<div className={styles.runningTime}>
<span className={styles.label}>Running timer</span>
<span className={styles.timer}>{runningTime}</span>
</div>
<span className={styles.title}>{projectTitle}</span>
<div className={styles.startTime}>
<span className={styles.label}>Scheduled start</span>
<span className={styles.timer}>{getTimeStart()}</span>
</div>
<div className={styles.endTime}>
<span className={styles.label}>Scheduled end</span>
<span className={styles.timer}>{getTimeEnd()}</span>
</div>
<StatusBarTimers
projectTitle={projectTitle}
playback={playback}
selectedEventId={selectedEventId}
firstStart={firstStart}
firstId={firstId}
lastEnd={lastEnd}
lastId={lastId}
/>
{data && <StatusBarProgress viewSettings={data} />}
</div>
);
}
@@ -0,0 +1,30 @@
import { ViewSettings } from 'ontime-types';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import { useTimer } from '../../../common/hooks/useSocket';
import styles from './StatusBar.module.scss';
interface StatusBarProgressProps {
viewSettings: ViewSettings;
}
export default function StatusBarProgress(props: StatusBarProgressProps) {
const { viewSettings } = props;
const timer = useTimer();
const totalTime = (timer.duration ?? 0) + (timer.addedTime ?? 0);
return (
<MultiPartProgressBar
now={timer.current}
complete={totalTime}
normalColor={viewSettings.normalColor}
warning={viewSettings.warningThreshold}
warningColor={viewSettings.warningColor}
danger={viewSettings.dangerThreshold}
dangerColor={viewSettings.dangerColor}
className={styles.progressOverride}
/>
);
}
@@ -0,0 +1,94 @@
import { useMemo } from 'react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import { useTimer } from '../../../common/hooks/useSocket';
import { cx } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import styles from './StatusBar.module.scss';
interface StatusBarTimersProps {
projectTitle: string;
playback: Playback;
selectedEventId: string | null;
firstStart?: number;
firstId?: string;
lastEnd?: number;
lastId?: string;
}
export default function StatusBarTimers(props: StatusBarTimersProps) {
const { projectTitle, playback, selectedEventId, firstStart, firstId, lastEnd, lastId } = props;
const timer = useTimer();
const getTimeStart = () => {
if (firstStart === undefined) {
return '...';
}
if (selectedEventId) {
if (firstId === selectedEventId) {
return millisToString(timer.expectedFinish);
}
}
return millisToString(firstStart);
};
const getTimeEnd = () => {
if (lastEnd === undefined) {
return '...';
}
if (selectedEventId) {
if (lastId === selectedEventId) {
return millisToString(timer.expectedFinish);
}
}
return millisToString(lastEnd);
};
const PlaybackIconComponent = useMemo(() => {
const isPlaying = playback === Playback.Play || playback === Playback.Roll;
const classes = cx([styles.playbackIcon, isPlaying ? styles.active : null]);
return <PlaybackIcon state={playback} skipTooltip className={classes} />;
}, [playback]);
// use user defined format
const timeNow = formatTime(timer.clock, {
showSeconds: true,
});
const runningTime = millisToString(timer.current);
const elapsedTime = millisToString(timer.elapsed);
return (
<div className={styles.timers}>
{PlaybackIconComponent}
<div className={styles.timeNow}>
<span className={styles.label}>Time now</span>
<span className={styles.timer}>{timeNow}</span>
</div>
<div className={styles.elapsedTime}>
<span className={styles.label}>Elapsed time</span>
<span className={styles.timer}>{elapsedTime}</span>
</div>
<div className={styles.runningTime}>
<span className={styles.label}>Running timer</span>
<span className={styles.timer}>{runningTime}</span>
</div>
<span className={styles.title}>{projectTitle}</span>
<div className={styles.startTime}>
<span className={styles.label}>Scheduled start</span>
<span className={styles.timer}>{getTimeStart()}</span>
</div>
<div className={styles.endTime}>
<span className={styles.label}>Scheduled end</span>
<span className={styles.timer}>{getTimeEnd()}</span>
</div>
</div>
);
}
@@ -1,10 +1,11 @@
import { ComponentType, useMemo } from 'react';
import { TimeManagerType } from 'common/models/TimeManager.type';
import { Message, OntimeEvent, ProjectData, SupportedEvent, TimerMessage, ViewSettings } from 'ontime-types';
import { Message, OntimeEvent, ProjectData, Settings, SupportedEvent, TimerMessage, ViewSettings } from 'ontime-types';
import { useStore } from 'zustand';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import useViewSettings from '../../common/hooks-query/useViewSettings';
import { runtime } from '../../common/stores/runtime';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
@@ -27,6 +28,7 @@ type WithDataProps = {
nextId: string | null;
general: ProjectData;
viewSettings: ViewSettings;
settings: Settings | undefined;
onAir: boolean;
};
@@ -43,6 +45,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
const { data: rundownData } = useRundown();
const { data: project } = useProjectData();
const { data: viewSettings } = useViewSettings();
const { data: settings } = useSettings();
const publicEvents = useMemo(() => {
if (Array.isArray(rundownData)) {
@@ -104,6 +107,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
selectedId={selectedId}
publicSelectedId={publicSelectedId}
viewSettings={viewSettings}
settings={settings}
nextId={nextId}
general={project}
onAir={onAir}
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion';
import { Message, OntimeEvent, ProjectData, SupportedEvent, ViewSettings } from 'ontime-types';
import { Message, OntimeEvent, ProjectData, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
@@ -11,7 +11,7 @@ import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
import TitleCard from '../../../common/components/title-card/TitleCard';
import { BACKSTAGE_OPTIONS } from '../../../common/components/view-params-editor/constants';
import { getBackstageOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
@@ -37,10 +37,12 @@ interface BackstageProps {
selectedId: string | null;
general: ProjectData;
viewSettings: ViewSettings;
settings: Settings | undefined;
}
export default function Backstage(props: BackstageProps) {
const { isMirrored, publ, eventNow, eventNext, time, backstageEvents, selectedId, general, viewSettings } = props;
const { isMirrored, publ, eventNow, eventNext, time, backstageEvents, selectedId, general, viewSettings, settings } =
props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
const [blinkClass, setBlinkClass] = useState(false);
@@ -89,11 +91,12 @@ export default function Backstage(props: BackstageProps) {
}
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
const backstageOptions = getBackstageOptions(settings?.timeFormat ?? '24');
return (
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={BACKSTAGE_OPTIONS} />
<ViewParamsEditor paramFields={backstageOptions} />
<div className='project-header'>
{general.title}
<div className='clock-container'>
@@ -1,10 +1,10 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ViewSettings } from 'ontime-types';
import { Settings, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { CLOCK_OPTIONS } from '../../../common/components/view-params-editor/constants';
import { getClockOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
@@ -18,6 +18,7 @@ interface ClockProps {
isMirrored: boolean;
time: TimeManagerType;
viewSettings: ViewSettings;
settings: Settings | undefined;
}
const formatOptions = {
@@ -26,7 +27,7 @@ const formatOptions = {
};
export default function Clock(props: ClockProps) {
const { isMirrored, time, viewSettings } = props;
const { isMirrored, time, viewSettings, settings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
@@ -124,6 +125,8 @@ export default function Clock(props: ClockProps) {
const clock = formatTime(time.clock, formatOptions);
const clean = clock.replace('/:/g', '');
const clockOptions = getClockOptions(settings?.timeFormat ?? '24');
return (
<div
className={`clock-view ${isMirrored ? 'mirror' : ''}`}
@@ -135,7 +138,7 @@ export default function Clock(props: ClockProps) {
data-testid='clock-view'
>
<NavigationMenu />
<ViewParamsEditor paramFields={CLOCK_OPTIONS} />
<ViewParamsEditor paramFields={clockOptions} />
<SuperscriptTime
time={clock}
className='clock'
@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent, ViewSettings } from 'ontime-types';
import { OntimeEvent, OntimeRundownEntry, Playback, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { TIME_FORMAT_OPTION } from '../../../common/components/view-params-editor/constants';
import { getTimeOption } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
@@ -34,10 +34,11 @@ interface CountdownProps {
time: TimeManagerType;
selectedId: string | null;
viewSettings: ViewSettings;
settings: Settings | undefined;
}
export default function Countdown(props: CountdownProps) {
const { isMirrored, backstageEvents, time, selectedId, viewSettings } = props;
const { isMirrored, backstageEvents, time, selectedId, viewSettings, settings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
const { getLocalizedString } = useTranslation();
@@ -109,10 +110,12 @@ export default function Countdown(props: CountdownProps) {
isSelected || runningMessage === TimerMessage.waiting,
);
const timeOption = getTimeOption(settings?.timeFormat ?? '24');
return (
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={[TIME_FORMAT_OPTION]} />
<ViewParamsEditor paramFields={[timeOption]} />
{follow === null ? (
<CountdownSelect events={backstageEvents} />
) : (
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion';
import { Message, OntimeEvent, ProjectData, ViewSettings } from 'ontime-types';
import { Message, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
@@ -9,7 +9,7 @@ import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
import TitleCard from '../../../common/components/title-card/TitleCard';
import { PUBLIC_OPTIONS } from '../../../common/components/view-params-editor/constants';
import { getPublicOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
@@ -35,11 +35,22 @@ interface BackstageProps {
publicSelectedId: string | null;
general: ProjectData;
viewSettings: ViewSettings;
settings: Settings | undefined;
}
export default function Public(props: BackstageProps) {
const { isMirrored, publ, publicEventNow, publicEventNext, time, events, publicSelectedId, general, viewSettings } =
props;
const {
isMirrored,
publ,
publicEventNow,
publicEventNext,
time,
events,
publicSelectedId,
general,
viewSettings,
settings,
} = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
@@ -56,10 +67,12 @@ export default function Public(props: BackstageProps) {
const clock = formatTime(time.clock, formatOptions);
const qrSize = Math.max(window.innerWidth / 15, 128);
const publicOptions = getPublicOptions(settings?.timeFormat ?? '24');
return (
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={PUBLIC_OPTIONS} />
<ViewParamsEditor paramFields={publicOptions} />
<div className='project-header'>
{general.title}
<div className='clock-container'>
@@ -1,12 +1,12 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import type { OntimeEvent, OntimeRundown, ViewSettings } from 'ontime-types';
import type { OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types';
import { SupportedEvent } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { STUDIO_CLOCK_OPTIONS } from '../../../common/components/view-params-editor/constants';
import { getStudioClockOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import useFitText from '../../../common/hooks/useFitText';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
@@ -32,10 +32,11 @@ interface StudioClockProps {
nextId: string | null;
onAir: boolean;
viewSettings: ViewSettings;
settings: Settings | undefined;
}
export default function StudioClock(props: StudioClockProps) {
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, viewSettings } = props;
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, viewSettings, settings } = props;
// deferring rendering seems to affect styling (font and useFitText)
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
@@ -75,10 +76,12 @@ export default function StudioClock(props: StudioClockProps) {
const secondsNow = secondsInMillis(time.clock);
const isNegative = (time.current ?? 0) < 0;
const studioClockOptions = getStudioClockOptions(settings?.timeFormat ?? '24');
return (
<div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={STUDIO_CLOCK_OPTIONS} />
<ViewParamsEditor paramFields={studioClockOptions} />
<div className='clock-container'>
<div className={`studio-timer ${showSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
<div
@@ -1,16 +1,18 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { Message, OntimeEvent, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { Message, OntimeEvent, Playback, Settings, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import TitleCard from '../../../common/components/title-card/TitleCard';
import { TIMER_OPTIONS } from '../../../common/components/view-params-editor/constants';
import { getTimerOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { isStringBoolean } from '../../../common/utils/viewUtils';
import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { formatTimerDisplay, getTimerByType } from '../common/viewerUtils';
@@ -46,12 +48,15 @@ interface TimerProps {
eventNext: OntimeEvent | null;
time: TimeManagerType;
viewSettings: ViewSettings;
settings: Settings | undefined;
}
export default function Timer(props: TimerProps) {
const { isMirrored, pres, eventNow, eventNext, time, viewSettings, external } = props;
const { isMirrored, pres, eventNow, eventNext, time, viewSettings, external, settings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams();
useEffect(() => {
document.title = 'ontime - Timer';
}, []);
@@ -61,6 +66,26 @@ export default function Timer(props: TimerProps) {
return null;
}
// USER OPTIONS
const userOptions = {
hideClock: false,
hideCards: false,
hideProgress: false,
hideMessage: false,
};
const hideClock = searchParams.get('hideClock');
userOptions.hideClock = isStringBoolean(hideClock);
const hideCards = searchParams.get('hideCards');
userOptions.hideCards = isStringBoolean(hideCards);
const hideProgress = searchParams.get('hideProgress');
userOptions.hideProgress = isStringBoolean(hideProgress);
const hideMessage = searchParams.get('hideMessage');
userOptions.hideMessage = isStringBoolean(hideMessage);
const clock = formatTime(time.clock, formatOptions);
const showOverlay = pres.text !== '' && pres.visible;
const isPlaying = time.playback !== Playback.Pause;
@@ -100,22 +125,28 @@ export default function Timer(props: TimerProps) {
if (showExternal) {
timerFontSize *= 0.8;
}
const externalFontSize = timerFontSize * 0.4
const externalFontSize = timerFontSize * 0.4;
const timerContainerClasses = `timer-container ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`;
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
const timerOptions = getTimerOptions(settings?.timeFormat ?? '24');
return (
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={TIMER_OPTIONS} />
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
</div>
<ViewParamsEditor paramFields={timerOptions} />
{!userOptions.hideMessage && (
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
</div>
)}
<div className={`clock-container ${showClock ? '' : 'clock-container--hidden'}`}>
<div className='label'>{getLocalizedString('common.time_now')}</div>
<SuperscriptTime time={clock} className='clock' />
</div>
{!userOptions.hideClock && (
<div className={`clock-container ${showClock ? '' : 'clock-container--hidden'}`}>
<div className='label'>{getLocalizedString('common.time_now')}</div>
<SuperscriptTime time={clock} className='clock' />
</div>
)}
<div className={timerContainerClasses}>
{showEndMessage ? (
@@ -139,52 +170,63 @@ export default function Timer(props: TimerProps) {
</div>
</div>
<MultiPartProgressBar
className={isPlaying ? 'progress-container' : 'progress-container progress-container--paused'}
now={time.current || 0}
complete={totalTime}
normalColor={viewSettings.normalColor}
warning={viewSettings.warningThreshold}
warningColor={viewSettings.warningColor}
danger={viewSettings.dangerThreshold}
dangerColor={viewSettings.dangerColor}
hidden={!showProgress}
/>
{!userOptions.hideProgress && (
<MultiPartProgressBar
className={isPlaying ? 'progress-container' : 'progress-container progress-container--paused'}
now={time.current}
complete={totalTime}
normalColor={viewSettings.normalColor}
warning={viewSettings.warningThreshold}
warningColor={viewSettings.warningColor}
danger={viewSettings.dangerThreshold}
dangerColor={viewSettings.dangerColor}
hidden={!showProgress}
/>
)}
<AnimatePresence>
{eventNow && !finished && (
<motion.div
className='event now'
key='now'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<TitleCard label='now' title={eventNow.title} subtitle={eventNow.subtitle} presenter={eventNow.presenter} />
</motion.div>
)}
</AnimatePresence>
{!userOptions.hideCards && (
<>
<AnimatePresence>
{eventNow && !finished && (
<motion.div
className='event now'
key='now'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<TitleCard
label='now'
title={eventNow.title}
subtitle={eventNow.subtitle}
presenter={eventNow.presenter}
/>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{eventNext && (
<motion.div
className='event next'
key='next'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<TitleCard
label='next'
title={eventNext.title}
subtitle={eventNext.subtitle}
presenter={eventNext.presenter}
/>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{eventNext && (
<motion.div
className='event next'
key='next'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<TitleCard
label='next'
title={eventNext.title}
subtitle={eventNext.subtitle}
presenter={eventNext.presenter}
/>
</motion.div>
)}
</AnimatePresence>
</>
)}
</div>
);
}
-17
View File
@@ -2,23 +2,6 @@
//////////////////////////////////// general app elements
@mixin main-container {
background-color: $bg-container-l1;
border: 1px solid $bg-container-l1;
border-radius: 4px;
}
@mixin second-container {
background-color: $bg-container-l2;
border-radius: 4px;
}
@mixin third-container {
background-color: $bg-container-l3;
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 2px;
}
@mixin action-link {
color: $action-text-color;
display: flex;
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "2.16.2",
"version": "2.21.3",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -21,6 +21,7 @@
"scripts": {
"postinstall": "",
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"dev:electron": "cross-env NODE_ENV=development electron .",
"dist-win": "electron-builder --publish=never --x64 --win",
"dist-mac": "electron-builder --publish=never --mac",
+2 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "2.16.2",
"version": "2.21.3",
"exports": "./src/index.js",
"dependencies": {
"body-parser": "^1.20.0",
@@ -54,6 +54,7 @@
"build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs",
"build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --outfile=dist/index.cjs",
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"test": "cross-env IS_TEST=true vitest",
"test:pipeline": "cross-env IS_TEST=true vitest run",
"typecheck": "tsc --noEmit",
+27 -3
View File
@@ -3,7 +3,7 @@ import { LogOrigin, OSCSettings } from 'ontime-types';
import { Server } from 'node-osc';
import { IAdapter } from './IAdapter.js';
import { dispatchFromAdapter } from '../controllers/integrationController.js';
import { dispatchFromAdapter, type ChangeOptions } from '../controllers/integrationController.js';
import { logger } from '../classes/Logger.js';
export class OscServer implements IAdapter {
@@ -36,12 +36,36 @@ export class OscServer implements IAdapter {
return;
}
let transformedPayload: unknown = args;
// we need to transform the params for the change endpoint
// OSC: ontime/change/{eventID}/{propertyName} value
if (path === 'change') {
if (params.length < 2) {
logger.error(LogOrigin.Rx, 'OSC IN: No params provided for change');
return;
}
if (args === undefined) {
logger.error(LogOrigin.Rx, 'OSC IN: No valid payload provided for change');
return;
}
const eventId = params[0];
const property = params[1];
const value: string | number | boolean = args as string | number | boolean;
transformedPayload = {
eventId,
property,
value,
} satisfies ChangeOptions;
}
try {
const reply = dispatchFromAdapter(
path,
{
payload: args,
params,
payload: transformedPayload,
},
'osc',
);
+2 -1
View File
@@ -17,6 +17,7 @@
import { LogOrigin } from 'ontime-types';
import { WebSocket, WebSocketServer } from 'ws';
import type { Server } from 'http';
import getRandomName from '../utils/getRandomName.js';
import { IAdapter } from './IAdapter.js';
@@ -43,7 +44,7 @@ export class SocketServer implements IAdapter {
this.wss = null;
}
init(server) {
init(server: Server) {
this.wss = new WebSocketServer({ path: '/ws', server });
this.wss.on('connection', (ws) => {
+3 -3
View File
@@ -3,7 +3,7 @@ import { HTTPSettings, LogOrigin, OSCSettings } from 'ontime-types';
import 'dotenv/config';
import express from 'express';
import expressStaticGzip from 'express-static-gzip';
import http from 'http';
import http, { type Server } from 'http';
import cors from 'cors';
// import utils
@@ -108,8 +108,8 @@ enum OntimeStartOrder {
}
let step = OntimeStartOrder.InitAssets;
let expressServer = null;
let oscServer = null;
let expressServer: Server | null = null;
let oscServer: OscServer | null = null;
const checkStart = (currentState: OntimeStartOrder) => {
if (step !== currentState) {
@@ -1,5 +1,3 @@
import { OntimeEvent } from 'ontime-types';
import { messageService } from '../services/message-service/MessageService.js';
import { PlaybackService } from '../services/PlaybackService.js';
import { eventStore } from '../stores/EventStore.js';
@@ -7,19 +5,23 @@ import { parse, updateEvent } from './integrationController.config.js';
import { isKeyOfType } from 'ontime-types/src/utils/guards.js';
import { event } from '../models/eventsDefinition.js';
export type ChangeOptions = {
eventId: string;
property: string;
value: unknown;
};
//TODO: re-throwing the error does not add any extra information or value
export function dispatchFromAdapter(
type: string,
args: {
payload: unknown;
params?: Array<string>;
},
_source?: 'osc' | 'ws',
) {
const payload = args.payload;
const typeComponents = type.toLowerCase().split('/');
const mainType = typeComponents[0];
const params = args.params || [];
switch (mainType) {
case 'test-ontime': {
@@ -267,21 +269,14 @@ export function dispatchFromAdapter(
return { topic: 'timer', payload: timer };
}
// ontime/change/{eventID}/{propertyName}
// WS: {type: 'change', payload: { eventId, property, value } }
case 'change': {
if (params.length < 2) {
throw new Error('Too few parameters, 3 expected');
const { eventId, property, value } = payload as ChangeOptions;
if (!isKeyOfType(property, event)) {
throw new Error(`Cannot update unknown event property ${property}`);
}
if (payload === undefined) {
throw new Error('No payload found');
}
const eventID = params[0];
const propertyName = params[1] as keyof OntimeEvent;
if (!isKeyOfType(propertyName, event)) {
throw new Error(`Cannot update unknown event property ${propertyName}`);
}
const parsedPayload = parse(propertyName, payload);
return updateEvent(eventID, propertyName, parsedPayload);
const parsedPayload = parse(property, value);
return updateEvent(eventId, property, parsedPayload);
}
default: {
@@ -18,6 +18,7 @@ import { deleteAllEvents, notifyChanges } from '../services/rundown-service/Rund
import { deepmerge } from 'ontime-utils';
import { runtimeCacheStore } from '../stores/cachingStore.js';
import { delayedRundownCacheKey } from '../services/rundown-service/delayedRundown.utils.js';
import { integrationService } from '../services/integration-service/IntegrationService.js';
// Create controller for GET request to '/ontime/poll'
// Returns data for current state
@@ -296,10 +297,16 @@ export const postOSC = async (req, res) => {
const oscSettings = req.body;
await DataProvider.setOsc(oscSettings);
integrationService.unregister(oscIntegration);
// TODO: this update could be more granular, checking that relevant data was changed
const { message } = oscIntegration.init(oscSettings);
const { success, message } = oscIntegration.init(oscSettings);
logger.info(LogOrigin.Tx, message);
if (success) {
integrationService.register(oscIntegration);
}
res.send(oscSettings).status(200);
} catch (error) {
res.status(400).send({ message: error.toString() });
@@ -26,7 +26,15 @@ export class OscIntegration implements IIntegration {
* Initializes oscClient
*/
init(config: OSCSettings) {
const { targetIP, portOut, subscriptions } = config;
const { targetIP, portOut, subscriptions, enabledOut } = config;
if (!enabledOut) {
this.oscClient?.close();
return {
success: false,
message: 'OSC output disabled',
};
}
this.initSubscriptions(subscriptions);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "2.16.2",
"version": "2.21.3",
"description": "Time keeping for live events",
"keywords": [
"lighdev",
+2 -1
View File
@@ -7,7 +7,8 @@
"description": "shared typings for ontime",
"scripts": {
"cleanup": "rm -rf .turbo && rm -rf node_modules",
"lint": "eslint . --quiet"
"lint": "eslint . --quiet",
"lint-staged": "eslint"
},
"keywords": [],
"author": "",
+1
View File
@@ -6,6 +6,7 @@
"description": "shared logic for ontime",
"scripts": {
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"test": "vitest",
"test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules"