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

This commit is contained in:
Carlos Valente
2023-11-22 21:40:13 +01:00
38 changed files with 421 additions and 148 deletions
@@ -1,7 +1,12 @@
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
import { formatTime } from '../../utils/time'; import { formatTime } from '../../utils/time';
import './Schedule.scss'; import './Schedule.scss';
const formatOptions = {
format: 'hh:mm a',
};
interface ScheduleItemProps { interface ScheduleItemProps {
selected: 'past' | 'now' | 'future'; selected: 'past' | 'now' | 'future';
timeStart: number; timeStart: number;
@@ -16,8 +21,8 @@ interface ScheduleItemProps {
export default function ScheduleItem(props: ScheduleItemProps) { export default function ScheduleItem(props: ScheduleItemProps) {
const { selected, timeStart, timeEnd, title, presenter, backstageEvent, colour, skip } = props; const { selected, timeStart, timeEnd, title, presenter, backstageEvent, colour, skip } = props;
const start = formatTime(timeStart, { format: 'hh:mm' }); const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, { format: 'hh:mm' }); const end = formatTime(timeEnd, formatOptions);
const userColour = colour !== '' ? colour : ''; const userColour = colour !== '' ? colour : '';
const selectStyle = `entry--${selected}`; const selectStyle = `entry--${selected}`;
@@ -25,7 +30,12 @@ export default function ScheduleItem(props: ScheduleItemProps) {
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}> <li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
<div className='entry-times'> <div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: userColour }} /> <span className='entry-colour' style={{ backgroundColor: userColour }} />
{`${start}${end} ${backstageEvent ? '*' : ''}`} <div style={{ display: 'flex' }}>
<SuperscriptTime time={start} />
{' → '}
<SuperscriptTime time={end} />
{backstageEvent ? '*' : ''}
</div>
</div> </div>
<div className='entry-title'>{title}</div> <div className='entry-title'>{title}</div>
{presenter && <div className='entry-presenter'>{presenter}</div>} {presenter && <div className='entry-presenter'>{presenter}</div>}
@@ -59,10 +59,10 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
setSearchParams(defaultSearchParams); setSearchParams(defaultSearchParams);
} }
// linter is asking for `setSearchParams` in the useEffect deps // linter is asking for `setSearchParams` & `storedViewParams` in the useEffect deps
// rule is disabled since adding `setSearchParams` results in unnecessary re-renders // rule is disabled since adding `setSearchParams` & `storedViewParams` results in unnecessary re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [storedViewParams, pathname]); }, [pathname]);
const onEditDrawerClose = () => { const onEditDrawerClose = () => {
onClose(); onClose();
+34 -41
View File
@@ -1,53 +1,46 @@
import { useEffect, useState } from 'react'; import { useSyncExternalStore } from 'react';
/** const STORAGE_EVENT = 'ontime-storage';
* @description utility hook to handle state in local storage
* @param key
* @param initialValue
*/
export const useLocalStorage = <T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] => {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(`ontime-${key}`);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
useEffect(() => { function getSnapshot(key: string): string | null {
const handleStorageChange = (event: StorageEvent) => { try {
if (event.storageArea === window.localStorage && event.key === key) { return window.localStorage.getItem(`ontime-${key}`);
try { } catch {
const newValue = event.newValue ? JSON.parse(event.newValue) : initialValue; return null;
setStoredValue(newValue); }
} catch (_) { }
/* empty */
}
}
};
window.addEventListener('storage', handleStorageChange); function getParsedJson<T>(localStorageValue: string | null, initialValue: T): T {
try {
return localStorageValue ? JSON.parse(localStorageValue) : initialValue;
} catch {
return initialValue;
}
}
return () => { export const useLocalStorage = <T>(key: string, initialValue: T) => {
window.removeEventListener('storage', handleStorageChange); const localStorageValue = useSyncExternalStore(subscribe, () => getSnapshot(key));
}; const parsedLocalStorageValue = getParsedJson(localStorageValue, initialValue);
}, [initialValue, key]);
/** /**
* @description Set value to local storage * @description Set value to local storage
* @param value * @param value
*/ */
const setValue = (value: T | ((val: T) => T)) => { const setLocalStorageValue = (value: T | ((val: T) => T)) => {
try { // Allow value to be a function so we have same API as useState
// Allow value to be a function so we have same API as useState const valueToStore = value instanceof Function ? value(parsedLocalStorageValue) : value;
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore); localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
window.localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore)); window.dispatchEvent(new StorageEvent(STORAGE_EVENT));
} catch (error) {
console.error(error);
}
}; };
return [storedValue, setValue];
return [parsedLocalStorageValue, setLocalStorageValue] as const;
}; };
function subscribe(callback: () => void) {
window.addEventListener(STORAGE_EVENT, callback);
return () => {
window.removeEventListener(STORAGE_EVENT, callback);
};
}
@@ -27,6 +27,7 @@ export const useMessageControl = () => {
timerMessage: state.timerMessage, timerMessage: state.timerMessage,
publicMessage: state.publicMessage, publicMessage: state.publicMessage,
lowerMessage: state.lowerMessage, lowerMessage: state.lowerMessage,
externalMessage: state.externalMessage,
onAir: state.onAir, onAir: state.onAir,
}); });
@@ -40,6 +41,8 @@ export const setMessage = {
publicVisible: (payload: boolean) => socketSendJson('set-public-message-visible', payload), publicVisible: (payload: boolean) => socketSendJson('set-public-message-visible', payload),
lowerText: (payload: string) => socketSendJson('set-lower-message-text', payload), lowerText: (payload: string) => socketSendJson('set-lower-message-text', payload),
lowerVisible: (payload: boolean) => socketSendJson('set-lower-message-visible', payload), lowerVisible: (payload: boolean) => socketSendJson('set-lower-message-visible', payload),
externalText: (payload: string) => socketSendJson('set-external-message-text', payload),
externalVisible: (payload: boolean) => socketSendJson('set-external-message-visible', payload),
onAir: (payload: boolean) => socketSendJson('set-onAir', payload), onAir: (payload: boolean) => socketSendJson('set-onAir', payload),
timerBlink: (payload: boolean) => socketSendJson('set-timer-blink', payload), timerBlink: (payload: boolean) => socketSendJson('set-timer-blink', payload),
timerBlackout: (payload: boolean) => socketSendJson('set-timer-blackout', payload), timerBlackout: (payload: boolean) => socketSendJson('set-timer-blackout', payload),
+5 -1
View File
@@ -3,7 +3,7 @@ import { Playback, RuntimeStore } from 'ontime-types';
import { useStore } from 'zustand'; import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla'; import { createStore } from 'zustand/vanilla';
export const runtimeStorePlaceholder = { export const runtimeStorePlaceholder: RuntimeStore = {
timer: { timer: {
clock: 0, clock: 0,
current: null, current: null,
@@ -33,6 +33,10 @@ export const runtimeStorePlaceholder = {
text: '', text: '',
visible: false, visible: false,
}, },
externalMessage: {
text: '',
visible: false,
},
onAir: false, onAir: false,
loaded: { loaded: {
numEvents: 0, numEvents: 0,
@@ -26,4 +26,14 @@ describe('formatTime()', () => {
const time = formatTime(ms); const time = formatTime(ms);
expect(time).toStrictEqual('...'); expect(time).toStrictEqual('...');
}); });
it('shows 12h format without times', () => {
const ms = 13 * 60 * 60 * 1000;
const options = {
showSeconds: false,
format: 'hh:mm a',
};
const time = formatTime(ms, options, () => '12');
expect(time).toStrictEqual('01:00 PM');
});
}); });
@@ -1,4 +1,4 @@
import { Input } from '@chakra-ui/react'; import { IconButton, Input } from '@chakra-ui/react';
import { IoEye } from '@react-icons/all-files/io5/IoEye'; import { IoEye } from '@react-icons/all-files/io5/IoEye';
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline'; import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
@@ -13,13 +13,14 @@ interface InputRowProps {
placeholder: string; placeholder: string;
text: string; text: string;
visible?: boolean; visible?: boolean;
readonly?: boolean;
actionHandler: (action: string, payload: object) => void; actionHandler: (action: string, payload: object) => void;
changeHandler: (newValue: string) => void; changeHandler: (newValue: string) => void;
className?: string; className?: string;
} }
export default function InputRow(props: InputRowProps) { export default function InputRow(props: InputRowProps) {
const { label, placeholder, text, visible, actionHandler, changeHandler, className } = props; const { label, placeholder, text, visible, actionHandler, changeHandler, className, readonly } = props;
const handleInputChange = (newValue: string) => { const handleInputChange = (newValue: string) => {
changeHandler(newValue); changeHandler(newValue);
@@ -33,19 +34,31 @@ export default function InputRow(props: InputRowProps) {
<Input <Input
size='sm' size='sm'
variant='ontime-filled' variant='ontime-filled'
readOnly={readonly}
disabled={readonly}
value={text} value={text}
onChange={(event) => handleInputChange(event.target.value)} onChange={(event) => handleInputChange(event.target.value)}
placeholder={placeholder} placeholder={placeholder}
/> />
<TooltipActionBtn {readonly ? (
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })} <IconButton
tooltip={visible ? 'Make invisible' : 'Make visible'} size='sm'
aria-label={`Toggle ${label}`} isDisabled
openDelay={tooltipDelayMid} icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />} aria-label={`Toggle ${label}`}
variant={visible ? 'ontime-filled' : 'ontime-subtle'} variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm' />
/> ) : (
<TooltipActionBtn
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label={`Toggle ${label}`}
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
)}
</div> </div>
</div> </div>
); );
@@ -27,7 +27,3 @@
color: $action-text-color; color: $action-text-color;
} }
} }
.padTop {
margin-top: $section-spacing;
}
@@ -34,34 +34,48 @@ export default function MessageControl() {
actionHandler={() => setMessage.lowerVisible(!data.lowerMessage.visible)} actionHandler={() => setMessage.lowerVisible(!data.lowerMessage.visible)}
/> />
<InputRow <InputRow
label='Timer message' label='Timer'
placeholder='Shown in stage timer' placeholder='Message shown in stage timer'
text={data.timerMessage.text || ''} text={data.timerMessage.text || ''}
visible={data.timerMessage.visible || false} visible={data.timerMessage.visible || false}
changeHandler={(newValue) => setMessage.presenterText(newValue)} changeHandler={(newValue) => setMessage.presenterText(newValue)}
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)} actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
/> />
<div className={style.buttonSection}> <div className={style.buttonSection}>
<label className={style.label}>Timer message blink</label>
<label className={style.label}>Blackout timer screens</label>
<Button <Button
size='sm'
className={`${data.timerMessage.timerBlink ? style.blink : ''}`} className={`${data.timerMessage.timerBlink ? style.blink : ''}`}
variant={data.timerMessage.timerBlink ? 'ontime-filled' : 'ontime-subtle'} variant={data.timerMessage.timerBlink ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={data.timerMessage.timerBlink ? <IoSunny size='24px' /> : <IoSunnyOutline size='24px' />} leftIcon={data.timerMessage.timerBlink ? <IoSunny size='1rem' /> : <IoSunnyOutline size='1rem' />}
onClick={() => setMessage.timerBlink(!data.timerMessage.timerBlink)} onClick={() => setMessage.timerBlink(!data.timerMessage.timerBlink)}
data-testid='toggle timer blink' data-testid='toggle timer blink'
/> >
Blink message
</Button>
<Button <Button
size='sm'
className={style.blackoutButton} className={style.blackoutButton}
variant={data.timerMessage.timerBlackout ? 'ontime-filled' : 'ontime-subtle'} variant={data.timerMessage.timerBlackout ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={data.timerMessage.timerBlackout ? <IoEye size='24px' /> : <IoEyeOffOutline size='24px' />} leftIcon={data.timerMessage.timerBlackout ? <IoEye size='1rem' /> : <IoEyeOffOutline size='1rem' />}
onClick={() => setMessage.timerBlackout(!data.timerMessage.timerBlackout)} onClick={() => setMessage.timerBlackout(!data.timerMessage.timerBlackout)}
data-testid='toggle timer blackout' data-testid='toggle timer blackout'
/> >
Blackout screen
</Button>
</div> </div>
<div className={`${style.onAirSection} ${style.padTop}`}> <InputRow
label='External Message'
placeholder='-'
readonly
text={data.externalMessage.text || ''}
visible={data.externalMessage.visible || false}
changeHandler={() => undefined}
actionHandler={() => undefined}
/>
<div className={style.onAirSection}>
<label className={style.label}>Toggle On Air state</label> <label className={style.label}>Toggle On Air state</label>
<Button <Button
size='sm'
variant={data.onAir ? 'ontime-filled' : 'ontime-subtle'} variant={data.onAir ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={data.onAir ? <IoMicSharp size='24px' /> : <IoMicOffOutline size='24px' />} leftIcon={data.onAir ? <IoMicSharp size='24px' /> : <IoMicOffOutline size='24px' />}
onClick={() => setMessage.onAir(!data.onAir)} onClick={() => setMessage.onAir(!data.onAir)}
@@ -24,10 +24,7 @@ interface CuesheetProps {
} }
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) { export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
const followSelected = useCuesheetSettings((state) => state.followSelected); const { followSelected, showSettings, showDelayBlock, showPrevious } = useCuesheetSettings();
const showSettings = useCuesheetSettings((state) => state.showSettings);
const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock);
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
const [columnVisibility, setColumnVisibility] = useLocalStorage('table-hidden', {}); const [columnVisibility, setColumnVisibility] = useLocalStorage('table-hidden', {});
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder); const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
@@ -66,7 +63,9 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
setColumnSizing({}); setColumnSizing({});
}; };
const headerGroups = table.getHeaderGroups; const headerGroups = table.getHeaderGroups();
const rowModel = table.getRowModel();
const allLeafColumns = table.getAllLeafColumns();
let eventIndex = 0; let eventIndex = 0;
let isPast = Boolean(selectedId); let isPast = Boolean(selectedId);
@@ -75,7 +74,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
<> <>
{showSettings && ( {showSettings && (
<CuesheetTableSettings <CuesheetTableSettings
columns={table.getAllLeafColumns()} columns={allLeafColumns}
handleResetResizing={resetColumnResizing} handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder} handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible} handleClearToggles={setAllVisible}
@@ -85,7 +84,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
<table className={style.cuesheet}> <table className={style.cuesheet}>
<CuesheetHeader headerGroups={headerGroups} /> <CuesheetHeader headerGroups={headerGroups} />
<tbody> <tbody>
{table.getRowModel().rows.map((row) => { {rowModel.rows.map((row) => {
const key = row.original.id; const key = row.original.id;
const isSelected = selectedId === key; const isSelected = selectedId === key;
if (isSelected) { if (isSelected) {
@@ -23,7 +23,7 @@ import { SortableCell } from './SortableCell';
import style from '../Cuesheet.module.scss'; import style from '../Cuesheet.module.scss';
interface CuesheetHeaderProps { interface CuesheetHeaderProps {
headerGroups: () => HeaderGroup<OntimeRundownEntry>[]; headerGroups: HeaderGroup<OntimeRundownEntry>[];
} }
function CuesheetHeader(props: CuesheetHeaderProps) { function CuesheetHeader(props: CuesheetHeaderProps) {
@@ -75,7 +75,7 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
return ( return (
<thead className={style.tableHeader}> <thead className={style.tableHeader}>
{headerGroups().map((headerGroup) => { {headerGroups.map((headerGroup) => {
const key = headerGroup.id; const key = headerGroup.id;
return ( return (
@@ -22,12 +22,14 @@ interface CuesheetTableSettingsProps {
function CuesheetTableSettings(props: CuesheetTableSettingsProps) { function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props; const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
const showPrevious = useCuesheetSettings((state) => state.showPrevious); const {
const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility); showPrevious,
const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock); toggleDelayVisibility,
const toggleDelayVisibility = useCuesheetSettings((state) => state.toggleDelayVisibility); showDelayBlock,
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes); showDelayedTimes,
const toggleDelayedTimes = useCuesheetSettings((state) => state.toggleDelayedTimes); toggleDelayedTimes,
togglePreviousVisibility,
} = useCuesheetSettings();
return ( return (
<div className={style.tableSettings}> <div className={style.tableSettings}>
@@ -112,6 +112,7 @@ export default function AliasesForm() {
icon={<IoRemove />} icon={<IoRemove />}
colorScheme='red' colorScheme='red'
isDisabled={disableInputs} isDisabled={disableInputs}
data-testid={`field__delete_${index}`}
/> />
<Input <Input
{...inputProps} {...inputProps}
@@ -121,6 +122,7 @@ export default function AliasesForm() {
variant='ontime-filled-on-light' variant='ontime-filled-on-light'
placeholder='URL Alias' placeholder='URL Alias'
isDisabled={disableInputs} isDisabled={disableInputs}
data-testid={`field__alias_${index}`}
/> />
<Input <Input
{...inputProps} {...inputProps}
@@ -130,6 +132,7 @@ export default function AliasesForm() {
variant='ontime-filled-on-light' variant='ontime-filled-on-light'
placeholder='URL (portion after ontime Port)' placeholder='URL (portion after ontime Port)'
isDisabled={disableInputs} isDisabled={disableInputs}
data-testid={`field__url_${index}`}
/> />
<TooltipActionBtn <TooltipActionBtn
clickHandler={(event) => handleLinks(event, alias.alias)} clickHandler={(event) => handleLinks(event, alias.alias)}
@@ -140,8 +143,14 @@ export default function AliasesForm() {
icon={<IoOpenOutline />} icon={<IoOpenOutline />}
colorScheme='red' colorScheme='red'
isDisabled={disableInputs} isDisabled={disableInputs}
data-testid={`field__test_${index}`}
/>
<Switch
{...register(`aliases.${index}.enabled`)}
variant='ontime-on-light'
isDisabled={disableInputs}
data-testid={`field__enable_${index}`}
/> />
<Switch {...register(`aliases.${index}.enabled`)} variant='ontime-on-light' isDisabled={disableInputs} />
</li> </li>
); );
})} })}
@@ -14,6 +14,7 @@ type WithDataProps = {
pres: TimerMessage; pres: TimerMessage;
publ: Message; publ: Message;
lower: Message; lower: Message;
external: Message;
eventNow: OntimeEvent | null; eventNow: OntimeEvent | null;
publicEventNow: OntimeEvent | null; publicEventNow: OntimeEvent | null;
eventNext: OntimeEvent | null; eventNext: OntimeEvent | null;
@@ -51,12 +52,12 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
}, [rundownData]); }, [rundownData]);
// websocket data // websocket data
const data = useStore(runtime);
const { const {
timer, timer,
publicMessage, publicMessage,
timerMessage, timerMessage,
lowerMessage, lowerMessage,
externalMessage,
playback, playback,
onAir, onAir,
eventNext, eventNext,
@@ -64,7 +65,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
publicEventNow, publicEventNow,
eventNow, eventNow,
loaded, loaded,
} = data; } = useStore(runtime);
const publicSelectedId = loaded.selectedPublicEventId; const publicSelectedId = loaded.selectedPublicEventId;
const selectedId = loaded.selectedEventId; const selectedId = loaded.selectedEventId;
const nextId = loaded.nextEventId; const nextId = loaded.nextEventId;
@@ -92,6 +93,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
pres={timerMessage} pres={timerMessage}
publ={publicMessage} publ={publicMessage}
lower={lowerMessage} lower={lowerMessage}
external={externalMessage}
eventNow={eventNow} eventNow={eventNow}
publicEventNow={publicEventNow} publicEventNow={publicEventNow}
eventNext={eventNext} eventNext={eventNext}
@@ -60,6 +60,7 @@
font-weight: 600; font-weight: 600;
color: var(--secondary-color-override, $viewer-secondary-color); color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em; letter-spacing: 0.05em;
line-height: 0.95em;
} }
.message { .message {
@@ -18,6 +18,7 @@ import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider'; import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation'; import { titleVariants } from '../common/animation';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import './Backstage.scss'; import './Backstage.scss';
@@ -97,7 +98,7 @@ export default function Backstage(props: BackstageProps) {
{general.title} {general.title}
<div className='clock-container'> <div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div> <div className='label'>{getLocalizedString('common.time_now')}</div>
<div className='time'>{clock}</div> <SuperscriptTime time={clock} className='time' />
</div> </div>
</div> </div>
@@ -128,12 +129,16 @@ export default function Backstage(props: BackstageProps) {
<div className='timer-group'> <div className='timer-group'>
<div className='aux-timers'> <div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div> <div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
<div className='aux-timers__value'>{startedAt}</div> <SuperscriptTime time={startedAt} className='aux-timers__value' />
</div> </div>
<div className='timer-gap' /> <div className='timer-gap' />
<div className='aux-timers'> <div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.expected_finish')}</div> <div className='aux-timers__label'>{getLocalizedString('common.expected_finish')}</div>
<div className='aux-timers__value'>{expectedFinish}</div> {isNegative ? (
<div className='aux-timers__value'>{expectedFinish}</div>
) : (
<SuperscriptTime time={startedAt} className='aux-timers__value' />
)}
</div> </div>
<div className='timer-gap' /> <div className='timer-gap' />
<div className='aux-timers'> <div className='aux-timers'>
@@ -10,6 +10,7 @@ import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet
import { TimeManagerType } from '../../../common/models/TimeManager.type'; import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { OverridableOptions } from '../../../common/models/View.types'; import { OverridableOptions } from '../../../common/models/View.types';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import './Clock.scss'; import './Clock.scss';
@@ -135,7 +136,8 @@ export default function Clock(props: ClockProps) {
> >
<NavigationMenu /> <NavigationMenu />
<ViewParamsEditor paramFields={CLOCK_OPTIONS} /> <ViewParamsEditor paramFields={CLOCK_OPTIONS} />
<div <SuperscriptTime
time={clock}
className='clock' className='clock'
style={{ style={{
color: userOptions.textColour, color: userOptions.textColour,
@@ -145,9 +147,7 @@ export default function Clock(props: ClockProps) {
left: userOptions.left, left: userOptions.left,
backgroundColor: userOptions.textBackground, backgroundColor: userOptions.textBackground,
}} }}
> />
{clock}
</div>
</div> </div>
); );
} }
@@ -0,0 +1,4 @@
sup.period {
top: -1em;
font-size: 0.4em;
}
@@ -0,0 +1,23 @@
import { CSSProperties } from 'react';
import './SuperscriptTime.scss';
interface SuperscriptTimeProps {
time: string;
className?: string;
style?: CSSProperties;
}
export default function SuperscriptTime(props: SuperscriptTimeProps) {
const { time, className, style } = props;
// we assume anything after space is a period tag
const [timeString, period] = time.split(' ');
return (
<div className={className} style={style}>
{timeString}
{period && <sup className='period'>{period}</sup>}
</div>
);
}
@@ -68,6 +68,7 @@
font-size: clamp(32px, 3.5vw, 50px); font-size: clamp(32px, 3.5vw, 50px);
color: var(--secondary-color-override, $viewer-secondary-color); color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em; letter-spacing: 0.05em;
line-height: 0.95em;
} }
} }
@@ -135,6 +136,7 @@
font-size: clamp(32px, 3.5vw, 50px); font-size: clamp(32px, 3.5vw, 50px);
color: var(--secondary-color-override, $viewer-secondary-color); color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em; letter-spacing: 0.05em;
line-height: 0.95em;
&--delayed { &--delayed {
color: $delay-color; color: $delay-color;
@@ -11,6 +11,7 @@ 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 { useTranslation } from '../../../translation/TranslationProvider'; import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { fetchTimerData, TimerMessage } from './countdown.helpers'; import { fetchTimerData, TimerMessage } from './countdown.helpers';
import CountdownSelect from './CountdownSelect'; import CountdownSelect from './CountdownSelect';
@@ -118,26 +119,27 @@ export default function Countdown(props: CountdownProps) {
<div className='countdown-container' data-testid='countdown-event'> <div className='countdown-container' data-testid='countdown-event'>
<div className='clock-container'> <div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div> <div className='label'>{getLocalizedString('common.time_now')}</div>
<div className='time'>{clock}</div> <SuperscriptTime time={clock} className='time' />
</div> </div>
{runningMessage !== TimerMessage.unhandled && ( {runningMessage !== TimerMessage.unhandled && (
<div className='status'>{getLocalizedString(`countdown.${runningMessage}`)}</div> <div className='status'>{getLocalizedString(`countdown.${runningMessage}`)}</div>
)} )}
<span className={`timer ${standby ? 'timer--paused' : ''} ${isRunningFinished ? 'timer--finished' : ''}`}> <SuperscriptTime
{formattedTimer} time={formattedTimer}
</span> className={`timer ${standby ? 'timer--paused' : ''} ${isRunningFinished ? 'timer--finished' : ''}`}
/>
<div className='title'>{follow?.title || 'Untitled Event'}</div> <div className='title'>{follow?.title || 'Untitled Event'}</div>
<div className='timer-group'> <div className='timer-group'>
<div className='aux-timers'> <div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.start_time')}</div> <div className='aux-timers__label'>{getLocalizedString('common.start_time')}</div>
<span className={`aux-timers__value ${delayedTimerStyles}`}>{startTime}</span> <SuperscriptTime time={startTime} className={`aux-timers__value ${delayedTimerStyles}`} />
</div> </div>
<div className='aux-timers'> <div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.end_time')}</div> <div className='aux-timers__label'>{getLocalizedString('common.end_time')}</div>
<span className={`aux-timers__value ${delayedTimerStyles}`}>{endTime}</span> <SuperscriptTime time={endTime} className={`aux-timers__value ${delayedTimerStyles}`} />
</div> </div>
</div> </div>
</div> </div>
@@ -9,6 +9,10 @@ import { sanitiseTitle } from './countdown.helpers';
import './Countdown.scss'; import './Countdown.scss';
const formatOptions = {
format: 'hh:mm a',
};
interface CountdownSelectProps { interface CountdownSelectProps {
events: OntimeRundownEntry[]; events: OntimeRundownEntry[];
} }
@@ -31,8 +35,8 @@ export default function CountdownSelect(props: CountdownSelectProps) {
filteredEvents.map((event: OntimeEvent, counter: number) => { filteredEvents.map((event: OntimeEvent, counter: number) => {
const index = counter + 1; const index = counter + 1;
const title = sanitiseTitle(event.title); const title = sanitiseTitle(event.title);
const start = formatTime(event.timeStart, { format: 'hh:mm' }); const start = formatTime(event.timeStart, formatOptions);
const end = formatTime(event.timeEnd, { format: 'hh:mm' }); const end = formatTime(event.timeEnd, formatOptions);
return ( return (
<li key={event.id}> <li key={event.id}>
@@ -58,6 +58,7 @@
font-size: clamp(32px, 3.5vw, 50px); font-size: clamp(32px, 3.5vw, 50px);
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em; letter-spacing: 0.05em;
line-height: 0.95em;
} }
.message { .message {
@@ -16,6 +16,7 @@ import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider'; import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation'; import { titleVariants } from '../common/animation';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import './Public.scss'; import './Public.scss';
@@ -63,7 +64,7 @@ export default function Public(props: BackstageProps) {
{general.title} {general.title}
<div className='clock-container'> <div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div> <div className='label'>{getLocalizedString('common.time_now')}</div>
<div className='time'>{clock}</div> <SuperscriptTime time={clock} className='time' />
</div> </div>
</div> </div>
@@ -13,7 +13,7 @@ $half-hours: min(1.5vh, 10px);
$size-min: min(2.5vh, 18px); $size-min: min(2.5vh, 18px);
$half-min: min(1.25vh, 9px); $half-min: min(1.25vh, 9px);
$red-active: #c53030; $red-active: #c53030;
$red-idle: #300000; $red-idle: #000000;
$cyan-active: #0ff; $cyan-active: #0ff;
$cyan-idle: #0aa; $cyan-idle: #0aa;
@@ -56,11 +56,10 @@ $cyan-idle: #0aa;
.hours { .hours {
border-radius: 50%; border-radius: 50%;
position: absolute; position: absolute;
background: $red-idle; background: var(--studio-idle, $red-idle);
&--active { &--active {
background: $red-active; background: var(--studio-active, $red-active);
box-shadow: 0 0 10px 2px rgba(255, 0, 0, 0.25);
} }
} }
@@ -80,7 +79,7 @@ $cyan-idle: #0aa;
} }
.studio-timer { .studio-timer {
color: $red-active; color: var(--studio-active, $red-active);
font-size: calc(#{$clock-size} / 3); font-size: calc(#{$clock-size} / 3);
margin-top: calc(50% - calc(#{$clock-size} / 7)); margin-top: calc(50% - calc(#{$clock-size} / 7));
line-height: 0.8em; line-height: 0.8em;
@@ -88,9 +87,8 @@ $cyan-idle: #0aa;
&--with-seconds { &--with-seconds {
font-size: calc(#{$clock-size} / 4.6); font-size: calc(#{$clock-size} / 4.6);
margin-top: calc(50% - calc(#{$clock-size} / 11)); margin-top: calc(50% - calc(#{$clock-size} / 11));
}
} }
}
.next-title:after, .next-title:after,
@@ -100,14 +98,15 @@ $cyan-idle: #0aa;
} }
.next-title { .next-title {
color: $cyan-idle; color: var(--studio-idle-label, $cyan-idle);
text-align: center; text-align: center;
} }
.next-countdown { .next-countdown {
color: var(--studio-active-label, $cyan-active);
font-size: 10vh; font-size: 10vh;
line-height: 1em; line-height: 1em;
color: $cyan-active;
&--overtime { &--overtime {
color: darken($red-active, 10%); color: darken($red-active, 10%);
@@ -131,16 +130,16 @@ $cyan-idle: #0aa;
padding-bottom: 2vh; padding-bottom: 2vh;
font-size: 15vh; font-size: 15vh;
line-height: 0.9em; line-height: 0.9em;
color: $red-active; color: var(--studio-active, $red-active);
&--idle { &--idle {
color: $red-idle; color: var(--studio-idle, $red-active);
} }
} }
.schedule { .schedule {
ul { ul {
color: $cyan-idle; color: var(--studio-idle-label, $cyan-idle);
font-size: 3.75vh; font-size: 3.75vh;
line-height: 1em; line-height: 1em;
list-style: none; list-style: none;
@@ -154,18 +153,18 @@ $cyan-idle: #0aa;
} }
.now { .now {
color: $cyan-active; color: var(--studio-active-label, $cyan-active);
} }
.next { .next {
color: $red-active; color: var(--studio-active, $red-active);
} }
.user-colour { .user-colour {
width: 0.35em; width: 0.35em;
height: 0.35em; height: 0.35em;
aspect-ratio: 1; aspect-ratio: 1;
background-color: $red-idle; background-color: var(--studio-idle, $red-idle);
margin-right: 0.35em; margin-right: 0.35em;
} }
} }
@@ -48,6 +48,7 @@
font-size: clamp(32px, 3.5vw, 50px); font-size: clamp(32px, 3.5vw, 50px);
color: var(--secondary-color-override, $viewer-secondary-color); color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em; letter-spacing: 0.05em;
line-height: 0.95em;
} }
&--hidden { &--hidden {
@@ -78,6 +79,9 @@
justify-self: center; justify-self: center;
align-self: center; align-self: center;
width: 100%;
overflow: hidden;
.end-message { .end-message {
text-align: center; text-align: center;
font-size: 11.5vw; font-size: 11.5vw;
@@ -89,7 +93,6 @@
.timer { .timer {
opacity: 1; opacity: 1;
font-size: 20vw;
font-family: var(--font-family-override, $viewer-font-family); font-family: var(--font-family-override, $viewer-font-family);
color: var(--timer-color-override, $timer-color); color: var(--timer-color-override, $timer-color);
line-height: 0.9em; line-height: 0.9em;
@@ -97,6 +100,9 @@
letter-spacing: 0.05em; letter-spacing: 0.05em;
font-weight: 600; font-weight: 600;
transition-property: font-size;
transition-duration: $viewer-transition-time;
&--paused { &--paused {
opacity: $viewer-opacity-disabled; opacity: $viewer-opacity-disabled;
transition: $viewer-transition-time; transition: $viewer-transition-time;
@@ -108,6 +114,27 @@
} }
} }
.external {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
text-align: center;
color: var(--external-color-override, $external-color);
letter-spacing: 0.5px;
line-height: 0.9em;
padding-bottom: 0.2em;
transition-property: opacity, height;
transition-duration: $viewer-transition-time;
border-top: 1px solid rgba(white, 0.1);
&--hidden {
opacity: 0;
height: 0;
}
}
.progress-container { .progress-container {
grid-area: progress; grid-area: progress;
width: 100%; width: 100%;
@@ -115,12 +142,11 @@
opacity: 1; opacity: 1;
transition: $viewer-transition-time; transition: $viewer-transition-time;
&--paused { &--paused {
opacity: $viewer-opacity-disabled; opacity: $viewer-opacity-disabled;
transition: $viewer-transition-time; transition: $viewer-transition-time;
}
} }
}
/* =================== OVERLAY ===================*/ /* =================== OVERLAY ===================*/
@@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { OntimeEvent, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types'; import { Message, OntimeEvent, Playback, 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';
@@ -12,6 +12,7 @@ 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 { useTranslation } from '../../../translation/TranslationProvider'; import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { formatTimerDisplay, getTimerByType } from '../common/viewerUtils'; import { formatTimerDisplay, getTimerByType } from '../common/viewerUtils';
import './Timer.scss'; import './Timer.scss';
@@ -40,6 +41,7 @@ const titleVariants = {
interface TimerProps { interface TimerProps {
isMirrored: boolean; isMirrored: boolean;
pres: TimerMessage; pres: TimerMessage;
external: Message;
eventNow: OntimeEvent | null; eventNow: OntimeEvent | null;
eventNext: OntimeEvent | null; eventNext: OntimeEvent | null;
time: TimeManagerType; time: TimeManagerType;
@@ -47,10 +49,9 @@ interface TimerProps {
} }
export default function Timer(props: TimerProps) { export default function Timer(props: TimerProps) {
const { isMirrored, pres, eventNow, eventNext, time, viewSettings } = props; const { isMirrored, pres, eventNow, eventNext, time, viewSettings, external } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
useEffect(() => { useEffect(() => {
document.title = 'ontime - Timer'; document.title = 'ontime - Timer';
}, []); }, []);
@@ -77,6 +78,7 @@ export default function Timer(props: TimerProps) {
const showBlinking = pres.timerBlink; const showBlinking = pres.timerBlink;
const showBlackout = pres.timerBlackout; const showBlackout = pres.timerBlackout;
const showClock = time.timerType !== TimerType.Clock; const showClock = time.timerType !== TimerType.Clock;
const showExternal = external.visible && external.text;
const timerColor = const timerColor =
showProgress && showDanger showProgress && showDanger
@@ -93,7 +95,13 @@ export default function Timer(props: TimerProps) {
} }
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''} ${showBlackout ? 'blackout' : ''}`; const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''} ${showBlackout ? 'blackout' : ''}`;
const timerFontSize = 89 / (stageTimerCharacters - 1); let timerFontSize = 89 / (stageTimerCharacters - 1);
// we need to shrink the timer if the external is going to be there
if (showExternal) {
timerFontSize *= 0.8;
}
const externalFontSize = timerFontSize * 0.4
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' : ''}`;
return ( return (
@@ -106,10 +114,10 @@ export default function Timer(props: TimerProps) {
<div className={`clock-container ${showClock ? '' : 'clock-container--hidden'}`}> <div className={`clock-container ${showClock ? '' : 'clock-container--hidden'}`}>
<div className='label'>{getLocalizedString('common.time_now')}</div> <div className='label'>{getLocalizedString('common.time_now')}</div>
<div className='clock'>{clock}</div> <SuperscriptTime time={clock} className='clock' />
</div> </div>
<div className={`timer-container ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`}> <div className={timerContainerClasses}>
{showEndMessage ? ( {showEndMessage ? (
<div className='end-message'>{viewSettings.endMessage}</div> <div className='end-message'>{viewSettings.endMessage}</div>
) : ( ) : (
@@ -123,6 +131,12 @@ export default function Timer(props: TimerProps) {
{display} {display}
</div> </div>
)} )}
<div
className={`external${showExternal ? '' : ' external--hidden'}`}
style={{ fontSize: `${externalFontSize}vw` }}
>
{external.text}
</div>
</div> </div>
<MultiPartProgressBar <MultiPartProgressBar
+1 -1
View File
@@ -5,7 +5,6 @@
// General // General
$viewer-transition-time: 0.5s; $viewer-transition-time: 0.5s;
// Text // Text
$viewer-font-family: "Open Sans", "Segoe UI", sans-serif; // --font-family-override $viewer-font-family: "Open Sans", "Segoe UI", sans-serif; // --font-family-override
$viewer-opacity-disabled: 0.6; $viewer-opacity-disabled: 0.6;
@@ -28,3 +27,4 @@ $timer-color: rgba(white, 80%); // --timer-color-override
$timer-finished-color: $playback-negative; $timer-finished-color: $playback-negative;
$timer-bold-font-family: "Arial Black", sans-serif; // --card-background-color-override $timer-bold-font-family: "Arial Black", sans-serif; // --card-background-color-override
$external-color: rgba(white, 70%); // --external-color-override
+15
View File
@@ -12,6 +12,11 @@ const commonStyles = {
border: '1px solid #578AF4', // $blue-500 border: '1px solid #578AF4', // $blue-500
}, },
_placeholder: { color: '#9d9d9d' }, // $gray-500 _placeholder: { color: '#9d9d9d' }, // $gray-500
_disabled: {
_hover: {
backgroundColor: '#262626', // $gray-1200
},
},
}; };
export const ontimeInputFilled = { export const ontimeInputFilled = {
@@ -30,6 +35,11 @@ export const ontimeInputFilledOnLight = {
_focus: { _focus: {
border: '2px solid #578AF4', // $blue-500 border: '2px solid #578AF4', // $blue-500
}, },
_disabled: {
_hover: {
backgroundColor: 'white',
},
},
}, },
}; };
@@ -58,4 +68,9 @@ export const ontimeTextAreaFilledOnLight = {
border: '2px solid #578AF4', // $blue-500 border: '2px solid #578AF4', // $blue-500
}, },
_placeholder: { color: '#9d9d9d' }, // $gray-500 _placeholder: { color: '#9d9d9d' }, // $gray-500
_disabled: {
_hover: {
backgroundColor: 'white',
},
},
}; };
+4
View File
@@ -34,6 +34,7 @@ import { populateStyles } from './modules/loadStyles.js';
import { eventStore, getInitialPayload } from './stores/EventStore.js'; import { eventStore, getInitialPayload } from './stores/EventStore.js';
import { PlaybackService } from './services/PlaybackService.js'; import { PlaybackService } from './services/PlaybackService.js';
import { RestorePoint, restoreService } from './services/RestoreService.js'; import { RestorePoint, restoreService } from './services/RestoreService.js';
import { messageService } from './services/message-service/MessageService.js';
console.log(`Starting Ontime version ${ONTIME_VERSION}`); console.log(`Starting Ontime version ${ONTIME_VERSION}`);
@@ -156,6 +157,9 @@ export const startServer = async () => {
const initialPayload = getInitialPayload(); const initialPayload = getInitialPayload();
eventStore.init(initialPayload); eventStore.init(initialPayload);
// eventStore set is a dependency of the services that publish to it
messageService.init(eventStore.set.bind(eventStore));
expressServer.listen(serverPort, '0.0.0.0'); expressServer.listen(serverPort, '0.0.0.0');
return { message: returnMessage, serverPort }; return { message: returnMessage, serverPort };
@@ -109,6 +109,20 @@ export function dispatchFromAdapter(
break; break;
} }
case 'set-external-message-text': {
if (typeof payload !== 'string') {
throw new Error(`Unable to parse payload: ${payload}`);
}
messageService.setExternalText(payload);
return;
}
case 'set-external-message-visible': {
if (typeof payload === 'undefined') {
throw new Error(`Unable to parse payload: ${payload}`);
}
messageService.setExternalVisibility(Boolean(payload));
break;
}
case 'start': { case 'start': {
PlaybackService.start(); PlaybackService.start();
break; break;
+7
View File
@@ -12,10 +12,17 @@
--timer-progress-bg-override: #fff; --timer-progress-bg-override: #fff;
--timer-progress-override: #202020; --timer-progress-override: #202020;
--external-color-override: #161616;
--cuesheet-running-bg-override: #D20300; --cuesheet-running-bg-override: #D20300;
--operator-running-bg-override: #D20300; --operator-running-bg-override: #D20300;
--operator-highlight-override: #FFAB33; --operator-highlight-override: #FFAB33;
--studio-active: #101010;
--studio-idle: #cfcfcf;
--studio-active-label: #101010;
--studio-idle-label: #595959;
} }
.timer { .timer {
@@ -1,7 +1,9 @@
import { Message } from 'ontime-types'; import { Message } from 'ontime-types';
import { eventStore } from '../../stores/EventStore.js';
import { TimerMessage } from 'ontime-types/src/definitions/runtime/MessageControl.type.js'; import { TimerMessage } from 'ontime-types/src/definitions/runtime/MessageControl.type.js';
import { throttle } from '../../utils/throttle.js';
import type { PublishFn } from '../../stores/EventStore.js';
let instance; let instance;
@@ -9,8 +11,12 @@ class MessageService {
timerMessage: TimerMessage; timerMessage: TimerMessage;
publicMessage: Message; publicMessage: Message;
lowerMessage: Message; lowerMessage: Message;
externalMessage: Message;
onAir: boolean; onAir: boolean;
private throttledSet: PublishFn;
private publish: PublishFn | null;
constructor() { constructor() {
if (instance) { if (instance) {
throw new Error('There can be only one'); throw new Error('There can be only one');
@@ -36,7 +42,40 @@ class MessageService {
visible: false, visible: false,
}; };
this.externalMessage = {
text: '',
visible: false,
};
this.onAir = false; this.onAir = false;
this.throttledSet = () => {
throw new Error('Published called before initialisation');
};
}
init(publish: PublishFn) {
this.publish = publish;
this.throttledSet = throttle((key, value) => this.publish(key, value), 100);
}
/**
* @description sets message on stage timer screen
*/
setExternalText(payload: string) {
if (this.externalMessage.text !== payload) {
this.externalMessage.text = payload;
this.throttledSet('externalMessage', this.externalMessage);
}
return this.getAll();
}
/**
* @description sets message visibility on stage timer screen
*/
setExternalVisibility(status: boolean) {
this.externalMessage.visible = status;
this.throttledSet('externalMessage', this.externalMessage);
return this.getAll();
} }
/** /**
@@ -44,7 +83,7 @@ class MessageService {
*/ */
setTimerText(payload: string) { setTimerText(payload: string) {
this.timerMessage.text = payload; this.timerMessage.text = payload;
eventStore.set('timerMessage', this.timerMessage); this.throttledSet('timerMessage', this.timerMessage);
return this.getAll(); return this.getAll();
} }
@@ -53,7 +92,7 @@ class MessageService {
*/ */
setTimerVisibility(status: boolean) { setTimerVisibility(status: boolean) {
this.timerMessage.visible = status; this.timerMessage.visible = status;
eventStore.set('timerMessage', this.timerMessage); this.throttledSet('timerMessage', this.timerMessage);
return this.getAll(); return this.getAll();
} }
@@ -62,7 +101,7 @@ class MessageService {
*/ */
setPublicText(payload: string) { setPublicText(payload: string) {
this.publicMessage.text = payload; this.publicMessage.text = payload;
eventStore.set('publicMessage', this.publicMessage); this.throttledSet('publicMessage', this.publicMessage);
return this.getAll(); return this.getAll();
} }
@@ -71,7 +110,7 @@ class MessageService {
*/ */
setPublicVisibility(status: boolean) { setPublicVisibility(status: boolean) {
this.publicMessage.visible = status; this.publicMessage.visible = status;
eventStore.set('publicMessage', this.publicMessage); this.throttledSet('publicMessage', this.publicMessage);
return this.getAll(); return this.getAll();
} }
@@ -80,7 +119,7 @@ class MessageService {
*/ */
setLowerText(payload: string) { setLowerText(payload: string) {
this.lowerMessage.text = payload; this.lowerMessage.text = payload;
eventStore.set('lowerMessage', this.lowerMessage); this.throttledSet('lowerMessage', this.lowerMessage);
return this.getAll(); return this.getAll();
} }
@@ -89,7 +128,7 @@ class MessageService {
*/ */
setLowerVisibility(status: boolean) { setLowerVisibility(status: boolean) {
this.lowerMessage.visible = status; this.lowerMessage.visible = status;
eventStore.set('lowerMessage', this.lowerMessage); this.throttledSet('lowerMessage', this.lowerMessage);
return this.getAll(); return this.getAll();
} }
@@ -102,7 +141,7 @@ class MessageService {
} else { } else {
this.onAir = status; this.onAir = status;
} }
eventStore.set('onAir', this.onAir); this.throttledSet('onAir', this.onAir);
return this.getAll(); return this.getAll();
} }
@@ -116,7 +155,7 @@ class MessageService {
} else { } else {
this.timerMessage.timerBlink = status; this.timerMessage.timerBlink = status;
} }
eventStore.set('timerMessage', this.timerMessage); this.throttledSet('timerMessage', this.timerMessage);
return this.getAll(); return this.getAll();
} }
@@ -130,7 +169,7 @@ class MessageService {
} else { } else {
this.timerMessage.timerBlackout = status; this.timerMessage.timerBlackout = status;
} }
eventStore.set('timerMessage', this.timerMessage); this.throttledSet('timerMessage', this.timerMessage);
return this.getAll(); return this.getAll();
} }
+3
View File
@@ -4,6 +4,8 @@ import { eventTimer } from '../services/TimerService.js';
import { messageService } from '../services/message-service/MessageService.js'; import { messageService } from '../services/message-service/MessageService.js';
import { eventLoader } from '../classes/event-loader/EventLoader.js'; import { eventLoader } from '../classes/event-loader/EventLoader.js';
export type PublishFn = <T extends keyof RuntimeStore>(key: T, value: RuntimeStore[T]) => void;
let store: Partial<RuntimeStore> = {}; let store: Partial<RuntimeStore> = {};
/** /**
@@ -68,6 +70,7 @@ export const getInitialPayload = () => ({
timerMessage: messageService.timerMessage, timerMessage: messageService.timerMessage,
publicMessage: messageService.publicMessage, publicMessage: messageService.publicMessage,
lowerMessage: messageService.lowerMessage, lowerMessage: messageService.lowerMessage,
externalMessage: messageService.externalMessage,
onAir: messageService.onAir, onAir: messageService.onAir,
loaded: eventLoader.loaded, loaded: eventLoader.loaded,
eventNow: eventLoader.eventNow, eventNow: eventLoader.eventNow,
+32
View File
@@ -0,0 +1,32 @@
/**
* Creates a throttled version of the passed function
* This function uses a leading algorithm
* which means that the function will be executed immediately on first call
* @param {Function} cb - function to throttle
* @param {number} delay - time (in ms) to throttle
* @returns {Function}
*/
export function throttle<T extends any[], U>(cb: (...args: T) => U, delay: number) {
let shouldWait = false;
let waitingArgs;
const timeoutFunc = () => {
if (waitingArgs == null) {
shouldWait = false;
} else {
cb(...waitingArgs);
waitingArgs = null;
setTimeout(timeoutFunc, delay);
}
};
return (...args: T) => {
if (shouldWait) {
waitingArgs = args;
return;
}
cb(...args);
shouldWait = true;
setTimeout(timeoutFunc, delay);
};
}
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
test('test', async ({ context }) => { test('message control sends messages to screens', async ({ context }) => {
const editorPage = await context.newPage(); const editorPage = await context.newPage();
const featurePage = await context.newPage(); const featurePage = await context.newPage();
@@ -25,9 +25,9 @@ test('test', async ({ context }) => {
await featurePage.getByText('testing lower').click({ timeout: 5000 }); await featurePage.getByText('testing lower').click({ timeout: 5000 });
// stage timer message // stage timer message
await editorPage.getByPlaceholder('Shown in stage timer').click(); await editorPage.getByPlaceholder('Timer').click();
await editorPage.getByPlaceholder('Shown in stage timer').fill('testing stage'); await editorPage.getByPlaceholder('Timer').fill('testing stage');
await editorPage.getByRole('button', { name: /toggle timer message/i }).click({ timeout: 5000 }); await editorPage.getByRole('button', { name: /toggle timer/i }).click({ timeout: 5000 });
await featurePage.goto('http://localhost:4001/timer'); await featurePage.goto('http://localhost:4001/timer');
await featurePage.waitForLoadState('load', { timeout: 5000 }); await featurePage.waitForLoadState('load', { timeout: 5000 });
+21
View File
@@ -0,0 +1,21 @@
import { test, expect } from '@playwright/test';
test('test aliases feature, it should redirect to given alias', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
// open settings
await page.getByRole('button', { name: 'Settings' }).click();
await page.getByRole('tab', { name: 'URL Aliases' }).click();
// create alias
await page.getByRole('button', { name: 'Add new' }).click();
await page.getByTestId('field__alias_1').fill('testing');
await page.getByTestId('field__url_1').fill('countdown');
await page.getByTestId('field__enable_1').click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await page.getByRole('button', { name: 'Close' }).click();
// make sure alias works
await page.goto('http://localhost:4001/testing');
await page.getByText('Select an event to follow').click();
});
@@ -13,6 +13,7 @@ export type RuntimeStore = {
timerMessage: TimerMessage; timerMessage: TimerMessage;
publicMessage: Message; publicMessage: Message;
lowerMessage: Message; lowerMessage: Message;
externalMessage: Message;
onAir: boolean; onAir: boolean;
// event loader // event loader