mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-03 06:28:01 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3df17d172f | |||
| d59c92219f | |||
| 23fce763e5 | |||
| 524aa74a3d | |||
| 32b7908d5b | |||
| e36b19a511 | |||
| ba62276951 | |||
| 4c4b83979d | |||
| 2dcea86b36 | |||
| e54996bd5e | |||
| 41753fc202 | |||
| e77572c0b6 | |||
| 4c268d531e | |||
| 5e481d49da | |||
| 0a68377b82 | |||
| cb39a13bea | |||
| e56460a940 | |||
| 390d466673 |
@@ -23,9 +23,10 @@ jobs:
|
||||
version: 16.16.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 7.26.3
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
+10
-10
@@ -1,25 +1,25 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "2.0.0-beta1",
|
||||
"version": "2.0.0-beta2",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.5.1",
|
||||
"@chakra-ui/react": "^2.5.5",
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dnd-kit/utilities": "^3.2.1",
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/styled": "^11.10.5",
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/react": "^7.28.1",
|
||||
"@sentry/tracing": "^7.24.1",
|
||||
"@tanstack/react-query": "^4.26.1",
|
||||
"@tanstack/react-query-devtools": "^4.26.1",
|
||||
"@sentry/react": "^7.46.0",
|
||||
"@sentry/tracing": "^7.46.0",
|
||||
"@tanstack/react-query": "^4.28.0",
|
||||
"@tanstack/react-query-devtools": "^4.29.0",
|
||||
"autosize": "^5.0.2",
|
||||
"axios": "^1.2.0",
|
||||
"color": "^4.2.3",
|
||||
"csv-stringify": "^6.2.3",
|
||||
"deepmerge": "^4.3.0",
|
||||
"framer-motion": "^8.0.2",
|
||||
"framer-motion": "^10.10.0",
|
||||
"luxon": "^3.3.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -58,7 +58,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/vite-plugin": "^0.3.0",
|
||||
"@sentry/vite-plugin": "^0.4.0",
|
||||
"@tanstack/eslint-plugin-query": "^4.26.2",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.1.1",
|
||||
|
||||
@@ -10,6 +10,7 @@ import useElectronEvent from './common/hooks/useElectronEvent';
|
||||
import { ontimeQueryClient } from './common/queryClient';
|
||||
import { connectSocket } from './common/utils/socket';
|
||||
import theme from './theme/theme';
|
||||
import { TranslationProvider } from './translation/TranslationProvider';
|
||||
import AppRouter from './AppRouter';
|
||||
|
||||
// Load Open Sans typeface
|
||||
@@ -52,7 +53,9 @@ function App() {
|
||||
<div className='App'>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<AppRouter />
|
||||
<TranslationProvider>
|
||||
<AppRouter />
|
||||
</TranslationProvider>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { lazy, useEffect } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import useAliases from './common/hooks-query/useAliases';
|
||||
import withData from './features/viewers/ViewWrapper';
|
||||
import { useTranslation } from './translation/TranslationProvider';
|
||||
|
||||
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Table = lazy(() => import('./features/table/ProtectedTable'));
|
||||
@@ -36,6 +37,17 @@ export default function AppRouter() {
|
||||
const { data } = useAliases();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setLanguage } = useTranslation();
|
||||
|
||||
// Set output language
|
||||
useEffect(() => {
|
||||
const langParam = searchParams.get('lang');
|
||||
if (langParam && langParam.length === 2) {
|
||||
setLanguage(searchParams.get('lang'));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
|
||||
// navigate if is alias route
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,7 +3,6 @@ const minimalLocation = 'minimal';
|
||||
const speakerLocation = 'speaker';
|
||||
const smLocation = 'sm';
|
||||
const publicLocation = 'public';
|
||||
const pipLocation = 'pip';
|
||||
const studioLocation = 'studio';
|
||||
const cuesheetLocation = 'cuesheet';
|
||||
const countdownLocation = 'countdown';
|
||||
@@ -17,7 +16,6 @@ export const viewerLocations = [
|
||||
{ link: smLocation, label: 'Backstage screen' },
|
||||
{ link: publicLocation, label: 'Public screen' },
|
||||
{ link: lowerLocation, label: 'Lower thirds' },
|
||||
{ link: pipLocation, label: 'Picture in Picture' },
|
||||
{ link: studioLocation, label: 'Studio clock' },
|
||||
{ link: countdownLocation, label: 'Countdown' },
|
||||
{ link: cuesheetLocation, label: 'Cuesheet' },
|
||||
|
||||
@@ -25,7 +25,7 @@ export const serverURL = calculateServer();
|
||||
export const websocketUrl = `ws://${window.location.hostname}:${STATIC_PORT}/ws`;
|
||||
|
||||
export const eventURL = `${serverURL}/eventdata`;
|
||||
export const rundownURL = `${serverURL}/eventlist`;
|
||||
export const rundownURL = `${serverURL}/events`;
|
||||
export const ontimeURL = `${serverURL}/ontime`;
|
||||
|
||||
export const stylesPath = 'external/styles/override.css';
|
||||
|
||||
@@ -10,13 +10,7 @@ export default function TooltipActionBtn(props: TooltipActionBtnProps) {
|
||||
const { clickHandler, icon, size = 'xs', tooltip, openDelay = 0, className, ...rest } = props;
|
||||
return (
|
||||
<Tooltip label={tooltip} openDelay={openDelay}>
|
||||
<IconButton
|
||||
{...rest}
|
||||
size={size}
|
||||
icon={icon}
|
||||
onClick={clickHandler}
|
||||
className={className}
|
||||
/>
|
||||
<IconButton {...rest} size={size} icon={icon} onClick={clickHandler} className={className} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: $section-white;
|
||||
margin-top: $main-spacing;
|
||||
border-bottom: 1px solid $border-color-ondark;
|
||||
padding-bottom: $element-inner-spacing;
|
||||
margin-bottom: $element-spacing;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
input[type="color"] {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Input } from '@chakra-ui/react';
|
||||
|
||||
import { EventEditorSubmitActions } from '../../../../features/event-editor/EventEditor';
|
||||
|
||||
import style from './ColourInput.module.scss';
|
||||
|
||||
interface ColourInputProps {
|
||||
value: string;
|
||||
name: EventEditorSubmitActions;
|
||||
handleChange: (newValue: EventEditorSubmitActions, name: string) => void;
|
||||
}
|
||||
|
||||
export default function ColourInput(props: ColourInputProps) {
|
||||
const { value, name, handleChange } = props;
|
||||
return (
|
||||
<Input
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
className={style.colourInput}
|
||||
type='color'
|
||||
value={value}
|
||||
onChange={(event) => handleChange(name, event.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IoBan } from '@react-icons/all-files/io5/IoBan';
|
||||
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
|
||||
import style from './SwatchSelect.module.scss';
|
||||
|
||||
interface SwatchProps {
|
||||
color: string;
|
||||
onClick: (color: string) => void;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
export default function Swatch(props: SwatchProps) {
|
||||
const { color, isSelected, onClick } = props;
|
||||
|
||||
const classes = cx([style.swatch, isSelected ? style.selected : null]);
|
||||
|
||||
if (!color) {
|
||||
return (
|
||||
<div className={`${classes} ${style.center}`}>
|
||||
<IoBan />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className={classes} style={{ backgroundColor: `${color}` }} onClick={() => onClick(color)} />;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
cursor: pointer;
|
||||
aspect-ratio: 1;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 16px;
|
||||
border: 4px solid #262626;
|
||||
|
||||
&.selected {
|
||||
border: 2px solid #578AF4;
|
||||
}
|
||||
}
|
||||
|
||||
.center {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
color: #578AF4;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorTitles';
|
||||
|
||||
import Swatch from './Swatch';
|
||||
|
||||
import style from './SwatchSelect.module.scss';
|
||||
|
||||
interface ColourInputProps {
|
||||
value: string;
|
||||
name: TitleActions;
|
||||
handleChange: (newValue: TitleActions, name: string) => void;
|
||||
}
|
||||
|
||||
const colours = [
|
||||
'',
|
||||
'#FFCC78', // $orange-400
|
||||
'#FFAB33', // $orange-600
|
||||
'#77C785', // $green-400
|
||||
'#339E4E', // $green-600
|
||||
'#779BE7', // $blue-400
|
||||
'#3E75E8', // $blue-600
|
||||
'#FF7878', // $red-400
|
||||
'#ED3333', // $red-600
|
||||
'#A790F5', // $violet-400
|
||||
'#8064E1', // $violet-600
|
||||
'#9d9d9d', // $gray-500
|
||||
'#ececec', // $gray-100
|
||||
];
|
||||
|
||||
export default function SwatchSelect(props: ColourInputProps) {
|
||||
const { value, name, handleChange } = props;
|
||||
|
||||
const setColour = useCallback(
|
||||
(newValue: string) => {
|
||||
if (newValue !== value) {
|
||||
handleChange(name, newValue);
|
||||
}
|
||||
},
|
||||
[handleChange, name, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={style.list}>
|
||||
{colours.map((colour) => (
|
||||
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ const inputStyleProps = {
|
||||
size: 'sm',
|
||||
color: '#E69056',
|
||||
variant: 'ontime-filled',
|
||||
fontSize: '15px',
|
||||
letterSpacing: '0.3px',
|
||||
};
|
||||
|
||||
interface DelayInputProps {
|
||||
@@ -24,7 +26,9 @@ export default function DelayInput(props: DelayInputProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (value == null) return;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
setValue(value);
|
||||
}, [value]);
|
||||
|
||||
@@ -36,7 +40,6 @@ export default function DelayInput(props: DelayInputProps) {
|
||||
(newValue?: string) => {
|
||||
if (newValue === '') setValue(0);
|
||||
const delayValue = clamp(Number(newValue), -60, 60);
|
||||
|
||||
if (delayValue === value) return;
|
||||
setValue(delayValue);
|
||||
|
||||
@@ -49,15 +52,18 @@ export default function DelayInput(props: DelayInputProps) {
|
||||
* @description Handles common keys for submit and cancel
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
const onKeyDownHandler = useCallback((key: string) => {
|
||||
if (key === 'Enter') {
|
||||
inputRef.current?.blur();
|
||||
validate(inputRef.current?.value);
|
||||
} else if (key === 'Escape') {
|
||||
inputRef.current?.blur();
|
||||
setValue(value);
|
||||
}
|
||||
}, [validate, value]);
|
||||
const onKeyDownHandler = useCallback(
|
||||
(key: string) => {
|
||||
if (key === 'Enter') {
|
||||
inputRef.current?.blur();
|
||||
validate(inputRef.current?.value);
|
||||
} else if (key === 'Escape') {
|
||||
inputRef.current?.blur();
|
||||
setValue(value);
|
||||
}
|
||||
},
|
||||
[validate, value],
|
||||
);
|
||||
|
||||
const labelText = `${Math.abs(value) !== 1 ? 'minutes' : 'minute'} ${
|
||||
value !== undefined && value >= 0 ? 'delayed' : 'ahead'
|
||||
|
||||
@@ -15,15 +15,19 @@ interface BaseProps {
|
||||
submitHandler: (field: EventEditorSubmitActions, newValue: string) => void;
|
||||
}
|
||||
|
||||
interface TextAreaProps {
|
||||
interface TextInputProps extends BaseProps {
|
||||
isTextArea?: false;
|
||||
}
|
||||
|
||||
interface TextAreaProps extends BaseProps {
|
||||
isTextArea: true;
|
||||
resize?: 'horizontal' | 'vertical' | 'none';
|
||||
}
|
||||
|
||||
type TextInputProps = BaseProps & TextAreaProps;
|
||||
type InputProps = TextInputProps | TextAreaProps;
|
||||
|
||||
export default function TextInput(props: TextInputProps) {
|
||||
const { isTextArea, isFullHeight, size = 'sm', field, initialText = '', submitHandler, resize = 'none' } = props;
|
||||
export default function TextInput(props: InputProps) {
|
||||
const { isTextArea, isFullHeight, size = 'sm', field, initialText = '', submitHandler } = props;
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
||||
@@ -31,6 +35,11 @@ export default function TextInput(props: TextInputProps) {
|
||||
const textInputProps = useReactiveTextInput(initialText, submitCallback, { submitOnEnter: true });
|
||||
const textAreaProps = useReactiveTextInput(initialText, submitCallback);
|
||||
|
||||
let resize = 'none';
|
||||
if (isTextArea) {
|
||||
resize = (props as TextAreaProps)?.resize ?? 'none';
|
||||
}
|
||||
|
||||
return isTextArea ? (
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeEvent, useCallback, useEffect, useState } from 'react';
|
||||
import { ChangeEvent, KeyboardEvent, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface UseReactiveTextInputReturn {
|
||||
value: string;
|
||||
@@ -14,8 +14,7 @@ export default function useReactiveTextInput(
|
||||
submitOnEnter?: boolean;
|
||||
},
|
||||
): UseReactiveTextInputReturn {
|
||||
const [text, setText] = useState(initialText);
|
||||
|
||||
const [text, setText] = useState<string>(initialText);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof initialText === 'undefined') {
|
||||
@@ -58,7 +57,6 @@ export default function useReactiveTextInput(
|
||||
[initialText, submitCallback],
|
||||
);
|
||||
|
||||
|
||||
/**
|
||||
* @description Handles common keys for submit and cancel
|
||||
* @param {string} key
|
||||
@@ -81,8 +79,8 @@ export default function useReactiveTextInput(
|
||||
|
||||
return {
|
||||
value: text,
|
||||
onChange: (event) => handleChange((event.target as HTMLInputElement).value),
|
||||
onBlur: (event) => handleSubmit((event.target as HTMLInputElement).value),
|
||||
onKeyDown: (event) => keyHandler(event.key),
|
||||
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
|
||||
onBlur: (event: ChangeEvent) => handleSubmit((event.target as HTMLInputElement).value),
|
||||
onKeyDown: (event: KeyboardEvent) => keyHandler(event.key),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
@use "../../../../theme/v2Styles" as *;
|
||||
|
||||
$input-font-size: 15px;
|
||||
$input-delayed-border-color: #E69056;
|
||||
|
||||
.timeInput {
|
||||
width: fit-content !important;
|
||||
|
||||
.inputLeft {
|
||||
max-width: fit-content;
|
||||
}
|
||||
|
||||
.inputLeft,
|
||||
.inputButton {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
@@ -15,6 +22,13 @@ $input-delayed-border-color: #E69056;
|
||||
padding: 0 0 0 2.6em;
|
||||
}
|
||||
|
||||
.warn {
|
||||
&::after {
|
||||
content: "*";
|
||||
color: $warning-orange;
|
||||
}
|
||||
}
|
||||
|
||||
&.delayed {
|
||||
.inputField {
|
||||
border: 1px solid $input-delayed-border-color;
|
||||
|
||||
@@ -2,26 +2,27 @@ import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } f
|
||||
import { Button, Input, InputGroup, InputLeftElement, Tooltip } from '@chakra-ui/react';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import { EventEditorSubmitActions } from '../../../../features/event-editor/EventEditor';
|
||||
import { tooltipDelayFast } from '../../../../ontimeConfig';
|
||||
import { useEmitLog } from '../../../stores/logger';
|
||||
import { forgivingStringToMillis } from '../../../utils/dateConfig';
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
import { TimeEntryField } from '../../../utils/timesManager';
|
||||
|
||||
import style from './TimeInput.module.scss';
|
||||
|
||||
interface TimeInputProps {
|
||||
name: TimeEntryField;
|
||||
submitHandler: (field: EventEditorSubmitActions, value: number) => void;
|
||||
submitHandler: (field: TimeEntryField, value: number) => void;
|
||||
time?: number;
|
||||
delay?: number;
|
||||
placeholder: string;
|
||||
validationHandler: (entry: TimeEntryField, val: number) => boolean;
|
||||
previousEnd?: number;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export default function TimeInput(props: TimeInputProps) {
|
||||
const { name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0 } = props;
|
||||
const { name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0, warning } = props;
|
||||
const { emitError } = useEmitLog();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [value, setValue] = useState('');
|
||||
@@ -30,7 +31,6 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
* @description Resets input value to given
|
||||
*/
|
||||
const resetValue = useCallback(() => {
|
||||
// Todo: check if change is necessary
|
||||
try {
|
||||
setValue(millisToString(time + delay));
|
||||
} catch (error) {
|
||||
@@ -149,20 +149,23 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
};
|
||||
|
||||
const ButtonTooltip = () => {
|
||||
if (name === 'timeStart') return 'Start';
|
||||
if (name === 'timeEnd') return 'End';
|
||||
if (name === 'durationOverride') return 'Duration';
|
||||
if (name === 'timeStart') return `Start${warning ? `: ${warning}` : ''}`;
|
||||
if (name === 'timeEnd') return `End${warning ? `: ${warning}` : ''}`;
|
||||
if (name === 'durationOverride') return `Duration${warning ? `: ${warning}` : ''}`;
|
||||
return '';
|
||||
};
|
||||
|
||||
const inputClasses = cx([style.timeInput, isDelayed ? style.delayed : null]);
|
||||
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null, warning ? style.warn : null]);
|
||||
|
||||
return (
|
||||
<InputGroup size='sm' className={`${style.timeInput} ${isDelayed ? style.delayed : ''}`}>
|
||||
<InputLeftElement width='fit-content'>
|
||||
<InputGroup size='sm' className={inputClasses}>
|
||||
<InputLeftElement className={style.inputLeft}>
|
||||
<Tooltip label={ButtonTooltip()} openDelay={tooltipDelayFast} variant='ontime-ondark'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ontime-subtle-white'
|
||||
className={`${style.inputButton} ${isDelayed ? style.delayed : ''}`}
|
||||
className={buttonClasses}
|
||||
tabIndex={-1}
|
||||
border={isDelayed ? '1px solid #E69056' : '1px solid transparent'}
|
||||
borderRight='1px solid transparent'
|
||||
|
||||
@@ -7,7 +7,7 @@ $progress-bar-br: 6px;
|
||||
width: 100%;
|
||||
height: $progress-bar-size;
|
||||
border-radius: $progress-bar-br;
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
@@ -17,7 +17,7 @@ $progress-bar-br: 6px;
|
||||
.progress-bar__indicator {
|
||||
height: $progress-bar-size;
|
||||
border-radius: $progress-bar-br;
|
||||
background-color: var(--accent-color-override, $accent-color);
|
||||
background-color: var(--timer-progress-override, $accent-color);
|
||||
transition: 1s linear;
|
||||
transition-property: width;
|
||||
}
|
||||
|
||||
@@ -16,10 +16,7 @@ export default function ProgressBar(props: ProgressBarProps) {
|
||||
|
||||
return (
|
||||
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
|
||||
<div
|
||||
className='progress-bar__indicator'
|
||||
style={{ width: `${percentComplete}%` }}
|
||||
/>
|
||||
<div className='progress-bar__indicator' style={{ width: `${percentComplete}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import { formatDisplay, millisToSeconds } from '../../utils/dateConfig';
|
||||
import { formatDisplay } from '../../utils/dateConfig';
|
||||
|
||||
import './TimerDisplay.scss';
|
||||
|
||||
@@ -21,7 +21,7 @@ const TimerDisplay = (props: TimerDisplayProps) => {
|
||||
if (time === null || typeof time === 'undefined' || isNaN(time)) {
|
||||
display = '-- : -- : --';
|
||||
} else {
|
||||
display = formatDisplay(millisToSeconds(time));
|
||||
display = formatDisplay(time);
|
||||
}
|
||||
|
||||
const isNegative = (time ?? 0) < 0;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
|
||||
import './TitleCard.scss';
|
||||
|
||||
interface TitleCardProps {
|
||||
@@ -9,13 +11,15 @@ interface TitleCardProps {
|
||||
|
||||
export default function TitleCard(props: TitleCardProps) {
|
||||
const { label, title, subtitle, presenter } = props;
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
const accent = label === 'now';
|
||||
|
||||
return (
|
||||
<div className='title-card'>
|
||||
<div className='inline'>
|
||||
<span className='presenter'>{presenter}</span>
|
||||
<span className={accent? 'label accent': 'label'}>{label}</span>
|
||||
<span className={accent ? 'label accent' : 'label'}>{getLocalizedString(`common.${label}`)}</span>
|
||||
</div>
|
||||
<div className='title'>{title}</div>
|
||||
<div className='subtitle'>{subtitle}</div>
|
||||
|
||||
@@ -76,6 +76,7 @@ export const useEventAction = () => {
|
||||
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
|
||||
if (typeof previousEvent !== 'undefined' && previousEvent.type === 'event') {
|
||||
newEvent.timeStart = previousEvent.timeEnd;
|
||||
newEvent.timeEnd = previousEvent.timeEnd;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,4 +12,5 @@ export type OverridableOptions = {
|
||||
hideOvertime?: boolean;
|
||||
hideMessagesOverlay?: boolean;
|
||||
hideEndMessage?: boolean;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
@@ -19,12 +19,12 @@ describe('test string from formatDisplay function', () => {
|
||||
});
|
||||
|
||||
it('test with valid millis', () => {
|
||||
const t = { val: 3600, result: '01:00:00' };
|
||||
const t = { val: 3600000, result: '01:00:00' };
|
||||
expect(formatDisplay(t.val, false)).toBe(t.result);
|
||||
});
|
||||
|
||||
it('test with negative millis', () => {
|
||||
const t = { val: -3600, result: '01:00:00' };
|
||||
const t = { val: -3600000, result: '01:00:00' };
|
||||
expect(formatDisplay(t.val, false)).toBe(t.result);
|
||||
});
|
||||
|
||||
@@ -39,17 +39,17 @@ describe('test string from formatDisplay function', () => {
|
||||
});
|
||||
|
||||
it('test with 86400 (24 hours)', () => {
|
||||
const t = { val: 86400, result: '00:00:00' };
|
||||
const t = { val: 86400000, result: '00:00:00' };
|
||||
expect(formatDisplay(t.val, false)).toBe(t.result);
|
||||
});
|
||||
|
||||
it('test with 86401 (24 hours and 1 second)', () => {
|
||||
const t = { val: 86401, result: '00:00:01' };
|
||||
const t = { val: 86401000, result: '00:00:01' };
|
||||
expect(formatDisplay(t.val, false)).toBe(t.result);
|
||||
});
|
||||
|
||||
it('test with -86401 (-24 hours and 1 second)', () => {
|
||||
const t = { val: -86401, result: '00:00:01' };
|
||||
const t = { val: -86401000, result: '00:00:01' };
|
||||
expect(formatDisplay(t.val, false)).toBe(t.result);
|
||||
});
|
||||
});
|
||||
@@ -61,12 +61,12 @@ describe('test string from formatDisplay function with hidezero', () => {
|
||||
});
|
||||
|
||||
it('test with valid millis', () => {
|
||||
const t = { val: 3600, result: '01:00:00' };
|
||||
const t = { val: 3600000, result: '01:00:00' };
|
||||
expect(formatDisplay(t.val, true)).toBe(t.result);
|
||||
});
|
||||
|
||||
it('test with negative millis', () => {
|
||||
const t = { val: -3600, result: '01:00:00' };
|
||||
const t = { val: -3600000, result: '01:00:00' };
|
||||
expect(formatDisplay(t.val, true)).toBe(t.result);
|
||||
});
|
||||
|
||||
@@ -81,17 +81,17 @@ describe('test string from formatDisplay function with hidezero', () => {
|
||||
});
|
||||
|
||||
it('test with 86400 (24 hours)', () => {
|
||||
const t = { val: 86400, result: '00:00' };
|
||||
const t = { val: 86400000, result: '00:00' };
|
||||
expect(formatDisplay(t.val, true)).toBe(t.result);
|
||||
});
|
||||
|
||||
it('test with 86401 (24 hours and 1 second)', () => {
|
||||
const t = { val: 86401, result: '00:01' };
|
||||
const t = { val: 86401000, result: '00:01' };
|
||||
expect(formatDisplay(t.val, true)).toBe(t.result);
|
||||
});
|
||||
|
||||
it('test with -86401 (-24 hours and 1 second)', () => {
|
||||
const t = { val: -86401, result: '00:01' };
|
||||
const t = { val: -86401000, result: '00:01' };
|
||||
expect(formatDisplay(t.val, true)).toBe(t.result);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,19 +6,19 @@ export const timeFormatSeconds = 'HH:mm:ss';
|
||||
/**
|
||||
* another go at simpler string formatting (counters)
|
||||
* @description Converts seconds to string representing time
|
||||
* @param {number | null} seconds - time in seconds
|
||||
* @param {number | null} milliseconds - time in seconds
|
||||
* @param {boolean} [hideZero] - whether to show hours in case its 00
|
||||
* @returns {string} String representing absolute time 00:12:02
|
||||
*/
|
||||
export function formatDisplay(seconds: number | null, hideZero = false): string {
|
||||
if (typeof seconds !== 'number') {
|
||||
export function formatDisplay(milliseconds: number | null, hideZero = false): string {
|
||||
if (typeof milliseconds !== 'number') {
|
||||
return hideZero ? '00:00' : '00:00:00';
|
||||
}
|
||||
|
||||
// add an extra 0 if necessary
|
||||
const format = (val: number) => `0${Math.floor(val)}`.slice(-2);
|
||||
|
||||
const s = Math.abs(seconds);
|
||||
const s = Math.abs(millisToSeconds(milliseconds));
|
||||
const hours = Math.floor((s / 3600) % 24);
|
||||
const minutes = Math.floor((s % 3600) / 60);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Log } from 'ontime-types';
|
||||
import { Log, RuntimeStore } from 'ontime-types';
|
||||
|
||||
import { RUNTIME, websocketUrl } from '../api/apiConstants';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
@@ -50,7 +50,7 @@ export const connectSocket = () => {
|
||||
break;
|
||||
}
|
||||
case 'ontime': {
|
||||
runtime.setState(payload);
|
||||
runtime.setState(payload as RuntimeStore);
|
||||
if (import.meta.env.DEV) {
|
||||
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type TimeEntryField = 'timeStart' |'timeEnd' | 'durationOverride';
|
||||
export type TimeEntryField = 'timeStart' | 'timeEnd' | 'durationOverride';
|
||||
|
||||
/**
|
||||
* @description Milliseconds in a day
|
||||
@@ -14,7 +14,12 @@ export const calculateDuration = (start: number, end: number): number =>
|
||||
/**
|
||||
* @description Checks which field the value relates to
|
||||
*/
|
||||
export const handleTimeEntry = (field: TimeEntryField, val: number, timeStart: number, timeEnd: number): {start: number, end: number, durationOverride: boolean} => {
|
||||
export const handleTimeEntry = (
|
||||
field: TimeEntryField,
|
||||
val: number,
|
||||
timeStart: number,
|
||||
timeEnd: number,
|
||||
): { start: number; end: number; durationOverride: boolean } => {
|
||||
let start = timeStart;
|
||||
let end = timeEnd;
|
||||
let durationOverride = false;
|
||||
@@ -32,13 +37,18 @@ export const handleTimeEntry = (field: TimeEntryField, val: number, timeStart: n
|
||||
/**
|
||||
* @description Validates time entry
|
||||
*/
|
||||
export const validateEntry = (field: TimeEntryField, value: number, timeStart: number, timeEnd: number): { value: boolean, catch: string } => {
|
||||
const validate = { value: true, catch: '' };
|
||||
export const validateEntry = (
|
||||
field: TimeEntryField,
|
||||
value: number,
|
||||
timeStart: number,
|
||||
timeEnd: number,
|
||||
): { value: boolean; warnings: { start?: string; end?: string; duration?: string } } => {
|
||||
const validate = { value: true, warnings: { start: '', end: '', duration: '' } };
|
||||
|
||||
const { start, end } = handleTimeEntry(field, value, timeStart, timeEnd);
|
||||
|
||||
if (end < start) {
|
||||
validate.catch = 'Start time later than end time';
|
||||
validate.warnings.start = 'Start time later than end time';
|
||||
}
|
||||
|
||||
return validate;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { millisToString } from 'ontime-utils';
|
||||
|
||||
import TimerDisplay from '../../../common/components/timer-display/TimerDisplay';
|
||||
import { setPlayback, useTimer } from '../../../common/hooks/useSocket';
|
||||
import { millisToMinutes } from '../../../common/utils/dateConfig';
|
||||
import { millisToMinutes, millisToSeconds } from '../../../common/utils/dateConfig';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
import TapButton from './TapButton';
|
||||
@@ -30,7 +30,30 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
|
||||
const hasAddedTime = Boolean(timer.addedTime);
|
||||
|
||||
const rollLabel = isRolling ? 'Roll mode active' : '';
|
||||
const addedTimeLabel = hasAddedTime ? `Added ${millisToMinutes(timer.addedTime)} minutes` : '';
|
||||
|
||||
const resolveAddedTimeLabel = () => {
|
||||
function resolveClosestUnit(ms: number) {
|
||||
if (ms < 6000) {
|
||||
return `${millisToSeconds(ms)} seconds`;
|
||||
} else if (ms < 12000) {
|
||||
return `1 minute`;
|
||||
} else {
|
||||
return `${millisToMinutes(ms)} minutes`;
|
||||
}
|
||||
}
|
||||
|
||||
if (timer.addedTime > 0) {
|
||||
return `Added ${resolveClosestUnit(timer.addedTime)}`;
|
||||
}
|
||||
|
||||
if (timer.addedTime < 0) {
|
||||
return `Removed ${resolveClosestUnit(timer.addedTime)}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const addedTimeLabel = resolveAddedTimeLabel();
|
||||
|
||||
return (
|
||||
<div className={style.timeContainer}>
|
||||
|
||||
@@ -144,7 +144,7 @@ $playback-width: 450px;
|
||||
.eventEditor {
|
||||
border-radius: 8px 8px 0 0;
|
||||
background-color: $bg-container-l2;
|
||||
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
|
||||
box-shadow: rgba(0, 0, 0, 0.6) 0 3px 6px 6px;
|
||||
border-top: 1px solid $white-10;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -164,15 +164,15 @@ $playback-width: 450px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #202020;
|
||||
background-color: $gray-1200;
|
||||
padding: 8px;
|
||||
border-left: 1px solid $white-10;
|
||||
border-radius: 0 8px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.editor {
|
||||
grid-area: even;
|
||||
height: calc(100% - 24px);
|
||||
|
||||
.content {
|
||||
height: calc(100% - 24px);
|
||||
@@ -187,7 +187,6 @@ $playback-width: 450px;
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 24px);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +64,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
.inputLabel {
|
||||
@mixin input-label() {
|
||||
font-size: 13px;
|
||||
display: block;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.countedInput {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@include input-label;
|
||||
}
|
||||
|
||||
.inputLabel {
|
||||
display: block;
|
||||
@include input-label;
|
||||
|
||||
.delayLabel {
|
||||
color: $ontime-delay-text;
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Select, Switch } from '@chakra-ui/react';
|
||||
import { IoBan } from '@react-icons/all-files/io5/IoBan';
|
||||
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import CopyTag from '../../common/components/copy-tag/CopyTag';
|
||||
import ColourInput from '../../common/components/input/colour-input/ColourInput';
|
||||
import TextInput from '../../common/components/input/text-input/TextInput';
|
||||
import TimeInput from '../../common/components/input/time-input/TimeInput';
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import { useEventEditorStore } from '../../common/stores/eventEditor';
|
||||
import { useEmitLog } from '../../common/stores/logger';
|
||||
import { millisToMinutes } from '../../common/utils/dateConfig';
|
||||
import getDelayTo from '../../common/utils/getDelayTo';
|
||||
import { calculateDuration, TimeEntryField, validateEntry } from '../../common/utils/timesManager';
|
||||
|
||||
import EventEditorTimes from './composite/EventEditorTimes';
|
||||
import EventEditorTitles from './composite/EventEditorTitles';
|
||||
|
||||
import style from './EventEditor.module.scss';
|
||||
|
||||
export type EventEditorSubmitActions = keyof OntimeEvent | 'durationOverride';
|
||||
export type EventEditorSubmitActions = keyof OntimeEvent;
|
||||
|
||||
// Todo: add previous end to TimeInput fields
|
||||
export default function EventEditor() {
|
||||
const { openId } = useEventEditorStore();
|
||||
const { data } = useRundown();
|
||||
const { emitWarning, emitError } = useEmitLog();
|
||||
const { updateEvent } = useEventAction();
|
||||
const [event, setEvent] = useState<OntimeEvent | null>(null);
|
||||
const [delay, setDelay] = useState(0);
|
||||
|
||||
@@ -44,85 +34,10 @@ export default function EventEditor() {
|
||||
}
|
||||
}, [data, event, openId]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(field: EventEditorSubmitActions, value: string | number) => {
|
||||
if (event === null) {
|
||||
return;
|
||||
}
|
||||
const newEventData: Partial<OntimeEvent> = { id: event.id };
|
||||
switch (field) {
|
||||
case 'durationOverride': {
|
||||
// duration defines timeEnd
|
||||
newEventData.duration = value as number;
|
||||
newEventData.timeEnd = event.timeStart + (value as number);
|
||||
break;
|
||||
}
|
||||
case 'timeStart': {
|
||||
newEventData.duration = calculateDuration(value as number, event.timeEnd);
|
||||
newEventData.timeStart = value as number;
|
||||
break;
|
||||
}
|
||||
case 'timeEnd': {
|
||||
newEventData.duration = calculateDuration(event.timeStart, value as number);
|
||||
newEventData.timeEnd = value as number;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (field in event) {
|
||||
// create object with new field
|
||||
newEventData[field] = value;
|
||||
break;
|
||||
} else {
|
||||
emitError(`Unknown field: ${field}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
updateEvent(newEventData);
|
||||
},
|
||||
[emitError, event, updateEvent],
|
||||
);
|
||||
|
||||
const timerValidationHandler = useCallback(
|
||||
(entry: TimeEntryField, val: number) => {
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
const valid = validateEntry(entry, val, event.timeStart, event.timeEnd);
|
||||
if (!valid.value) {
|
||||
emitWarning(`Time Input Warning: ${valid.catch}`);
|
||||
}
|
||||
return valid.value;
|
||||
},
|
||||
[event, emitWarning],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(field: string, value: string) => {
|
||||
updateEvent({ id: event.id, [field]: value });
|
||||
},
|
||||
[event, updateEvent],
|
||||
);
|
||||
|
||||
const togglePublic = useCallback(
|
||||
(currentValue: boolean) => {
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
updateEvent({ id: event.id, isPublic: !currentValue });
|
||||
},
|
||||
[event, updateEvent],
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return <span>Loading...</span>;
|
||||
}
|
||||
|
||||
const delayed = delay !== 0;
|
||||
const addedTime = delayed ? `${delay >= 0 ? '+' : '-'} ${millisToMinutes(Math.abs(delay))} minutes` : null;
|
||||
const newStart = delayed ? `New start ${millisToString(event.timeStart + delay)}` : null;
|
||||
const newEnd = delayed ? `New end ${millisToString(event.timeEnd + delay)}` : null;
|
||||
|
||||
return (
|
||||
<div className={style.eventEditor}>
|
||||
<div className={style.eventInfo}>
|
||||
@@ -132,112 +47,24 @@ export default function EventEditor() {
|
||||
<div className={style.eventActions}>
|
||||
<CopyTag label='OSC trigger'>{`/ontime/gotoid/${event.id}`}</CopyTag>
|
||||
</div>
|
||||
<div className={style.timeOptions}>
|
||||
<div className={style.timers}>
|
||||
<label className={style.inputLabel}>
|
||||
Start time {delayed && <span className={style.delayLabel}>{addedTime}</span>}
|
||||
{delayed && <div className={style.delayLabel}>{newStart}</div>}
|
||||
</label>
|
||||
<TimeInput
|
||||
name='timeStart'
|
||||
submitHandler={handleSubmit}
|
||||
validationHandler={timerValidationHandler}
|
||||
time={event.timeStart}
|
||||
delay={delay}
|
||||
placeholder='Start'
|
||||
/>
|
||||
<label className={style.inputLabel}>
|
||||
End time {delayed && <span className={style.delayLabel}>{addedTime}</span>}
|
||||
{delayed && <div className={style.delayLabel}>{newEnd}</div>}
|
||||
</label>
|
||||
<TimeInput
|
||||
name='timeEnd'
|
||||
submitHandler={handleSubmit}
|
||||
validationHandler={timerValidationHandler}
|
||||
time={event.timeEnd}
|
||||
delay={delay}
|
||||
placeholder='End'
|
||||
/>
|
||||
<label className={style.inputLabel}>Duration</label>
|
||||
<TimeInput
|
||||
name='durationOverride'
|
||||
submitHandler={handleSubmit}
|
||||
validationHandler={timerValidationHandler}
|
||||
time={event.duration}
|
||||
placeholder='Duration'
|
||||
/>
|
||||
</div>
|
||||
<div className={style.timeSettings}>
|
||||
<label className={style.inputLabel}>Timer Type</label>
|
||||
<Select
|
||||
size='sm'
|
||||
name='timerType'
|
||||
value={event.timerType}
|
||||
onChange={(event) => handleChange('timerType', event.target.value)}
|
||||
variant='ontime'
|
||||
>
|
||||
<option value={TimerType.CountDown}>Count down</option>
|
||||
<option value={TimerType.CountUp}>Count up</option>
|
||||
<option value={TimerType.Clock}>Clock</option>
|
||||
</Select>
|
||||
<label className={style.inputLabel}>End Action</label>
|
||||
<Select
|
||||
size='sm'
|
||||
name='endAction'
|
||||
value={event.endAction}
|
||||
onChange={(event) => handleChange('endAction', event.target.value)}
|
||||
variant='ontime'
|
||||
>
|
||||
<option value={EndAction.Continue}>Continue</option>
|
||||
<option value={EndAction.Stop}>Stop</option>
|
||||
<option value={EndAction.LoadNext}>Load Next</option>
|
||||
<option value={EndAction.PlayNext}>Play Next</option>
|
||||
</Select>
|
||||
<span className={style.spacer} />
|
||||
<label className={`${style.inputLabel} ${style.publicToggle}`}>
|
||||
<Switch isChecked={event.isPublic} onChange={() => togglePublic(event.isPublic)} variant='ontime' />
|
||||
Event is public
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.titles}>
|
||||
<div className={style.left}>
|
||||
<div className={style.column}>
|
||||
<label className={style.inputLabel}>Title</label>
|
||||
<TextInput field='title' initialText={event.title} submitHandler={handleSubmit} />
|
||||
</div>
|
||||
<div className={style.column}>
|
||||
<label className={style.inputLabel}>Presenter</label>
|
||||
<TextInput field='presenter' initialText={event.presenter} submitHandler={handleSubmit} />
|
||||
</div>
|
||||
<div className={style.column}>
|
||||
<label className={style.inputLabel}>Subtitle</label>
|
||||
<TextInput field='subtitle' initialText={event.subtitle} submitHandler={handleSubmit} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.right}>
|
||||
<div className={style.column}>
|
||||
<label className={style.inputLabel}>Colour</label>
|
||||
<div className={style.inline}>
|
||||
<ColourInput name='colour' value={event?.colour} handleChange={handleSubmit} />
|
||||
<Button leftIcon={<IoBan />} onClick={() => handleSubmit('colour', '')} variant='ontime-subtle' size='sm'>
|
||||
Clear colour
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${style.column} ${style.fullHeight}`}>
|
||||
<label className={style.inputLabel}>Note</label>
|
||||
<TextInput
|
||||
field='note'
|
||||
initialText={event.note}
|
||||
submitHandler={handleSubmit}
|
||||
isTextArea
|
||||
isFullHeight
|
||||
resize='none'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EventEditorTimes
|
||||
eventId={event.id}
|
||||
timeStart={event.timeStart}
|
||||
timeEnd={event.timeEnd}
|
||||
duration={event.duration}
|
||||
delay={delay}
|
||||
isPublic={event.isPublic}
|
||||
endAction={event.endAction}
|
||||
timerType={event.timerType}
|
||||
/>
|
||||
<EventEditorTitles
|
||||
eventId={event.id}
|
||||
title={event.title}
|
||||
presenter={event.presenter}
|
||||
subtitle={event.subtitle}
|
||||
note={event.note}
|
||||
colour={event.colour}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,4 +35,3 @@ const EventEditorExport = () => {
|
||||
};
|
||||
|
||||
export default memo(EventEditorExport);
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Textarea } from '@chakra-ui/react';
|
||||
|
||||
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
||||
|
||||
import { TitleActions } from './EventEditorTitles';
|
||||
|
||||
import style from '../EventEditor.module.scss';
|
||||
|
||||
interface CountedTextAreaProps {
|
||||
field: TitleActions;
|
||||
label: string;
|
||||
initialValue: string;
|
||||
submitHandler: (field: TitleActions, value: string) => void;
|
||||
}
|
||||
|
||||
export default function CountedTextArea(props: CountedTextAreaProps) {
|
||||
const { field, label, initialValue, submitHandler } = props;
|
||||
|
||||
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
||||
|
||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback);
|
||||
|
||||
return (
|
||||
<div className={`${style.column} ${style.fullHeight}`}>
|
||||
<div className={style.countedInput}>
|
||||
<label className={style.inputLabel}>{label}</label>
|
||||
<span className={style.charCount}>{`${value.length} characters`}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
size='sm'
|
||||
resize='none'
|
||||
variant='ontime-filled'
|
||||
style={{ height: '100%' }}
|
||||
data-testid='input-textarea'
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
|
||||
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
||||
|
||||
import { TitleActions } from './EventEditorTitles';
|
||||
|
||||
import style from '../EventEditor.module.scss';
|
||||
|
||||
interface CountedTextInputProps {
|
||||
field: TitleActions;
|
||||
label: string;
|
||||
initialValue: string;
|
||||
submitHandler: (field: TitleActions, value: string) => void;
|
||||
}
|
||||
|
||||
export default function CountedTextInput(props: CountedTextInputProps) {
|
||||
const { field, label, initialValue, submitHandler } = props;
|
||||
|
||||
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
||||
|
||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, {
|
||||
submitOnEnter: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={style.column}>
|
||||
<div className={style.countedInput}>
|
||||
<label className={style.inputLabel}>{label}</label>
|
||||
<span className={style.charCount}>{`${value.length} characters`}</span>
|
||||
</div>
|
||||
<Input
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
data-testid='input-textfield'
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { Select, Switch } from '@chakra-ui/react';
|
||||
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import TimeInput from '../../../common/components/input/time-input/TimeInput';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { millisToMinutes } from '../../../common/utils/dateConfig';
|
||||
import { calculateDuration, TimeEntryField, validateEntry } from '../../../common/utils/timesManager';
|
||||
|
||||
import style from '../EventEditor.module.scss';
|
||||
|
||||
interface EventEditorTimesProps {
|
||||
eventId: string;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
delay: number;
|
||||
isPublic: boolean;
|
||||
endAction: EndAction;
|
||||
timerType: TimerType;
|
||||
}
|
||||
|
||||
type TimeActions = 'timeStart' | 'timeEnd' | 'durationOverride' | 'timerType' | 'endAction' | 'isPublic';
|
||||
|
||||
// Todo: add previous end to TimeInput fields
|
||||
const EventEditorTimes = (props: EventEditorTimesProps) => {
|
||||
const { eventId, timeStart, timeEnd, duration, delay, isPublic, endAction, timerType } = props;
|
||||
const { updateEvent } = useEventAction();
|
||||
|
||||
const [warning, setWarnings] = useState({ start: '', end: '', duration: '' });
|
||||
|
||||
const timerValidationHandler = (entry: TimeEntryField, val: number) => {
|
||||
const valid = validateEntry(entry, val, timeStart, timeEnd);
|
||||
setWarnings((prev) => ({ ...prev, ...valid.warnings }));
|
||||
return valid.value;
|
||||
};
|
||||
|
||||
const handleSubmit = (field: TimeActions, value: number | string | boolean) => {
|
||||
const newEventData: Partial<OntimeEvent> = { id: eventId };
|
||||
switch (field) {
|
||||
case 'durationOverride': {
|
||||
// duration defines timeEnd
|
||||
newEventData.duration = value as number;
|
||||
newEventData.timeEnd = timeStart + (value as number);
|
||||
break;
|
||||
}
|
||||
case 'timeStart': {
|
||||
newEventData.duration = calculateDuration(value as number, timeEnd);
|
||||
newEventData.timeStart = value as number;
|
||||
break;
|
||||
}
|
||||
case 'timeEnd': {
|
||||
newEventData.duration = calculateDuration(timeStart, value as number);
|
||||
newEventData.timeEnd = value as number;
|
||||
break;
|
||||
}
|
||||
case 'isPublic': {
|
||||
updateEvent({ id: eventId, isPublic: !(value as boolean) });
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (field === 'timerType' || field === 'endAction') {
|
||||
// @ts-expect-error -- not sure how to typecheck here
|
||||
newEventData[field as keyof OntimeEvent] = value as string;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
updateEvent(newEventData);
|
||||
};
|
||||
|
||||
const delayed = delay !== 0;
|
||||
const addedTime = delayed ? `${delay >= 0 ? '+' : '-'} ${millisToMinutes(Math.abs(delay))} minutes` : null;
|
||||
const newStart = delayed ? `New start ${millisToString(timeStart + delay)}` : null;
|
||||
const newEnd = delayed ? `New end ${millisToString(timeEnd + delay)}` : null;
|
||||
|
||||
return (
|
||||
<div className={style.timeOptions}>
|
||||
<div className={style.timers}>
|
||||
<label className={style.inputLabel}>
|
||||
Start time {delayed && <span className={style.delayLabel}>{addedTime}</span>}
|
||||
{delayed && <div className={style.delayLabel}>{newStart}</div>}
|
||||
</label>
|
||||
<TimeInput
|
||||
name='timeStart'
|
||||
submitHandler={handleSubmit}
|
||||
validationHandler={timerValidationHandler}
|
||||
time={timeStart}
|
||||
delay={delay}
|
||||
placeholder='Start'
|
||||
warning={warning.start}
|
||||
/>
|
||||
<label className={style.inputLabel}>
|
||||
End time {delayed && <span className={style.delayLabel}>{addedTime}</span>}
|
||||
{delayed && <div className={style.delayLabel}>{newEnd}</div>}
|
||||
</label>
|
||||
<TimeInput
|
||||
name='timeEnd'
|
||||
submitHandler={handleSubmit}
|
||||
validationHandler={timerValidationHandler}
|
||||
time={timeEnd}
|
||||
delay={delay}
|
||||
placeholder='End'
|
||||
warning={warning.end}
|
||||
/>
|
||||
<label className={style.inputLabel}>Duration</label>
|
||||
<TimeInput
|
||||
name='durationOverride'
|
||||
submitHandler={handleSubmit}
|
||||
validationHandler={timerValidationHandler}
|
||||
time={duration}
|
||||
placeholder='Duration'
|
||||
warning={warning.duration}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.timeSettings}>
|
||||
<label className={style.inputLabel}>Timer Type</label>
|
||||
<Select
|
||||
size='sm'
|
||||
name='timerType'
|
||||
value={timerType}
|
||||
onChange={(event) => handleSubmit('timerType', event.target.value)}
|
||||
variant='ontime'
|
||||
>
|
||||
<option value={TimerType.CountDown}>Count down</option>
|
||||
<option value={TimerType.CountUp}>Count up</option>
|
||||
<option value={TimerType.Clock}>Clock</option>
|
||||
</Select>
|
||||
<label className={style.inputLabel}>End Action</label>
|
||||
<Select
|
||||
size='sm'
|
||||
name='endAction'
|
||||
value={endAction}
|
||||
onChange={(event) => handleSubmit('endAction', event.target.value)}
|
||||
variant='ontime'
|
||||
>
|
||||
<option value={EndAction.Continue}>Continue</option>
|
||||
<option value={EndAction.Stop}>Stop</option>
|
||||
<option value={EndAction.LoadNext}>Load Next</option>
|
||||
<option value={EndAction.PlayNext}>Play Next</option>
|
||||
</Select>
|
||||
<span className={style.spacer} />
|
||||
<label className={`${style.inputLabel} ${style.publicToggle}`}>
|
||||
<Switch isChecked={isPublic} onChange={() => handleSubmit('isPublic', isPublic)} variant='ontime' />
|
||||
Event is public
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(EventEditorTimes);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
|
||||
import CountedTextArea from './CountedTextArea';
|
||||
import CountedTextInput from './CountedTextInput';
|
||||
|
||||
import style from '../EventEditor.module.scss';
|
||||
|
||||
interface EventEditorTitlesProps {
|
||||
eventId: string;
|
||||
title: string;
|
||||
presenter: string;
|
||||
subtitle: string;
|
||||
note: string;
|
||||
colour: string;
|
||||
}
|
||||
|
||||
export type TitleActions = 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
|
||||
|
||||
const EventEditorTitles = (props: EventEditorTitlesProps) => {
|
||||
const { eventId, title, presenter, subtitle, note, colour } = props;
|
||||
const { updateEvent } = useEventAction();
|
||||
|
||||
const handleSubmit = (field: TitleActions, value: string) => {
|
||||
updateEvent({ id: eventId, [field]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.titles}>
|
||||
<div className={style.left}>
|
||||
<CountedTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
|
||||
<CountedTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
|
||||
<CountedTextInput field='subtitle' label='Subtitle' initialValue={subtitle} submitHandler={handleSubmit} />
|
||||
</div>
|
||||
<div className={style.right}>
|
||||
<div className={style.column}>
|
||||
<label className={style.inputLabel}>Colour</label>
|
||||
<div className={style.inline}>
|
||||
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
|
||||
</div>
|
||||
</div>
|
||||
<CountedTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(EventEditorTitles);
|
||||
@@ -2,8 +2,6 @@ import { PropsWithChildren, useState } from 'react';
|
||||
|
||||
import CollapseBar from '../../common/components/collapse-bar/CollapseBar';
|
||||
|
||||
import style from './Info.module.scss';
|
||||
|
||||
interface CollapsableInfoProps {
|
||||
title: string;
|
||||
}
|
||||
@@ -13,9 +11,9 @@ export default function CollapsableInfo(props: PropsWithChildren<CollapsableInfo
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<>
|
||||
<CollapseBar title={title} isCollapsed={collapsed} onClick={() => setCollapsed((prev) => !prev)} />
|
||||
{!collapsed && children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin-top: $main-spacing;
|
||||
}
|
||||
|
||||
.labels {
|
||||
font-size: $inner-section-text-size;
|
||||
display: flex;
|
||||
|
||||
@@ -4,12 +4,8 @@
|
||||
$info-gray: $secondary-text-gray;
|
||||
$info-hover: $section-white;
|
||||
|
||||
.infoLoggerContainer {
|
||||
max-height: 80%;
|
||||
height: 100%
|
||||
}
|
||||
|
||||
.log {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
font-size: 0.8em;
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function InfoLogger() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={style.infoLoggerContainer}>
|
||||
<>
|
||||
<div className={style.buttonBar}>
|
||||
<Button
|
||||
variant={showUser ? 'ontime-filled' : 'ontime-subtle'}
|
||||
@@ -125,6 +125,6 @@ export default function InfoLogger() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function InfoNif() {
|
||||
|
||||
return (
|
||||
<div className={style.interfaceList}>
|
||||
{data?.networkInterfaces.map((nif) => (
|
||||
{data?.networkInterfaces?.map((nif) => (
|
||||
<span key={nif.address} onClick={() => handleClick(nif.address)} className={style.interface}>
|
||||
{`${nif.name} - ${nif.address}`}
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
|
||||
@@ -8,12 +8,14 @@ import { SupportedEvent } from 'ontime-types';
|
||||
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import { useCursor } from '../../common/stores/cursorStore';
|
||||
import { useEventEditorStore } from '../../common/stores/eventEditor';
|
||||
|
||||
import style from './RundownMenu.module.scss';
|
||||
|
||||
const RundownMenu = () => {
|
||||
const isCursorLocked = useCursor((state) => state.isCursorLocked);
|
||||
const toggleCursorLocked = useCursor((state) => state.toggleCursorLocked);
|
||||
const removeOpenEvent = useEventEditorStore((state) => state.removeOpenEvent);
|
||||
|
||||
const { addEvent, deleteAllEvents } = useEventAction();
|
||||
|
||||
@@ -31,7 +33,8 @@ const RundownMenu = () => {
|
||||
|
||||
const deleteAll = useCallback(() => {
|
||||
deleteAllEvents();
|
||||
}, [deleteAllEvents]);
|
||||
removeOpenEvent();
|
||||
}, [deleteAllEvents, removeOpenEvent]);
|
||||
|
||||
return (
|
||||
<HStack className={style.headerButtons}>
|
||||
|
||||
@@ -42,14 +42,14 @@ export default function ViewsSettingsModal() {
|
||||
try {
|
||||
await postView(formData);
|
||||
} catch (error) {
|
||||
emitError(`Error view settings: ${error}`)
|
||||
} finally{
|
||||
emitError(`Error view settings: ${error}`);
|
||||
} finally {
|
||||
await refetch();
|
||||
setChanged(false);
|
||||
}
|
||||
setSubmitting(false);
|
||||
},
|
||||
[emitError, formData, refetch]
|
||||
[emitError, formData, refetch],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -72,7 +72,7 @@ export default function ViewsSettingsModal() {
|
||||
setFormData(temp);
|
||||
setChanged(true);
|
||||
},
|
||||
[formData]
|
||||
[formData],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -89,18 +89,30 @@ export default function ViewsSettingsModal() {
|
||||
<IoInformationCircleOutline color='#2b6cb0' fontSize='2em' />
|
||||
CSS Style Overrides
|
||||
</span>
|
||||
This feature allows user defined CSS to override the application stylesheets as a way to
|
||||
customise viewers appearance.
|
||||
This feature allows user defined CSS to override the application stylesheets as a way to customise viewers
|
||||
appearance.
|
||||
<br />
|
||||
Currently the feature affects the following views
|
||||
<br />
|
||||
Currently the feature affects the following views<br />
|
||||
<ul className={style.featureList}>
|
||||
<li><IoCheckmarkSharp /> Stage timer</li>
|
||||
<li><IoCheckmarkSharp /> Clock</li>
|
||||
<li><IoCheckmarkSharp /> Minimal timer</li>
|
||||
<li><IoCheckmarkSharp /> Backstage screen</li>
|
||||
<li><IoCheckmarkSharp /> Public screen</li>
|
||||
<li><IoCheckmarkSharp /> Picture in Picture</li>
|
||||
<li><IoCheckmarkSharp /> Countdown</li>
|
||||
<li>
|
||||
<IoCheckmarkSharp /> Stage timer
|
||||
</li>
|
||||
<li>
|
||||
<IoCheckmarkSharp /> Clock
|
||||
</li>
|
||||
<li>
|
||||
<IoCheckmarkSharp /> Minimal timer
|
||||
</li>
|
||||
<li>
|
||||
<IoCheckmarkSharp /> Backstage screen
|
||||
</li>
|
||||
<li>
|
||||
<IoCheckmarkSharp /> Public screen
|
||||
</li>
|
||||
<li>
|
||||
<IoCheckmarkSharp /> Countdown
|
||||
</li>
|
||||
</ul>
|
||||
Read more about it in the documentation{' '}
|
||||
<a
|
||||
@@ -123,19 +135,12 @@ export default function ViewsSettingsModal() {
|
||||
</FormLabel>
|
||||
<EnableBtn
|
||||
active={formData.overrideStyles}
|
||||
text={
|
||||
formData.overrideStyles ? 'Style Override Enabled' : 'Style Override Disabled'
|
||||
}
|
||||
text={formData.overrideStyles ? 'Style Override Enabled' : 'Style Override Disabled'}
|
||||
actionHandler={() => handleChange('overrideStyles', !formData.overrideStyles)}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<SubmitContainer
|
||||
revert={revert}
|
||||
submitting={submitting}
|
||||
changed={changed}
|
||||
status={status}
|
||||
/>
|
||||
<SubmitContainer revert={revert} submitting={submitting} changed={changed} status={status} />
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
||||
@@ -26,3 +26,7 @@
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
min-height: 50vh;
|
||||
}
|
||||
@@ -194,7 +194,7 @@ export default function Rundown(props: RundownProps) {
|
||||
}
|
||||
};
|
||||
|
||||
if (!entries.length) {
|
||||
if (statefulEntries?.length < 1) {
|
||||
return <RundownEmpty handleAddNew={() => insertAtCursor(SupportedEvent.Event, -1)} />;
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ export default function Rundown(props: RundownProps) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className={style.spacer} />
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
@@ -8,13 +8,13 @@ $block-clearance: 8px;
|
||||
$block-border-radius: 8px;
|
||||
$block-text-color: $gray-50;
|
||||
$block-bg: $gray-1200;
|
||||
$block-bg2: $gray-1050; // for delay and blocks
|
||||
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
|
||||
$secondary-block-height: 40px;
|
||||
$block-cursor-color: $blue-400;
|
||||
|
||||
@mixin block-styling() {
|
||||
box-sizing: content-box;
|
||||
background-color: $block-bg;
|
||||
border: 1px solid $white-10;
|
||||
font-family: $ontime-font-family;
|
||||
border-radius: $block-border-radius;
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
.block {
|
||||
@include block-spacing;
|
||||
@include block-styling;
|
||||
box-sizing: content-box;
|
||||
|
||||
background-color: $block-bg2;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr auto;
|
||||
align-items: center;
|
||||
height: $secondary-block-height;
|
||||
gap: 8px;
|
||||
|
||||
&.hasCursor {
|
||||
outline: 1px solid $block-cursor-color;
|
||||
@@ -18,6 +21,6 @@
|
||||
@include drag-style;
|
||||
}
|
||||
|
||||
.actionOverlay {
|
||||
.actionMenu {
|
||||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||
import { OntimeBlock, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import EditableBlockTitle from '../common/EditableBlockTitle';
|
||||
import BlockActionMenu from '../event-block/composite/BlockActionMenu';
|
||||
import { EventItemActions } from '../RundownEntry';
|
||||
|
||||
@@ -57,7 +58,8 @@ export default function BlockBlock(props: BlockBlockProps) {
|
||||
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
|
||||
<IoReorderTwo />
|
||||
</span>
|
||||
<BlockActionMenu className={style.actionOverlay} showAdd showDelay enableDelete actionHandler={actionHandler} />
|
||||
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
|
||||
<BlockActionMenu className={style.actionMenu} showAdd showDelay enableDelete actionHandler={actionHandler} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Editable, EditableInput, EditablePreview } from '@chakra-ui/react';
|
||||
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import style from './TitleEditor.module.scss';
|
||||
|
||||
interface TitleEditorProps {
|
||||
title: string;
|
||||
eventId: string;
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function EditableBlockTitle(props: TitleEditorProps) {
|
||||
const { title, eventId, placeholder, className } = props;
|
||||
const [blockTitle, setBlockTitle] = useState<string>(title || '');
|
||||
const { updateEvent } = useEventAction();
|
||||
|
||||
useEffect(() => {
|
||||
setBlockTitle(title);
|
||||
}, [title]);
|
||||
|
||||
const handleTitle = useCallback(
|
||||
(text: string) => {
|
||||
if (text === title) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanVal = text.trim();
|
||||
setBlockTitle(cleanVal);
|
||||
|
||||
updateEvent({ id: eventId, title: cleanVal });
|
||||
},
|
||||
[title, updateEvent, eventId],
|
||||
);
|
||||
|
||||
const classes = cx([className, style.eventTitle, !blockTitle ? style.noTitle : null]);
|
||||
return (
|
||||
<Editable
|
||||
variant='ontime'
|
||||
value={blockTitle}
|
||||
className={classes}
|
||||
placeholder={placeholder}
|
||||
onChange={(value) => setBlockTitle(value)}
|
||||
onSubmit={(value) => handleTitle(value)}
|
||||
>
|
||||
<EditablePreview className={style.preview} />
|
||||
<EditableInput />
|
||||
</Editable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.titleEditor {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.noTitle {
|
||||
.preview {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
@include block-spacing;
|
||||
@include block-styling;
|
||||
|
||||
background-color: $block-bg2;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr auto;
|
||||
grid-template-areas: 'drag inpt btns';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button, HStack } from '@chakra-ui/react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||
import { OntimeDelay, OntimeEvent } from 'ontime-types';
|
||||
|
||||
@@ -31,7 +32,7 @@ interface DelayBlockProps {
|
||||
|
||||
export default function DelayBlock(props: DelayBlockProps) {
|
||||
const { data, hasCursor, actionHandler } = props;
|
||||
const { applyDelay, updateEvent } = useEventAction();
|
||||
const { applyDelay, updateEvent, deleteEvent } = useEventAction();
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
|
||||
const {
|
||||
@@ -56,9 +57,13 @@ export default function DelayBlock(props: DelayBlockProps) {
|
||||
}
|
||||
}, [hasCursor]);
|
||||
|
||||
const applyDelayHandler = useCallback(() => {
|
||||
const applyDelayHandler = () => {
|
||||
applyDelay(data.id);
|
||||
}, [data.id, applyDelay]);
|
||||
};
|
||||
|
||||
const cancelDelayHandler = () => {
|
||||
deleteEvent(data.id);
|
||||
};
|
||||
|
||||
const delaySubmitHandler = useCallback(
|
||||
(value: number) => {
|
||||
@@ -84,7 +89,10 @@ export default function DelayBlock(props: DelayBlockProps) {
|
||||
<DelayInput value={delayValue} submitHandler={delaySubmitHandler} />
|
||||
<HStack spacing='8px' className={style.actionOverlay}>
|
||||
<Button onClick={applyDelayHandler} size='sm' leftIcon={<IoCheckmark />} variant='ontime-subtle-white'>
|
||||
Apply delay
|
||||
Apply
|
||||
</Button>
|
||||
<Button onClick={cancelDelayHandler} size='sm' leftIcon={<IoClose />} variant='ontime-subtle-white'>
|
||||
Cancel
|
||||
</Button>
|
||||
<BlockActionMenu showAdd enableDelete actionHandler={actionHandler} />
|
||||
</HStack>
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
@use '../blockMixins' as *;
|
||||
|
||||
$skip-opacity: 0.1;
|
||||
|
||||
.eventBlock {
|
||||
@include block-styling;
|
||||
background-color: $block-bg;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
@@ -29,7 +32,6 @@
|
||||
|
||||
&.skip {
|
||||
border: 1px solid $white-3;
|
||||
box-shadow: none;
|
||||
|
||||
.delayNote,
|
||||
.eventTitle,
|
||||
@@ -37,7 +39,7 @@
|
||||
.binder,
|
||||
.eventTimers,
|
||||
.eventStatus {
|
||||
opacity: $opacity-disabled;
|
||||
opacity: $skip-opacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,18 +89,6 @@
|
||||
|
||||
.eventTitle {
|
||||
grid-area: title;
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&.noTitle {
|
||||
.preview {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.eventActions {
|
||||
@@ -158,6 +148,7 @@
|
||||
gap: 8px;
|
||||
|
||||
.tag {
|
||||
padding-top: 1px;
|
||||
font-size: 0.55em;
|
||||
color: $active-indicator;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { Editable, EditableInput, EditablePreview, Tooltip } from '@chakra-ui/react';
|
||||
import { IoCaretDownCircle } from '@react-icons/all-files/io5/IoCaretDownCircle';
|
||||
import { IoCaretUpCircle } from '@react-icons/all-files/io5/IoCaretUpCircle';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoCaretDown } from '@react-icons/all-files/io5/IoCaretDown';
|
||||
import { IoCaretUp } from '@react-icons/all-files/io5/IoCaretUp';
|
||||
import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
|
||||
import { IoPeople } from '@react-icons/all-files/io5/IoPeople';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
import { IoPlayCircle } from '@react-icons/all-files/io5/IoPlayCircle';
|
||||
import { IoPlayForwardCircle } from '@react-icons/all-files/io5/IoPlayForwardCircle';
|
||||
import { IoPlayOutline } from '@react-icons/all-files/io5/IoPlayOutline';
|
||||
import { IoPlaySkipForwardCircle } from '@react-icons/all-files/io5/IoPlaySkipForwardCircle';
|
||||
import { IoReload } from '@react-icons/all-files/io5/IoReload';
|
||||
import { IoRemoveCircle } from '@react-icons/all-files/io5/IoRemoveCircle';
|
||||
import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline';
|
||||
import { IoStopCircle } from '@react-icons/all-files/io5/IoStopCircle';
|
||||
import { IoPlayForward } from '@react-icons/all-files/io5/IoPlayForward';
|
||||
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
|
||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
||||
import { IoTime } from '@react-icons/all-files/io5/IoTime';
|
||||
import { EndAction, Playback, TimerType } from 'ontime-types';
|
||||
|
||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { setEventPlayback } from '../../../common/hooks/useSocket';
|
||||
import { useEventEditorStore } from '../../../common/stores/eventEditor';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
import EditableBlockTitle from '../common/EditableBlockTitle';
|
||||
import { EventItemActions } from '../RundownEntry';
|
||||
|
||||
import BlockActionMenu from './composite/BlockActionMenu';
|
||||
import EventBlockPlayback from './composite/EventBlockPlayback';
|
||||
import EventBlockProgressBar from './composite/EventBlockProgressBar';
|
||||
import EventBlockTimers from './composite/EventBlockTimers';
|
||||
|
||||
@@ -78,38 +73,14 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
||||
actionHandler,
|
||||
} = props;
|
||||
|
||||
const { updateEvent } = useEventAction();
|
||||
|
||||
const [blockTitle, setBlockTitle] = useState<string>(title || '');
|
||||
const [renderInner, setRenderInner] = useState(false);
|
||||
const setOpenEvent = useEventEditorStore((state) => state.setOpenEvent);
|
||||
const removeOpenEvent = useEventEditorStore((state) => state.removeOpenEvent);
|
||||
|
||||
// Todo: could I re-render the item without causing a state change here?
|
||||
// ?? use refs instead?
|
||||
|
||||
useEffect(() => {
|
||||
setRenderInner(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setBlockTitle(title);
|
||||
}, [title]);
|
||||
|
||||
const handleTitle = useCallback(
|
||||
(text: string) => {
|
||||
if (text === title) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanVal = text.trim();
|
||||
setBlockTitle(cleanVal);
|
||||
|
||||
updateEvent({ id: eventId, title: cleanVal });
|
||||
},
|
||||
[title, updateEvent, eventId],
|
||||
);
|
||||
|
||||
const toggleOpenEvent = useCallback(() => {
|
||||
if (isOpen) {
|
||||
removeOpenEvent();
|
||||
@@ -128,62 +99,16 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
||||
|
||||
return !renderInner ? null : (
|
||||
<>
|
||||
<div className={style.playbackActions}>
|
||||
<TooltipActionBtn
|
||||
variant='ontime-subtle-white'
|
||||
aria-label='Skip event'
|
||||
tooltip='Skip event'
|
||||
icon={skip ? <IoRemoveCircle /> : <IoRemoveCircleOutline />}
|
||||
{...tooltipProps}
|
||||
{...blockBtnStyle}
|
||||
clickHandler={() => actionHandler('update', { field: 'skip', value: !skip })}
|
||||
tabIndex={-1}
|
||||
disabled={selected}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
variant='ontime-subtle-white'
|
||||
aria-label='Load event'
|
||||
tooltip='Load event'
|
||||
icon={<IoReload className={style.flip} />}
|
||||
disabled={skip}
|
||||
{...tooltipProps}
|
||||
{...blockBtnStyle}
|
||||
clickHandler={() => setEventPlayback.loadEvent(eventId)}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
variant='ontime-subtle-white'
|
||||
aria-label='Start event'
|
||||
tooltip='Start event'
|
||||
icon={eventIsPlaying ? <IoPlay /> : <IoPlayOutline />}
|
||||
disabled={skip}
|
||||
{...tooltipProps}
|
||||
{...blockBtnStyle}
|
||||
clickHandler={() => setEventPlayback.startEvent(eventId)}
|
||||
backgroundColor={eventIsPlaying ? '#58A151' : undefined}
|
||||
_hover={{ backgroundColor: eventIsPlaying ? '#58A151' : undefined }}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
<EventBlockPlayback eventId={eventId} skip={skip} isPlaying={eventIsPlaying} selected={selected} />
|
||||
<EventBlockTimers
|
||||
eventId={eventId}
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
duration={duration}
|
||||
delay={delay}
|
||||
actionHandler={actionHandler}
|
||||
previousEnd={previousEnd}
|
||||
/>
|
||||
<Editable
|
||||
variant='ontime'
|
||||
value={blockTitle}
|
||||
className={`${style.eventTitle} ${!title ? style.noTitle : ''}`}
|
||||
placeholder='Event title'
|
||||
onChange={(value) => setBlockTitle(value)}
|
||||
onSubmit={(value) => handleTitle(value)}
|
||||
>
|
||||
<EditablePreview className={style.preview} />
|
||||
<EditableInput />
|
||||
</Editable>
|
||||
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
|
||||
<div className={style.statusElements}>
|
||||
<span className={style.eventNote}>{note}</span>
|
||||
<div className={selected ? style.progressBg : `${style.progressBg} ${style.hidden}`}>
|
||||
@@ -195,16 +120,16 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
||||
<span className={style.tag}>NEXT</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={`End action: ${endAction}`} {...tooltipProps}>
|
||||
<span>
|
||||
<EndActionIcon action={endAction} className={style.statusIcon} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={`Time type: ${timerType}`} {...tooltipProps}>
|
||||
<span>
|
||||
<TimerIcon type={timerType} className={style.statusIcon} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={`End action: ${endAction}`} {...tooltipProps}>
|
||||
<span>
|
||||
<EndActionIcon action={endAction} className={style.statusIcon} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} {...tooltipProps}>
|
||||
<span>
|
||||
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
|
||||
@@ -236,24 +161,24 @@ export default memo(EventBlockInner);
|
||||
function EndActionIcon(props: { action: EndAction; className: string }) {
|
||||
const { action, className } = props;
|
||||
if (action === EndAction.LoadNext) {
|
||||
return <IoPlaySkipForwardCircle className={className} />;
|
||||
return <IoPlaySkipForward className={className} />;
|
||||
}
|
||||
if (action === EndAction.PlayNext) {
|
||||
return <IoPlayForwardCircle className={className} />;
|
||||
return <IoPlayForward className={className} />;
|
||||
}
|
||||
if (action === EndAction.Stop) {
|
||||
return <IoStopCircle className={className} />;
|
||||
return <IoStop className={className} />;
|
||||
}
|
||||
return <IoPlayCircle className={className} />;
|
||||
return <IoPlay className={className} />;
|
||||
}
|
||||
|
||||
function TimerIcon(props: { type: TimerType; className: string }) {
|
||||
const { type, className } = props;
|
||||
if (type === TimerType.CountUp) {
|
||||
return <IoCaretUpCircle className={className} />;
|
||||
return <IoCaretUp className={className} />;
|
||||
}
|
||||
if (type === TimerType.Clock) {
|
||||
return <IoTime className={className} />;
|
||||
}
|
||||
return <IoCaretDownCircle className={className} />;
|
||||
return <IoCaretDown className={className} />;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuDivider,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import { IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuList, Tooltip } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline';
|
||||
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
|
||||
@@ -27,14 +19,15 @@ interface BlockActionMenuProps {
|
||||
actionHandler: (action: EventItemActions, payload?: any) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function BlockActionMenu(props: BlockActionMenuProps) {
|
||||
const { showAdd, showDelay, showBlock, enableDelete, showClone, actionHandler, className } = props;
|
||||
|
||||
const handleAddEvent = useCallback(() => actionHandler("event"), [actionHandler])
|
||||
const handleAddDelay = useCallback(() => actionHandler("delay"), [actionHandler])
|
||||
const handleAddBlock = useCallback(() => actionHandler("block"), [actionHandler])
|
||||
const handleClone = useCallback(() => actionHandler("clone"), [actionHandler])
|
||||
const handleDelete = useCallback(() => actionHandler("delete"), [actionHandler])
|
||||
const handleAddEvent = useCallback(() => actionHandler('event'), [actionHandler]);
|
||||
const handleAddDelay = useCallback(() => actionHandler('delay'), [actionHandler]);
|
||||
const handleAddBlock = useCallback(() => actionHandler('block'), [actionHandler]);
|
||||
const handleClone = useCallback(() => actionHandler('clone'), [actionHandler]);
|
||||
const handleDelete = useCallback(() => actionHandler('delete'), [actionHandler]);
|
||||
|
||||
return (
|
||||
<Menu isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
|
||||
@@ -44,8 +37,7 @@ export default function BlockActionMenu(props: BlockActionMenuProps) {
|
||||
aria-label='Event options'
|
||||
icon={<IoEllipsisHorizontal />}
|
||||
tabIndex={-1}
|
||||
variant='ontime-subtle'
|
||||
color='#f6f6f6'
|
||||
variant='ontime-subtle-white'
|
||||
size='sm'
|
||||
className={className}
|
||||
/>
|
||||
@@ -54,36 +46,19 @@ export default function BlockActionMenu(props: BlockActionMenuProps) {
|
||||
<MenuItem icon={<IoAdd />} onClick={handleAddEvent} isDisabled={!showAdd}>
|
||||
Add Event after
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<IoTimerOutline />}
|
||||
onClick={handleAddDelay}
|
||||
isDisabled={!showDelay}
|
||||
>
|
||||
<MenuItem icon={<IoTimerOutline />} onClick={handleAddDelay} isDisabled={!showDelay}>
|
||||
Add Delay after
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<IoRemoveCircleOutline />}
|
||||
onClick={handleAddBlock}
|
||||
isDisabled={!showBlock}
|
||||
>
|
||||
<MenuItem icon={<IoRemoveCircleOutline />} onClick={handleAddBlock} isDisabled={!showBlock}>
|
||||
Add Block after
|
||||
</MenuItem>
|
||||
{showClone && (
|
||||
<MenuItem
|
||||
icon={<IoDuplicateOutline />}
|
||||
onClick={handleClone}
|
||||
isDisabled={!showBlock}
|
||||
>
|
||||
<MenuItem icon={<IoDuplicateOutline />} onClick={handleClone} isDisabled={!showBlock}>
|
||||
Clone event
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
icon={<IoTrashBinSharp />}
|
||||
onClick={handleDelete}
|
||||
isDisabled={!enableDelete}
|
||||
color='#D20300'
|
||||
>
|
||||
<MenuItem icon={<IoTrashBinSharp />} onClick={handleDelete} isDisabled={!enableDelete} color='#D20300'>
|
||||
Delete event
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { memo } from 'react';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
import { IoPlayOutline } from '@react-icons/all-files/io5/IoPlayOutline';
|
||||
import { IoReload } from '@react-icons/all-files/io5/IoReload';
|
||||
import { IoRemoveCircle } from '@react-icons/all-files/io5/IoRemoveCircle';
|
||||
import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline';
|
||||
|
||||
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
|
||||
import { useEventAction } from '../../../../common/hooks/useEventAction';
|
||||
import { setEventPlayback } from '../../../../common/hooks/useSocket';
|
||||
import { tooltipDelayMid } from '../../../../ontimeConfig';
|
||||
|
||||
import style from '../EventBlock.module.scss';
|
||||
|
||||
const blockBtnStyle = {
|
||||
size: 'sm',
|
||||
};
|
||||
|
||||
const tooltipProps = {
|
||||
openDelay: tooltipDelayMid,
|
||||
};
|
||||
|
||||
interface EventBlockPlaybackProps {
|
||||
eventId: string;
|
||||
skip: boolean;
|
||||
isPlaying: boolean;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
|
||||
const { eventId, skip, isPlaying, selected } = props;
|
||||
const { updateEvent } = useEventAction();
|
||||
|
||||
const toggleSkip = () => {
|
||||
updateEvent({ id: eventId, skip: !skip });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.playbackActions}>
|
||||
<TooltipActionBtn
|
||||
variant='ontime-subtle-white'
|
||||
aria-label='Skip event'
|
||||
tooltip='Skip event'
|
||||
icon={skip ? <IoRemoveCircle /> : <IoRemoveCircleOutline />}
|
||||
backgroundColor={skip ? '#FA5656' : undefined}
|
||||
_hover={{ backgroundColor: skip ? '#FF7878' : '#404040' }}
|
||||
{...tooltipProps}
|
||||
{...blockBtnStyle}
|
||||
clickHandler={toggleSkip}
|
||||
tabIndex={-1}
|
||||
isDisabled={selected}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
variant='ontime-subtle-white'
|
||||
aria-label='Load event'
|
||||
tooltip='Load event'
|
||||
icon={<IoReload className={style.flip} />}
|
||||
isDisabled={skip}
|
||||
{...tooltipProps}
|
||||
{...blockBtnStyle}
|
||||
clickHandler={() => setEventPlayback.loadEvent(eventId)}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
variant='ontime-subtle-white'
|
||||
aria-label='Start event'
|
||||
tooltip='Start event'
|
||||
icon={isPlaying ? <IoPlay /> : <IoPlayOutline />}
|
||||
isDisabled={skip}
|
||||
{...tooltipProps}
|
||||
{...blockBtnStyle}
|
||||
clickHandler={() => setEventPlayback.startEvent(eventId)}
|
||||
backgroundColor={isPlaying ? '#58A151' : undefined}
|
||||
_hover={{ backgroundColor: isPlaying ? '#58A151' : undefined }}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(EventBlockPlayback);
|
||||
+52
-30
@@ -1,21 +1,53 @@
|
||||
import { useCallback } from 'react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useEmitLog } from '@/common/stores/logger';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import { useEventAction } from '../../../../common/hooks/useEventAction';
|
||||
import { millisToMinutes } from '../../../../common/utils/dateConfig';
|
||||
import { validateEntry } from '../../../../common/utils/timesManager';
|
||||
import { calculateDuration, TimeEntryField, validateEntry } from '../../../../common/utils/timesManager';
|
||||
|
||||
import style from '../EventBlock.module.scss';
|
||||
|
||||
export default function EventBlockTimers(props) {
|
||||
const { timeStart, timeEnd, duration, delay, actionHandler, previousEnd } = props;
|
||||
const { emitWarning } = useEmitLog();
|
||||
interface EventBlockTimerProps {
|
||||
eventId: string;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
delay: number;
|
||||
previousEnd: number;
|
||||
}
|
||||
|
||||
const delayTime = `${delay >= 0 ? '+' : '-'} ${millisToMinutes(Math.abs(delay))}`;
|
||||
const newTime = millisToString(timeStart + delay);
|
||||
type TimeActions = 'timeStart' | 'timeEnd' | 'durationOverride';
|
||||
|
||||
const EventBlockTimers = (props: EventBlockTimerProps) => {
|
||||
const { eventId, timeStart, timeEnd, duration, delay, previousEnd } = props;
|
||||
const { updateEvent } = useEventAction();
|
||||
|
||||
const [warning, setWarnings] = useState({ start: '', end: '', duration: '' });
|
||||
|
||||
const handleSubmit = (field: TimeActions, value: number) => {
|
||||
const newEventData: Partial<OntimeEvent> = { id: eventId };
|
||||
switch (field) {
|
||||
case 'durationOverride': {
|
||||
// duration defines timeEnd
|
||||
newEventData.duration = value;
|
||||
newEventData.timeEnd = timeStart + value;
|
||||
break;
|
||||
}
|
||||
case 'timeStart': {
|
||||
newEventData.duration = calculateDuration(value, timeEnd);
|
||||
newEventData.timeStart = value;
|
||||
break;
|
||||
}
|
||||
case 'timeEnd': {
|
||||
newEventData.duration = calculateDuration(timeStart, value);
|
||||
newEventData.timeEnd = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
updateEvent(newEventData);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Validates a time input against its pair
|
||||
@@ -24,22 +56,16 @@ export default function EventBlockTimers(props) {
|
||||
* @return {boolean}
|
||||
*/
|
||||
const handleValidation = useCallback(
|
||||
(field, value) => {
|
||||
(field: TimeEntryField, value: number) => {
|
||||
const valid = validateEntry(field, value, timeStart, timeEnd);
|
||||
if (valid.catch) {
|
||||
emitWarning(`Time Input Warning: ${valid.catch}`);
|
||||
}
|
||||
setWarnings((prev) => ({ ...prev, ...valid.warnings }));
|
||||
return valid.value;
|
||||
},
|
||||
[emitWarning, timeEnd, timeStart]
|
||||
[timeEnd, timeStart],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(field, value) => {
|
||||
actionHandler('update', { field, value });
|
||||
},
|
||||
[actionHandler]
|
||||
);
|
||||
const delayTime = `${delay >= 0 ? '+' : '-'} ${millisToMinutes(Math.abs(delay))}`;
|
||||
const newTime = millisToString(timeStart + delay);
|
||||
|
||||
return (
|
||||
<div className={style.eventTimers}>
|
||||
@@ -51,6 +77,7 @@ export default function EventBlockTimers(props) {
|
||||
delay={delay}
|
||||
placeholder='Start'
|
||||
previousEnd={previousEnd}
|
||||
warning={warning.start}
|
||||
/>
|
||||
<TimeInput
|
||||
name='timeEnd'
|
||||
@@ -60,6 +87,7 @@ export default function EventBlockTimers(props) {
|
||||
delay={delay}
|
||||
placeholder='End'
|
||||
previousEnd={previousEnd}
|
||||
warning={warning.end}
|
||||
/>
|
||||
<TimeInput
|
||||
name='durationOverride'
|
||||
@@ -68,6 +96,7 @@ export default function EventBlockTimers(props) {
|
||||
time={duration}
|
||||
placeholder='Duration'
|
||||
previousEnd={previousEnd}
|
||||
warning={warning.duration}
|
||||
/>
|
||||
{delay !== 0 && delay !== null && (
|
||||
<div className={style.delayNote}>
|
||||
@@ -78,13 +107,6 @@ export default function EventBlockTimers(props) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EventBlockTimers.propTypes = {
|
||||
timeStart: PropTypes.number,
|
||||
timeEnd: PropTypes.number,
|
||||
duration: PropTypes.number,
|
||||
delay: PropTypes.number,
|
||||
actionHandler: PropTypes.func,
|
||||
previousEnd: PropTypes.number,
|
||||
};
|
||||
|
||||
export default memo(EventBlockTimers);
|
||||
@@ -15,6 +15,7 @@
|
||||
gap: 10%;
|
||||
|
||||
.quickBtn {
|
||||
font-weight: 400;
|
||||
width: auto;
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { Button, Checkbox, Tooltip } from '@chakra-ui/react';
|
||||
import { SupportedEvent } from 'ontime-types';
|
||||
|
||||
@@ -17,7 +17,7 @@ interface QuickAddBlockProps {
|
||||
disableAddBlock: boolean;
|
||||
}
|
||||
|
||||
export default function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
const QuickAddBlock = (props: QuickAddBlockProps) => {
|
||||
const { showKbd, eventId, previousEventId, disableAddDelay = true, disableAddBlock } = props;
|
||||
const { addEvent } = useEventAction();
|
||||
const { emitError } = useEmitLog();
|
||||
@@ -117,4 +117,6 @@ export default function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default memo(QuickAddBlock);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TableSettingsContext } from '../../common/context/TableSettingsContext'
|
||||
import useFullscreen from '../../common/hooks/useFullscreen';
|
||||
import { useTimer } from '../../common/hooks/useSocket';
|
||||
import useEventData from '../../common/hooks-query/useEventData';
|
||||
import { formatDisplay, millisToSeconds } from '../../common/utils/dateConfig';
|
||||
import { formatDisplay } from '../../common/utils/dateConfig';
|
||||
import { formatTime } from '../../common/utils/time';
|
||||
import { tooltipDelayFast } from '../../ontimeConfig';
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function TableHeader({ handleCSVExport, featureData }) {
|
||||
|
||||
// prepare presentation variables
|
||||
const isOvertime = timer.current < 0;
|
||||
const timerNow = `${isOvertime ? '-' : ''}${formatDisplay(millisToSeconds(timer.current))}`;
|
||||
const timerNow = `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
|
||||
const timeNow = formatTime(timer.clock, {
|
||||
showSeconds: true,
|
||||
format: 'hh:mm:ss a',
|
||||
|
||||
@@ -12,9 +12,10 @@ import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig';
|
||||
import { formatDisplay } from '../../../common/utils/dateConfig';
|
||||
import { getEventsWithDelay } from '../../../common/utils/eventsManager';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import { titleVariants } from '../common/animation';
|
||||
import { TitleManager } from '../ViewWrapper';
|
||||
|
||||
@@ -39,6 +40,7 @@ interface BackstageProps {
|
||||
export default function Backstage(props: BackstageProps) {
|
||||
const { isMirrored, publ, title, time, backstageEvents, selectedId, general, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
@@ -64,7 +66,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
if (time.current === null) {
|
||||
stageTimer = '- - : - -';
|
||||
} else {
|
||||
stageTimer = formatDisplay(Math.abs(millisToSeconds(time.current)), true);
|
||||
stageTimer = formatDisplay(Math.abs(time.current), true);
|
||||
if (isNegative) {
|
||||
stageTimer = `-${stageTimer}`;
|
||||
}
|
||||
@@ -77,7 +79,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
<div className='event-header'>
|
||||
{general.title}
|
||||
<div className='clock-container'>
|
||||
<div className='label'>Time Now</div>
|
||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||
<div className='time'>{clock}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,15 +105,15 @@ export default function Backstage(props: BackstageProps) {
|
||||
/>
|
||||
<div className='timer-group'>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>Started At</div>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
|
||||
<div className='aux-timers__value'>{startedAt}</div>
|
||||
</div>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>Expected Finish</div>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.expected_finish')}</div>
|
||||
<div className='aux-timers__value'>{expectedFinish}</div>
|
||||
</div>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>Stage Timer</div>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.stage_timer')}</div>
|
||||
<div className='aux-timers__value'>{stageTimer}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,7 +148,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
</ScheduleProvider>
|
||||
|
||||
<div className={showPublicMessage ? 'public-container' : 'public-container public-container--hidden'}>
|
||||
<div className='label'>Public message</div>
|
||||
<div className='label'>{getLocalizedString('common.public_message')}</div>
|
||||
<div className='message'>{publ.text}</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TimerType } from 'ontime-types';
|
||||
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig';
|
||||
import { formatDisplay } from '../../../common/utils/dateConfig';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
const formatOptions = {
|
||||
@@ -36,7 +36,7 @@ export function formatTimerDisplay(timer?: string | number | null): string {
|
||||
} else if (timer === null || typeof timer === 'undefined' || isNaN(timer)) {
|
||||
display = '-- : -- : --';
|
||||
} else {
|
||||
display = formatDisplay(millisToSeconds(timer), true);
|
||||
display = formatDisplay(timer, true);
|
||||
}
|
||||
|
||||
return display;
|
||||
|
||||
@@ -6,9 +6,10 @@ import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig';
|
||||
import { formatDisplay } from '../../../common/utils/dateConfig';
|
||||
import getDelayTo from '../../../common/utils/getDelayTo';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
|
||||
import { fetchTimerData, TimerMessage } from './countdown.helpers';
|
||||
import CountdownSelect from './CountdownSelect';
|
||||
@@ -37,6 +38,7 @@ export default function Countdown(props: CountdownProps) {
|
||||
const { isMirrored, backstageEvents, time, selectedId, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const [searchParams] = useSearchParams();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
const [follow, setFollow] = useState<OntimeEvent | null>(null);
|
||||
const [runningTimer, setRunningTimer] = useState(0);
|
||||
@@ -100,8 +102,8 @@ export default function Countdown(props: CountdownProps) {
|
||||
runningMessage === TimerMessage.ended
|
||||
? formatTime(runningTimer, formatOptionsFinished)
|
||||
: formatDisplay(
|
||||
isSelected ? millisToSeconds(runningTimer) : millisToSeconds(runningTimer + delay),
|
||||
isSelected || time.waiting,
|
||||
isSelected ? runningTimer : runningTimer + delay,
|
||||
isSelected || runningMessage === TimerMessage.waiting,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -112,11 +114,11 @@ export default function Countdown(props: CountdownProps) {
|
||||
) : (
|
||||
<div className='countdown-container' data-testid='countdown-event'>
|
||||
<div className='clock-container'>
|
||||
<div className='label'>Time Now</div>
|
||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||
<div className='time'>{clock}</div>
|
||||
</div>
|
||||
|
||||
<div className='status'>{runningMessage}</div>
|
||||
<div className='status'>{getLocalizedString(`countdown.${runningMessage}`)}</div>
|
||||
|
||||
<span className={`timer ${standby ? 'timer--paused' : ''} ${isRunningFinished ? 'timer--finished' : ''}`}>
|
||||
{formattedTimer}
|
||||
@@ -125,11 +127,11 @@ export default function Countdown(props: CountdownProps) {
|
||||
|
||||
<div className='timer-group'>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>Start Time</div>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.start_time')}</div>
|
||||
<span className={`aux-timers__value ${delayedTimerStyles}`}>{startTime}</span>
|
||||
</div>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>End Time</div>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.end_time')}</div>
|
||||
<span className={`aux-timers__value ${delayedTimerStyles}`}>{endTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,42 +3,43 @@ import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
|
||||
|
||||
import Empty from '../../../common/components/state/Empty';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
|
||||
import { sanitiseTitle } from './countdown.helpers';
|
||||
|
||||
import './Countdown.scss';
|
||||
|
||||
|
||||
interface CountdownSelectProps {
|
||||
events: OntimeRundownEntry[];
|
||||
}
|
||||
|
||||
export default function CountdownSelect(props: CountdownSelectProps) {
|
||||
const { events } = props;
|
||||
const filteredEvents = events.filter((event: OntimeRundownEntry) => event.type === SupportedEvent.Event) as OntimeEvent[];
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
const filteredEvents = events.filter(
|
||||
(event: OntimeRundownEntry) => event.type === SupportedEvent.Event,
|
||||
) as OntimeEvent[];
|
||||
|
||||
return (
|
||||
<div className='event-select' data-testid='countdown-select'>
|
||||
<span className='event-select__title'>Select an event to follow</span>
|
||||
<span className='event-select__title'>{getLocalizedString('countdown.select_event')}</span>
|
||||
<ul className='event-select__events'>
|
||||
{!events.length ? (
|
||||
<Empty text='No events in database' />
|
||||
) : (
|
||||
filteredEvents.map((event: OntimeEvent, counter: number) => {
|
||||
const index = counter + 1;
|
||||
const title = sanitiseTitle(event.title);
|
||||
const start = formatTime(event.timeStart, { format: 'hh:mm' });
|
||||
const end = formatTime(event.timeEnd, { format: 'hh:mm' });
|
||||
const index = counter + 1;
|
||||
const title = sanitiseTitle(event.title);
|
||||
const start = formatTime(event.timeStart, { format: 'hh:mm' });
|
||||
const end = formatTime(event.timeEnd, { format: 'hh:mm' });
|
||||
|
||||
return (
|
||||
<li key={event.id}>
|
||||
<Link to={`/countdown?eventid=${event.id}`}>
|
||||
{`${index}. ${start} → ${end} | ${title}`}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
},
|
||||
)
|
||||
return (
|
||||
<li key={event.id}>
|
||||
<Link to={`/countdown?eventid=${event.id}`}>{`${index}. ${start} → ${end} | ${title}`}</Link>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -3,23 +3,26 @@ import { OntimeEvent, Playback } from 'ontime-types';
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
|
||||
export enum TimerMessage {
|
||||
toStart = 'Time to start',
|
||||
waiting = 'Waiting for event start',
|
||||
running = 'Event running',
|
||||
ended = 'Event ended at',
|
||||
toStart = 'to_start',
|
||||
waiting = 'waiting',
|
||||
running = 'running',
|
||||
ended = 'ended',
|
||||
unhandled = '',
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses string as a title
|
||||
*/
|
||||
export const sanitiseTitle = (title: string | null) =>
|
||||
title ? title : '{no title}';
|
||||
export const sanitiseTitle = (title: string | null) => (title ? title : '{no title}');
|
||||
|
||||
/**
|
||||
* Returns a parsed timer and relevant status message
|
||||
*/
|
||||
export const fetchTimerData = (time: TimeManagerType, follow: OntimeEvent, selectedId: string): { message: TimerMessage, timer: number } => {
|
||||
export const fetchTimerData = (
|
||||
time: TimeManagerType,
|
||||
follow: OntimeEvent,
|
||||
selectedId: string | null,
|
||||
): { message: TimerMessage; timer: number } => {
|
||||
let message;
|
||||
let timer;
|
||||
|
||||
@@ -27,17 +30,14 @@ export const fetchTimerData = (time: TimeManagerType, follow: OntimeEvent, selec
|
||||
// check that is not running
|
||||
message = time.playback === Playback.Pause ? TimerMessage.waiting : TimerMessage.running;
|
||||
timer = time.current ?? 0;
|
||||
|
||||
} else if (time.clock < follow.timeStart) {
|
||||
// if it hasnt started, we count to start
|
||||
message = TimerMessage.toStart;
|
||||
timer = follow.timeStart - time.clock;
|
||||
|
||||
} else if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) {
|
||||
// if it has started, we show running timer
|
||||
message = TimerMessage.waiting;
|
||||
timer = time.current ?? 0;
|
||||
|
||||
} else {
|
||||
// running timer timer is not the one we are following
|
||||
|
||||
@@ -48,18 +48,15 @@ export const fetchTimerData = (time: TimeManagerType, follow: OntimeEvent, selec
|
||||
// if it hasnt started, we count to start
|
||||
message = TimerMessage.toStart;
|
||||
timer = follow.timeStart - time.clock;
|
||||
|
||||
} else if (follow.timeStart <= time.clock) {
|
||||
// if it has started, we show running timer
|
||||
message = TimerMessage.waiting;
|
||||
timer = time.current ?? 0;
|
||||
|
||||
} else {
|
||||
// if it has ended, we show how long ago
|
||||
message = TimerMessage.ended;
|
||||
timer = follow.timeEnd;
|
||||
}
|
||||
|
||||
} else {
|
||||
// if it has ended, we show how long ago
|
||||
message = TimerMessage.ended;
|
||||
|
||||
@@ -12,6 +12,7 @@ import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import { titleVariants } from '../common/animation';
|
||||
import { TitleManager } from '../ViewWrapper';
|
||||
|
||||
@@ -36,6 +37,7 @@ interface BackstageProps {
|
||||
export default function Public(props: BackstageProps) {
|
||||
const { isMirrored, publ, publicTitle, time, events, publicSelectedId, general, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Public Screen';
|
||||
@@ -57,7 +59,7 @@ export default function Public(props: BackstageProps) {
|
||||
<div className='event-header'>
|
||||
{general.title}
|
||||
<div className='clock-container'>
|
||||
<div className='label'>Time Now</div>
|
||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||
<div className='time'>{clock}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,7 +112,7 @@ export default function Public(props: BackstageProps) {
|
||||
</ScheduleProvider>
|
||||
|
||||
<div className={showPublicMessage ? 'public-container' : 'public-container public-container--hidden'}>
|
||||
<div className='label'>Public message</div>
|
||||
<div className='label'>{getLocalizedString('common.public_message')}</div>
|
||||
<div className='message'>{publ.text}</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function StudioClock(props) {
|
||||
{title.titleNext}
|
||||
</div>
|
||||
<div className={isNegative ? 'next-countdown' : 'next-countdown next-countdown--overtime'}>
|
||||
{selectedId != null && formatDisplay(time.current)}
|
||||
{selectedId !== null && formatDisplay(time.current)}
|
||||
</div>
|
||||
<div className='clock-indicators'>
|
||||
{activeIndicators.map((i) => (
|
||||
|
||||
@@ -9,6 +9,7 @@ import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import { formatTimerDisplay, getTimerByType } from '../common/viewerUtils';
|
||||
import { TitleManager } from '../ViewWrapper';
|
||||
|
||||
@@ -47,6 +48,7 @@ interface TimerProps {
|
||||
export default function Timer(props: TimerProps) {
|
||||
const { isMirrored, general, pres, title, time, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Timer';
|
||||
@@ -63,7 +65,7 @@ export default function Timer(props: TimerProps) {
|
||||
const isNegative =
|
||||
(time.current ?? 0) < 0 && time.timerType !== TimerType.Clock && time.timerType !== TimerType.CountUp;
|
||||
|
||||
const showEndMessage = time.current < 0 && general.endMessage;
|
||||
const showEndMessage = (time.current ?? 1) < 0 && general.endMessage;
|
||||
const showProgress = time.playback !== Playback.Stop;
|
||||
const showFinished = time.finished && (time.timerType !== TimerType.Clock || showEndMessage);
|
||||
const showClock = time.timerType !== TimerType.Clock;
|
||||
@@ -87,7 +89,7 @@ export default function Timer(props: TimerProps) {
|
||||
</div>
|
||||
|
||||
<div className={`clock-container ${showClock ? '' : 'clock-container--hidden'}`}>
|
||||
<div className='label'>Time Now</div>
|
||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||
<div className='clock'>{clock}</div>
|
||||
</div>
|
||||
|
||||
@@ -108,8 +110,8 @@ export default function Timer(props: TimerProps) {
|
||||
|
||||
<ProgressBar
|
||||
className={isPlaying ? 'progress-container' : 'progress-container progress-container--paused'}
|
||||
now={time.current}
|
||||
complete={time.duration}
|
||||
now={time.current || 0}
|
||||
complete={time.duration || 0}
|
||||
hidden={!showProgress}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type TranslationObject = { [key: string]: string };
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { createContext, useCallback, useContext, useState } from 'react';
|
||||
|
||||
import { langDe } from '@/translation/languages/de';
|
||||
import { langEn } from '@/translation/languages/en';
|
||||
|
||||
const translationsList = {
|
||||
en: langEn,
|
||||
de: langDe,
|
||||
};
|
||||
const ALLOWED_LANGUAGES = Object.keys(translationsList);
|
||||
const DEFAULT_LANGUAGE = 'en';
|
||||
export const TranslationContext = createContext(undefined);
|
||||
|
||||
export const TranslationProvider = ({ children }) => {
|
||||
const [language, setLanguageState] = useState('en');
|
||||
|
||||
const getLocalizedString = useCallback(
|
||||
(key, lang = language) => {
|
||||
if (key in translationsList[lang]) {
|
||||
return translationsList[lang][key];
|
||||
} else if (lang !== DEFAULT_LANGUAGE) {
|
||||
return getLocalizedString(key, 'en');
|
||||
}
|
||||
},
|
||||
[language],
|
||||
);
|
||||
|
||||
const setLanguage = useCallback((language) => {
|
||||
language = language.toLowerCase();
|
||||
if (ALLOWED_LANGUAGES.includes(language)) {
|
||||
setLanguageState(language);
|
||||
console.info(`Language set to ${language}`);
|
||||
} else {
|
||||
console.warn(`Language code ${language} does not exist.`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const contextValue = {
|
||||
getLocalizedString,
|
||||
setLanguage,
|
||||
};
|
||||
|
||||
return <TranslationContext.Provider value={contextValue}>{children}</TranslationContext.Provider>;
|
||||
};
|
||||
|
||||
export const useTranslation = () => {
|
||||
const { getLocalizedString, setLanguage } = useContext(TranslationContext);
|
||||
return { getLocalizedString, setLanguage };
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TranslationObject } from '../Translation.types';
|
||||
|
||||
export const langDe: TranslationObject = {
|
||||
'common.time_now': 'Aktuelle Zeit',
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { TranslationObject } from '../Translation.types';
|
||||
|
||||
export const langEn: TranslationObject = {
|
||||
'common.end_time': 'End Time',
|
||||
'common.expected_finish': 'Expected Finish',
|
||||
'common.now': 'Now',
|
||||
'common.next': 'Next',
|
||||
'common.public_message': 'Public message',
|
||||
'common.start_time': 'Start Time',
|
||||
'common.stage_timer': 'Stage Timer',
|
||||
'common.started_at': 'Started At',
|
||||
'common.time_now': 'Time now',
|
||||
'countdown.ended': 'Event ended at',
|
||||
'countdown.running': 'Event running',
|
||||
'countdown.select_event': 'Select an event to follow',
|
||||
'countdown.to_start': 'Time to start',
|
||||
'countdown.waiting': 'Waiting for event start',
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import sentryVitePlugin from '@sentry/vite-plugin';
|
||||
import { sentryVitePlugin } from '@sentry/vite-plugin';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
@@ -178,11 +178,11 @@ app.whenReady().then(() => {
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
dialog.showErrorBox('Ontime failed to reach server', error);
|
||||
console.log('ERROR: Ontime failed to reach server', error);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
dialog.showErrorBox('Ontime failed to start', error);
|
||||
console.log('ERROR: Ontime failed to start', error);
|
||||
});
|
||||
|
||||
// recreate window if no others open
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "2.0.0-beta1",
|
||||
"version": "2.0.0-beta2",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -12,7 +12,7 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "main.js",
|
||||
"devDependencies": {
|
||||
"electron": "^21.1.0",
|
||||
"electron": "^22.3.5",
|
||||
"electron-builder": "^23.6.0",
|
||||
"eslint": "^8.31.0",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "2.0.0-beta1",
|
||||
"version": "2.0.0-beta2",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@sentry/node": "^7.24.1",
|
||||
"@sentry/tracing": "^7.24.1",
|
||||
"@sentry/node": "^7.47.0",
|
||||
"@sentry/tracing": "^7.47.0",
|
||||
"body-parser": "^1.20.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.1",
|
||||
|
||||
@@ -112,7 +112,7 @@ export class SocketServer implements IAdapter {
|
||||
}
|
||||
|
||||
// message is any serializable value
|
||||
send(message: unknown) {
|
||||
sendAsJson(message: unknown) {
|
||||
this.wss?.clients.forEach((client) => {
|
||||
if (client !== this.wss && client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify(message));
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { socket } from './WebsocketAdapter.js';
|
||||
|
||||
/**
|
||||
* Utility function to notify clients that the REST data is stale
|
||||
* @param payload -- possible patch payload
|
||||
*/
|
||||
export function sendRefetch(payload: any | null = null) {
|
||||
socket.sendAsJson({
|
||||
type: 'ontime-refetch',
|
||||
payload,
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { integrationService } from './services/integration-service/IntegrationSe
|
||||
import { logger } from './classes/Logger.js';
|
||||
import { oscIntegration } from './services/integration-service/OscIntegration.js';
|
||||
import { populateStyles } from './modules/loadStyles.js';
|
||||
import { eventStore, getInitialPayload } from './stores/EventStore.js';
|
||||
|
||||
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||
|
||||
@@ -55,7 +56,7 @@ app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// Implement route endpoints
|
||||
app.use('/eventlist', rundownRouter);
|
||||
app.use('/events', rundownRouter);
|
||||
app.use('/eventdata', eventDataRouter);
|
||||
app.use('/ontime', ontimeRouter);
|
||||
app.use('/playback', playbackRouter);
|
||||
@@ -132,7 +133,10 @@ export const startServer = async () => {
|
||||
expressServer = http.createServer(app);
|
||||
|
||||
socket.init(expressServer);
|
||||
|
||||
// provide initial payload to event store
|
||||
eventLoader.init();
|
||||
eventStore.init(getInitialPayload());
|
||||
|
||||
expressServer.listen(serverPort, '0.0.0.0');
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ class Logger {
|
||||
}
|
||||
|
||||
try {
|
||||
socket.send({
|
||||
socket.sendAsJson({
|
||||
type: 'ontime-log',
|
||||
payload: log,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Class Event Provider is a mediator for handling the local db
|
||||
* and adds logic specific to ontime data
|
||||
*/
|
||||
import { EventData, ViewSettings } from 'ontime-types';
|
||||
import { EventData, SupportedEvent, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { data, db } from '../../modules/loadDb.js';
|
||||
import { safeMerge } from './DataProvider.utils.js';
|
||||
@@ -35,7 +35,9 @@ export class DataProvider {
|
||||
const eventIndex = data.rundown.findIndex((e) => e.id === eventId);
|
||||
const persistedEvent = data.rundown[eventIndex];
|
||||
const newEvent = { ...persistedEvent, ...newData };
|
||||
newEvent.revision++;
|
||||
if (newEvent.type === SupportedEvent.Event) {
|
||||
newEvent.revision++;
|
||||
}
|
||||
data.rundown[eventIndex] = newEvent;
|
||||
await this.persist();
|
||||
return data.rundown[eventIndex];
|
||||
|
||||
@@ -24,9 +24,9 @@ export class EventLoader {
|
||||
instance = this;
|
||||
}
|
||||
|
||||
// we need to delay init until the store is ready
|
||||
init() {
|
||||
this.reset();
|
||||
this.loadedEvent = null;
|
||||
this.reset(false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,19 +9,19 @@ import {
|
||||
reorderEvent,
|
||||
} from '../services/RundownService.ts';
|
||||
|
||||
// Create controller for GET request to '/eventlist'
|
||||
// Create controller for GET request to '/events'
|
||||
// Returns -
|
||||
export const rundownGetAll = async (req, res) => {
|
||||
res.json(DataProvider.getRundown());
|
||||
};
|
||||
|
||||
// Create controller for GET request to '/eventlist/:eventId'
|
||||
// Create controller for GET request to '/events/:eventId'
|
||||
// Returns -
|
||||
export const getEventById = async (req, res) => {
|
||||
res.json(DataProvider.getEventById(req.params?.eventId));
|
||||
};
|
||||
|
||||
// Create controller for POST request to '/eventlist/'
|
||||
// Create controller for POST request to '/events/'
|
||||
// Returns -
|
||||
export const rundownPost = async (req, res) => {
|
||||
if (failEmptyObjects(req.body, res)) {
|
||||
@@ -36,7 +36,7 @@ export const rundownPost = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Create controller for PUT request to '/eventlist/'
|
||||
// Create controller for PUT request to '/events/'
|
||||
// Returns -
|
||||
export const rundownPut = async (req, res) => {
|
||||
if (failEmptyObjects(req.body, res)) {
|
||||
@@ -65,7 +65,7 @@ export const rundownReorder = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Create controller for PATCH request to '/eventlist/applydelay/:eventId'
|
||||
// Create controller for PATCH request to '/events/applydelay/:eventId'
|
||||
// Returns -
|
||||
export const rundownApplyDelay = async (req, res) => {
|
||||
try {
|
||||
@@ -76,7 +76,7 @@ export const rundownApplyDelay = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Create controller for DELETE request to '/eventlist/:eventId'
|
||||
// Create controller for DELETE request to '/events/:eventId'
|
||||
// Returns -
|
||||
export const deleteEventById = async (req, res) => {
|
||||
try {
|
||||
@@ -87,7 +87,7 @@ export const deleteEventById = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Create controller for DELETE request to '/eventlist/'
|
||||
// Create controller for DELETE request to '/events/'
|
||||
// Returns -
|
||||
export const rundownDelete = async (req, res) => {
|
||||
try {
|
||||
|
||||
+4
-2
@@ -2,12 +2,14 @@
|
||||
--background-color-override: #ececec;
|
||||
--color-override: #101010;
|
||||
--secondary-color-override: #404040;
|
||||
--accent-color-override: #FA5656;
|
||||
--accent-color-override: #fa5656;
|
||||
--label-color-override: #6c6c6c;
|
||||
--timer-color-override: #202020;
|
||||
--card-background-color-override: #FFF;
|
||||
--card-background-color-override: #fff;
|
||||
--font-family-override: "Open Sans";
|
||||
--font-family-bold-override: "Arial Black";
|
||||
--timer-progress-bg-override: #fff;
|
||||
--timer-progress-override: #202020;
|
||||
}
|
||||
|
||||
.timer {
|
||||
|
||||
@@ -34,5 +34,6 @@ export const delay: Omit<OntimeDelay, 'id'> = {
|
||||
};
|
||||
|
||||
export const block: Omit<OntimeBlock, 'id'> = {
|
||||
title: '',
|
||||
type: SupportedEvent.Block,
|
||||
};
|
||||
|
||||
@@ -18,26 +18,26 @@ import {
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
// create route between controller and '/eventlist/' endpoint
|
||||
// create route between controller and '/events/' endpoint
|
||||
router.get('/', rundownGetAll);
|
||||
|
||||
// create route between controller and '/eventlist/:eventId' endpoint
|
||||
// create route between controller and '/events/:eventId' endpoint
|
||||
router.get('/:eventId', paramsMustHaveEventId, getEventById);
|
||||
|
||||
// create route between controller and '/eventlist/' endpoint
|
||||
// create route between controller and '/events/' endpoint
|
||||
router.post('/', rundownPostValidator, rundownPost);
|
||||
|
||||
// create route between controller and '/eventlist/' endpoint
|
||||
// create route between controller and '/events/' endpoint
|
||||
router.put('/', rundownPutValidator, rundownPut);
|
||||
|
||||
// create route between controller and '/eventlist/reorder' endpoint
|
||||
// create route between controller and '/events/reorder' endpoint
|
||||
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
|
||||
|
||||
// create route between controller and '/eventlist/applydelay/:eventId' endpoint
|
||||
// create route between controller and '/events/applydelay/:eventId' endpoint
|
||||
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
|
||||
|
||||
// create route between controller and '/eventlist/all' endpoint
|
||||
// create route between controller and '/events/all' endpoint
|
||||
router.delete('/all', rundownDelete);
|
||||
|
||||
// create route between controller and '/eventlist/:eventId' endpoint
|
||||
// create route between controller and '/events/:eventId' endpoint
|
||||
router.delete('/:eventId', paramsMustHaveEventId, deleteEventById);
|
||||
|
||||
@@ -219,7 +219,9 @@ export class PlaybackService {
|
||||
if (eventTimer.loadedTimerId) {
|
||||
const delayInMs = delayTime * 1000 * 60;
|
||||
eventTimer.delay(delayInMs);
|
||||
logger.info('PLAYBACK', `Added ${delayTime} min delay`);
|
||||
delayInMs > 0
|
||||
? logger.info('PLAYBACK', `Added ${delayTime} min delay`)
|
||||
: logger.info('PLAYBACK', `Removed ${delayTime} min delay`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { OntimeBaseEvent, OntimeBlock, OntimeDelay, OntimeEvent } from 'ontime-types';
|
||||
import { OntimeBaseEvent, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent } from 'ontime-types';
|
||||
import { generateId } from 'ontime-utils';
|
||||
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
import { block as blockDef, delay as delayDef, event as eventDef } from '../models/eventsDefinition.js';
|
||||
import { MAX_EVENTS } from '../settings.js';
|
||||
import { EventLoader, eventLoader } from '../classes/event-loader/EventLoader.js';
|
||||
import { eventTimer } from './TimerService.js';
|
||||
import { sendRefetch } from '../adapters/websocketAux.js';
|
||||
|
||||
/**
|
||||
* Checks if a list of IDs is in the current selection
|
||||
@@ -145,6 +146,7 @@ export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeD
|
||||
}
|
||||
updateTimer([id]);
|
||||
updateChangeNumEvents();
|
||||
sendRefetch();
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
@@ -156,6 +158,7 @@ export async function editEvent(eventData) {
|
||||
}
|
||||
const newEvent = await DataProvider.updateEventById(eventId, eventData);
|
||||
updateTimer([eventId]);
|
||||
sendRefetch();
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
@@ -168,6 +171,7 @@ export async function deleteEvent(eventId) {
|
||||
await DataProvider.deleteEvent(eventId);
|
||||
updateTimer([eventId]);
|
||||
updateChangeNumEvents();
|
||||
sendRefetch();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,6 +182,7 @@ export async function deleteAllEvents() {
|
||||
await DataProvider.clearRundown();
|
||||
updateTimer();
|
||||
updateChangeNumEvents();
|
||||
sendRefetch();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,6 +207,7 @@ export async function reorderEvent(eventId, from, to) {
|
||||
// save rundown
|
||||
await DataProvider.setRundown(rundown);
|
||||
updateTimer();
|
||||
sendRefetch();
|
||||
return reorderedItem;
|
||||
}
|
||||
|
||||
@@ -212,15 +218,13 @@ export async function reorderEvent(eventId, from, to) {
|
||||
*/
|
||||
export async function applyDelay(eventId) {
|
||||
const rundown = DataProvider.getRundown();
|
||||
// AUX
|
||||
let delayIndex = null;
|
||||
let blockIndex = null;
|
||||
let delayValue = 0;
|
||||
|
||||
for (const [index, e] of rundown.entries()) {
|
||||
// look for delay
|
||||
if (delayIndex === null) {
|
||||
if (e.id === eventId && e.type === 'delay') {
|
||||
if (e.id === eventId && e.type === SupportedEvent.Delay) {
|
||||
delayValue = e.duration;
|
||||
delayIndex = index;
|
||||
}
|
||||
@@ -228,16 +232,14 @@ export async function applyDelay(eventId) {
|
||||
|
||||
// apply delay value to all items until block or end
|
||||
else {
|
||||
if (e.type === 'event') {
|
||||
if (e.type === SupportedEvent.Event) {
|
||||
// update times
|
||||
e.timeStart += delayValue;
|
||||
e.timeEnd += delayValue;
|
||||
|
||||
// increment revision
|
||||
e.revision += 1;
|
||||
} else if (e.type === 'block') {
|
||||
// save id and stop
|
||||
blockIndex = index;
|
||||
} else if (e.type === SupportedEvent.Block) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -250,13 +252,10 @@ export async function applyDelay(eventId) {
|
||||
// delete delay
|
||||
rundown.splice(delayIndex, 1);
|
||||
|
||||
// delete block
|
||||
// index would have moved down since we deleted delay
|
||||
if (blockIndex) rundown.splice(blockIndex - 1, 1);
|
||||
|
||||
// update rundown
|
||||
await DataProvider.setRundown(rundown);
|
||||
updateTimer();
|
||||
sendRefetch();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EndAction, Playback, TimerLifeCycle, TimerState } from 'ontime-types';
|
||||
import { EndAction, OntimeEvent, Playback, TimerLifeCycle, TimerState } from 'ontime-types';
|
||||
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
import { PlaybackService } from './PlaybackService.js';
|
||||
@@ -8,13 +8,23 @@ import { integrationService } from './integration-service/IntegrationService.js'
|
||||
import { getCurrent, getElapsed, getExpectedFinish } from './timerUtils.js';
|
||||
import { clock } from './Clock.js';
|
||||
|
||||
/**
|
||||
* Aux type
|
||||
*/
|
||||
type Timer = {
|
||||
_startedAt: number;
|
||||
_finishAt: number;
|
||||
duration: number;
|
||||
current: number;
|
||||
};
|
||||
|
||||
export class TimerService {
|
||||
private readonly _interval: NodeJS.Timer;
|
||||
|
||||
playback: Playback;
|
||||
timer: TimerState;
|
||||
|
||||
loadedTimerId: null;
|
||||
loadedTimerId: string | null;
|
||||
private pausedTime: number;
|
||||
private pausedAt: number | null;
|
||||
private secondaryTarget: number | null;
|
||||
@@ -250,7 +260,7 @@ export class TimerService {
|
||||
|
||||
clock: this.timer.clock,
|
||||
secondaryTimer: this.timer.secondaryTimer,
|
||||
_secondaryTarget: this.secondaryTarget,
|
||||
secondaryTarget: this.secondaryTarget,
|
||||
};
|
||||
const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updateRoll(tempCurrentTimer);
|
||||
|
||||
@@ -263,6 +273,7 @@ export class TimerService {
|
||||
this._onFinish();
|
||||
}
|
||||
|
||||
// to load the next event we have to escalate to parent service
|
||||
if (doRollLoad) {
|
||||
PlaybackService.roll();
|
||||
}
|
||||
@@ -321,7 +332,13 @@ export class TimerService {
|
||||
}
|
||||
}
|
||||
|
||||
roll(currentEvent, nextEvent, timers) {
|
||||
/**
|
||||
* Loads roll information into timer service
|
||||
* @param {OntimeEvent | null} currentEvent -- both current event and next event cant be null
|
||||
* @param {OntimeEvent | null} nextEvent -- both current event and next event cant be null
|
||||
* @param {Timer} timers
|
||||
*/
|
||||
roll(currentEvent: OntimeEvent | null, nextEvent: OntimeEvent | null, timers: Timer) {
|
||||
this._clear();
|
||||
this.timer.clock = clock.timeNow();
|
||||
|
||||
@@ -336,9 +353,11 @@ export class TimerService {
|
||||
this.timer.duration = timers.duration;
|
||||
this.timer.current = timers.current;
|
||||
} else if (nextEvent) {
|
||||
// account for day after
|
||||
const nextStart = nextEvent.timeStart < this.timer.clock ? nextEvent.timeStart + DAY_TO_MS : nextEvent.timeStart;
|
||||
// nothing now, but something coming up
|
||||
this.timer.secondaryTimer = nextEvent.timeStart - this.timer.clock;
|
||||
this.secondaryTarget = nextEvent.timeStart;
|
||||
this.timer.secondaryTimer = nextStart - this.timer.clock;
|
||||
this.secondaryTarget = nextStart;
|
||||
}
|
||||
|
||||
this.playback = Playback.Roll;
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
DAY_TO_MS,
|
||||
getRollTimers,
|
||||
normaliseEndTime,
|
||||
replacePlaceholder,
|
||||
sortArrayByProperty,
|
||||
updateRoll,
|
||||
} from '../rollUtils.ts';
|
||||
import { DAY_TO_MS, getRollTimers, normaliseEndTime, sortArrayByProperty, updateRoll } from '../rollUtils.ts';
|
||||
|
||||
// test sortArrayByProperty()
|
||||
describe('sort simple arrays of objects', () => {
|
||||
@@ -265,8 +258,7 @@ describe('test that roll loads selection in right order', () => {
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
// TODO: always roll
|
||||
it.skip('if timer is at 100', () => {
|
||||
it('if timer is at 100', () => {
|
||||
const now = 100;
|
||||
const expected = {
|
||||
nowIndex: null,
|
||||
@@ -279,15 +271,14 @@ describe('test that roll loads selection in right order', () => {
|
||||
nextEvent: eventlist[0],
|
||||
nextPublicEvent: eventlist[4],
|
||||
currentEvent: null,
|
||||
currentPublicEvent: eventlist[6],
|
||||
currentPublicEvent: null,
|
||||
};
|
||||
|
||||
const state = getRollTimers(eventlist, now);
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
// TODO: always roll
|
||||
it.skip('handles rolls to next day with real values', () => {
|
||||
it('handles rolls to next day with real values', () => {
|
||||
const singleEventList = [
|
||||
{
|
||||
id: 1,
|
||||
@@ -459,68 +450,6 @@ describe('test that roll behaviour with overlapping times', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// test replacePlaceholder()
|
||||
describe('test that it replaces data correctly', () => {
|
||||
const values = {
|
||||
$timer: 'timer',
|
||||
$title: 'title',
|
||||
$presenter: 'presenter',
|
||||
$subtitle: 'subtitle',
|
||||
'$next-title': 'next title',
|
||||
'$next-presenter': 'next presenter',
|
||||
'$next-subtitle': 'next subtitle',
|
||||
};
|
||||
|
||||
it('replaces timer', () => {
|
||||
const test = '___1232132 $timer';
|
||||
const expected = '___1232132 timer';
|
||||
const s = replacePlaceholder(test, values);
|
||||
expect(s).toBe(expected);
|
||||
});
|
||||
|
||||
it('replaces title', () => {
|
||||
const test = '___1232132 $title';
|
||||
const expected = '___1232132 title';
|
||||
const s = replacePlaceholder(test, values);
|
||||
expect(s).toBe(expected);
|
||||
});
|
||||
|
||||
it('replaces presenter', () => {
|
||||
const test = '___1232132 $presenter';
|
||||
const expected = '___1232132 presenter';
|
||||
const s = replacePlaceholder(test, values);
|
||||
expect(s).toBe(expected);
|
||||
});
|
||||
|
||||
it('replaces subtitle', () => {
|
||||
const test = '___1232132 $subtitle';
|
||||
const expected = '___1232132 subtitle';
|
||||
const s = replacePlaceholder(test, values);
|
||||
expect(s).toBe(expected);
|
||||
});
|
||||
|
||||
it('replaces next next title', () => {
|
||||
const test = '___1232132 $next-title';
|
||||
const expected = '___1232132 next title';
|
||||
const s = replacePlaceholder(test, values);
|
||||
expect(s).toBe(expected);
|
||||
});
|
||||
|
||||
it('replaces next presenter', () => {
|
||||
const test = '___1232132 $next-presenter';
|
||||
const expected = '___1232132 next presenter';
|
||||
const s = replacePlaceholder(test, values);
|
||||
expect(s).toBe(expected);
|
||||
});
|
||||
|
||||
it('replaces next subtitle', () => {
|
||||
const test = '___1232132 $next-subtitle';
|
||||
const expected = '___1232132 next subtitle';
|
||||
const s = replacePlaceholder(test, values);
|
||||
expect(s).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
// test getRollTimers() on issue #58
|
||||
describe('test that roll behaviour multi day event edge cases', () => {
|
||||
it('if the start time is the day after end time, and start time is earlier than now', () => {
|
||||
@@ -621,7 +550,7 @@ describe('typical scenarios', () => {
|
||||
_finishAt: 15,
|
||||
clock: 11,
|
||||
secondaryTimer: null,
|
||||
_secondaryTarget: null,
|
||||
secondaryTarget: null,
|
||||
};
|
||||
|
||||
const expected = {
|
||||
@@ -648,12 +577,12 @@ describe('typical scenarios', () => {
|
||||
_finishAt: null,
|
||||
clock: 11,
|
||||
secondaryTimer: 1,
|
||||
_secondaryTarget: 15,
|
||||
secondaryTarget: 15,
|
||||
};
|
||||
|
||||
const expected = {
|
||||
updatedTimer: null,
|
||||
updatedSecondaryTimer: timers._secondaryTarget - timers.clock,
|
||||
updatedSecondaryTimer: timers.secondaryTarget - timers.clock,
|
||||
doRollLoad: false,
|
||||
isFinished: false,
|
||||
};
|
||||
@@ -668,11 +597,11 @@ describe('typical scenarios', () => {
|
||||
_finishAt: 11,
|
||||
clock: 12,
|
||||
secondaryTimer: null,
|
||||
_secondaryTarget: null,
|
||||
secondaryTarget: null,
|
||||
};
|
||||
|
||||
const expected = {
|
||||
updatedTimer: null,
|
||||
updatedTimer: -1,
|
||||
updatedSecondaryTimer: null,
|
||||
doRollLoad: true,
|
||||
isFinished: true,
|
||||
@@ -688,12 +617,12 @@ describe('typical scenarios', () => {
|
||||
_finishAt: null,
|
||||
clock: 16,
|
||||
secondaryTimer: 1,
|
||||
_secondaryTarget: 15,
|
||||
secondaryTarget: 15,
|
||||
};
|
||||
|
||||
const expected = {
|
||||
updatedTimer: null,
|
||||
updatedSecondaryTimer: timers._secondaryTarget - timers.clock,
|
||||
updatedSecondaryTimer: timers.secondaryTarget - timers.clock,
|
||||
doRollLoad: true,
|
||||
isFinished: false,
|
||||
};
|
||||
@@ -708,12 +637,12 @@ describe('typical scenarios', () => {
|
||||
_finishAt: null,
|
||||
clock: 15,
|
||||
secondaryTimer: 0,
|
||||
_secondaryTarget: 15,
|
||||
secondaryTarget: 15,
|
||||
};
|
||||
|
||||
const expected = {
|
||||
updatedTimer: null,
|
||||
updatedSecondaryTimer: timers._secondaryTarget - timers.clock,
|
||||
updatedSecondaryTimer: timers.secondaryTarget - timers.clock,
|
||||
doRollLoad: true,
|
||||
isFinished: false,
|
||||
};
|
||||
|
||||
@@ -17,72 +17,75 @@ export const normaliseEndTime = (start: number, end: number) => (end < start ? e
|
||||
* @returns {array} copy of array sorted in ascending order
|
||||
*/
|
||||
|
||||
export const sortArrayByProperty = (arr, property) => {
|
||||
export const sortArrayByProperty = <T>(arr: T[], property: string): T[] => {
|
||||
return [...arr].sort((a, b) => {
|
||||
return a[property] - b[property];
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Replaces placeholder variables in string with given data
|
||||
* @param {string} str - string to analyse
|
||||
* @param {object} values - map of variables: values to use
|
||||
* @returns {string} finished string
|
||||
*/
|
||||
|
||||
export const replacePlaceholder = (str, values) => {
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
str = str.replace(k, v);
|
||||
}
|
||||
return str;
|
||||
type Timer = {
|
||||
_startedAt: number;
|
||||
_finishAt: number;
|
||||
duration: number;
|
||||
current: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds loading information given a current rundown and time
|
||||
* @param rundown
|
||||
* @param timeNow
|
||||
* @param {OntimeEvent[]} rundown - List of playable events
|
||||
* @param {number} timeNow - time now in ms
|
||||
* @returns {{}}
|
||||
*/
|
||||
export const getRollTimers = (rundown: OntimeEvent[], timeNow: number) => {
|
||||
let nowIndex: number | null = null; // index of event now
|
||||
let nowId: string | null = null; // id of event now
|
||||
let publicIndex: number | null = null; // index of public event now
|
||||
let publicTime = -1;
|
||||
let nextIndex: number | null = null; // index of next event
|
||||
let publicNextIndex: number | null = null; // index of next public event
|
||||
let timeToNext: number | null = null; // counter: time for next event
|
||||
let publicTimeToNext: number | null = null; // counter: time for next public event
|
||||
let timers = null;
|
||||
let timers: Timer | null = null;
|
||||
|
||||
// Order events by startTime
|
||||
const orderedEvents = sortArrayByProperty(rundown, 'timeStart');
|
||||
|
||||
// preload first if we are past events
|
||||
const lastEvent = orderedEvents[orderedEvents.length - 1];
|
||||
const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd);
|
||||
|
||||
let nextEvent = null;
|
||||
let nextPublicEvent = null;
|
||||
let currentEvent = null;
|
||||
let currentPublicEvent = null;
|
||||
let nextEvent: OntimeEvent | null = null;
|
||||
let nextPublicEvent: OntimeEvent | null = null;
|
||||
let currentEvent: OntimeEvent | null = null;
|
||||
let currentPublicEvent: OntimeEvent | null = null;
|
||||
|
||||
if (timeNow > lastNormalEnd) {
|
||||
nextIndex = 0;
|
||||
timeToNext = orderedEvents[0].timeStart + DAY_TO_MS - timeNow;
|
||||
// we are past last end
|
||||
// preload first and find next
|
||||
|
||||
// look for next public
|
||||
for (const event of orderedEvents) {
|
||||
if (event.isPublic) {
|
||||
nextPublicEvent = event;
|
||||
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
break;
|
||||
const firstEvent = orderedEvents[0];
|
||||
nextIndex = 0;
|
||||
nextEvent = firstEvent;
|
||||
timeToNext = firstEvent.timeStart + DAY_TO_MS - timeNow;
|
||||
|
||||
if (firstEvent.isPublic) {
|
||||
nextPublicEvent = firstEvent;
|
||||
publicNextIndex = 0;
|
||||
} else {
|
||||
// look for next public
|
||||
// dev note: we feel that this is more efficient than filtering
|
||||
// since the next event will likely be close to the one playing
|
||||
for (const event of orderedEvents) {
|
||||
if (event.isPublic) {
|
||||
nextPublicEvent = event;
|
||||
// we need the index before this was sorted
|
||||
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// flags: select first event if several overlapping
|
||||
let nowFound = false;
|
||||
// keep track of the end times when looking for public
|
||||
let publicTime = -1;
|
||||
|
||||
// loop through events, look for where we should be
|
||||
for (const event of orderedEvents) {
|
||||
// When does the event end (handle midnight)
|
||||
const normalEnd = normaliseEndTime(event.timeStart, event.timeEnd);
|
||||
@@ -90,27 +93,18 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number) => {
|
||||
if (normalEnd <= timeNow) {
|
||||
// event ran already
|
||||
|
||||
// public event might not be the one running
|
||||
if (event.isPublic && normalEnd > publicTime) {
|
||||
// public event might not be the one running
|
||||
publicTime = normalEnd;
|
||||
currentPublicEvent = event;
|
||||
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
}
|
||||
} else if (normalEnd > timeNow && timeNow >= event.timeStart && !nowFound) {
|
||||
// event is running
|
||||
|
||||
// it could also be public
|
||||
if (event.isPublic) {
|
||||
publicTime = normalEnd;
|
||||
currentPublicEvent = event;
|
||||
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
}
|
||||
|
||||
currentEvent = event;
|
||||
nowIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
nowId = event.id;
|
||||
|
||||
// set timers
|
||||
timers = {
|
||||
_startedAt: event.timeStart,
|
||||
_finishAt: event.timeEnd,
|
||||
@@ -118,25 +112,39 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number) => {
|
||||
current: normalEnd - timeNow,
|
||||
};
|
||||
nowFound = true;
|
||||
|
||||
// it could also be public
|
||||
if (event.isPublic) {
|
||||
publicTime = normalEnd;
|
||||
currentPublicEvent = event;
|
||||
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
}
|
||||
} else if (normalEnd > timeNow) {
|
||||
// event will run
|
||||
|
||||
// no need to look after found first
|
||||
if (nextIndex !== null && publicNextIndex !== null) continue;
|
||||
// we already know whats next and next-public
|
||||
if (nextIndex !== null && publicNextIndex !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// look for next events
|
||||
// check how far the start is from now
|
||||
const wait = event.timeStart - timeNow;
|
||||
const timeToEventStart = event.timeStart - timeNow;
|
||||
|
||||
if (nextIndex === null || wait < timeToNext) {
|
||||
timeToNext = wait;
|
||||
// we don't have a next or this one starts sooner than current next
|
||||
if (nextIndex === null || timeToEventStart < timeToNext) {
|
||||
timeToNext = timeToEventStart;
|
||||
nextEvent = event;
|
||||
nextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
}
|
||||
if ((publicNextIndex === null || wait < publicTimeToNext) && event.isPublic) {
|
||||
publicTimeToNext = wait;
|
||||
nextPublicEvent = event;
|
||||
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
|
||||
if (event.isPublic) {
|
||||
// if we don't have a public next or this one start sooner than assigned next
|
||||
if (publicNextIndex === null || timeToEventStart < publicTimeToNext) {
|
||||
publicTimeToNext = timeToEventStart;
|
||||
nextPublicEvent = event;
|
||||
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,55 +165,50 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number) => {
|
||||
};
|
||||
};
|
||||
|
||||
type CurrentTimers = {
|
||||
selectedEventId: string | null;
|
||||
current: number | null;
|
||||
_finishAt: number | null;
|
||||
clock: number | null;
|
||||
secondaryTimer: number | null;
|
||||
secondaryTarget: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Implements update functions for roll mode
|
||||
* @param {object} currentTimers
|
||||
* @param {object} currentTimers.selectedEventId - Id of currently selected event
|
||||
* @param {object} currentTimers.current - Running timer
|
||||
* @param {object} currentTimers._finishAt - Expected finish time
|
||||
* @param {object} currentTimers.clock - time now
|
||||
* @param {object} currentTimers.secondaryTimer - secondary timer
|
||||
* @param {object} currentTimers._secondaryTarget - finish time of secondary timer
|
||||
* @param {CurrentTimers} currentTimers
|
||||
* @returns {object} object with selection variables
|
||||
*/
|
||||
export const updateRoll = (currentTimers) => {
|
||||
const { selectedEventId, current, _finishAt, clock, secondaryTimer, _secondaryTarget } = currentTimers;
|
||||
export const updateRoll = (currentTimers: CurrentTimers) => {
|
||||
const { selectedEventId, current, _finishAt, clock, secondaryTimer, secondaryTarget } = currentTimers;
|
||||
|
||||
// timers
|
||||
let updatedTimer = current;
|
||||
let updatedSecondaryTimer = secondaryTimer;
|
||||
// whether rollLoad should be called
|
||||
// whether rollLoad should be called: force reload of events
|
||||
let doRollLoad = false;
|
||||
// whether finished event should trigger
|
||||
let isFinished = false;
|
||||
let isPrimaryFinished = false;
|
||||
|
||||
if (selectedEventId && current >= 0) {
|
||||
if (selectedEventId && current !== null) {
|
||||
// if we have something selected and a timer, we are running
|
||||
// this is true because roll never goes into negative times
|
||||
|
||||
// update timer
|
||||
updatedTimer = _finishAt - clock;
|
||||
if (updatedTimer < 0) {
|
||||
isFinished = true;
|
||||
updatedTimer = null;
|
||||
isPrimaryFinished = true;
|
||||
// we need a new event
|
||||
doRollLoad = true;
|
||||
}
|
||||
} else if (secondaryTimer >= 0) {
|
||||
// if secondaryTimer is running we are in waiting to roll
|
||||
|
||||
// update secondary
|
||||
updatedSecondaryTimer = _secondaryTarget - clock;
|
||||
updatedSecondaryTimer = secondaryTarget - clock;
|
||||
|
||||
if (updatedSecondaryTimer <= 0) {
|
||||
// we need a new event
|
||||
doRollLoad = true;
|
||||
}
|
||||
}
|
||||
|
||||
// if nothing is running, we need to find out if
|
||||
// a) we just finished an event (finished was set to true)
|
||||
// b) we need to look for events
|
||||
// this could be caused by a secondary timer or event finished
|
||||
const secondaryRunning = updatedSecondaryTimer <= 0 && updatedSecondaryTimer != null;
|
||||
|
||||
if (isFinished || secondaryRunning) {
|
||||
// look for events
|
||||
doRollLoad = true;
|
||||
}
|
||||
|
||||
return { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished };
|
||||
return { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished: isPrimaryFinished };
|
||||
};
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { RuntimeStore } from 'ontime-types';
|
||||
import { socket } from '../adapters/WebsocketAdapter.js';
|
||||
import { eventTimer } from '../services/TimerService.js';
|
||||
import { messageService } from '../services/message-service/MessageService.js';
|
||||
import { eventLoader } from '../classes/event-loader/EventLoader.js';
|
||||
|
||||
const store: Partial<RuntimeStore> = {};
|
||||
let store: Partial<RuntimeStore> = {};
|
||||
|
||||
/**
|
||||
* A runtime store that broadcasts its payload
|
||||
*/
|
||||
export const eventStore = {
|
||||
init(payload: RuntimeStore) {
|
||||
store = payload;
|
||||
},
|
||||
get<T extends keyof RuntimeStore>(key: T) {
|
||||
return store[key];
|
||||
},
|
||||
@@ -23,9 +29,35 @@ export const eventStore = {
|
||||
return store;
|
||||
},
|
||||
broadcast() {
|
||||
socket.send({
|
||||
socket.sendAsJson({
|
||||
type: 'ontime',
|
||||
payload: store,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Module initialises the services and provides initial payload for the store
|
||||
* Currently registered objects in store
|
||||
* - Timer Service timer
|
||||
* - Timer Service playback
|
||||
* - Message Service timerMessage
|
||||
* - Message Service publicMessage
|
||||
* - Message Service lowerMessage
|
||||
* - Message Service onAir
|
||||
* - Event Loader loaded
|
||||
* - Event Loader titles
|
||||
* - Event Loader titlesPublic
|
||||
*/
|
||||
|
||||
export const getInitialPayload = () => ({
|
||||
timer: eventTimer.timer,
|
||||
playback: eventTimer.playback,
|
||||
timerMessage: messageService.timerMessage,
|
||||
publicMessage: messageService.publicMessage,
|
||||
lowerMessage: messageService.lowerMessage,
|
||||
onAir: messageService.onAir,
|
||||
loaded: eventLoader.loaded,
|
||||
titles: eventLoader.titles,
|
||||
titlesPublic: eventLoader.titlesPublic,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import getRandomName from '../getRandomName.js';
|
||||
|
||||
test('generates 100 unique names', () => {
|
||||
test('generates unique names', () => {
|
||||
const names = new Set();
|
||||
let attempts = 1;
|
||||
while (names.size < 100) {
|
||||
while (names.size < 10) {
|
||||
names.add(getRandomName());
|
||||
attempts++;
|
||||
}
|
||||
expect(attempts).toBeLessThan(105);
|
||||
expect(attempts).toBeLessThan(50);
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ export const parseRundown = (data): OntimeRundown => {
|
||||
id: e.id || generateId(),
|
||||
});
|
||||
} else if (e.type === 'block') {
|
||||
rundown.push({ ...blockDef, id: e.id || generateId() });
|
||||
rundown.push({ ...blockDef, title: e.title, id: e.id || generateId() });
|
||||
} else {
|
||||
console.log('ERROR: undefined event type, skipping');
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "2.0.0-beta1",
|
||||
"version": "2.0.0-beta2",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"lighdev",
|
||||
|
||||
@@ -21,6 +21,7 @@ export type OntimeDelay = OntimeBaseEvent & {
|
||||
|
||||
export type OntimeBlock = OntimeBaseEvent & {
|
||||
type: SupportedEvent.Block;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type OntimeEvent = OntimeBaseEvent & {
|
||||
|
||||
Generated
+559
-595
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user