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