Compare commits

...

21 Commits

Author SHA1 Message Date
Carlos Valente 770d12888d V2 beta3 (#338)
* chore: upgrade local build documentation

* style: keep scrolling event in screen

* feat: delay is time entry

* refactor: remove unused

* refactor: remove unused

* refactor: batch store updates

* refactor: virtually remove cap on events

* refactor: style and behaviour tweaks to event block

* style: tweaks on schedules

* chore: remove sentry from server

* style: reorder menu

* chore: update docs
2023-04-14 10:13:46 +02:00
Fabian Posenau 94a1369d64 fix docker on different port (#337)
Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-04-14 10:08:42 +02:00
Fabian Posenau 5ba27dca1d add onClick handler (#335)
Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-04-10 20:00:09 +02:00
cv 3df17d172f fix: sentry hangs node process 2023-04-09 19:54:19 +02:00
Fabian Posenau d59c92219f Fix/events enpoint (#329) 2023-04-09 13:53:51 +02:00
cv 23fce763e5 style: ensure events are always visible 2023-04-06 23:39:08 +02:00
cv 524aa74a3d chore: upgrade deps 2023-04-06 20:56:01 +02:00
cv 32b7908d5b chore: upgrade deps 2023-04-06 20:50:56 +02:00
cv e36b19a511 style: small tweaks on blocks 2023-04-06 20:46:24 +02:00
cv ba62276951 refactor: improve performance on EventBlock 2023-04-06 14:42:34 +02:00
cv 4c4b83979d fix: close editor on event delete 2023-04-06 14:10:23 +02:00
cv 2dcea86b36 style: small style tweaks 2023-04-06 14:07:19 +02:00
cv e54996bd5e refactor: prevent iterating on undefined 2023-04-06 11:58:47 +02:00
cv 41753fc202 chore: upgrade deps 2023-04-06 11:47:30 +02:00
cv e77572c0b6 refactor: performance tweaks to event editor 2023-04-06 11:00:28 +02:00
cv 4c268d531e style: fix info scroll 2023-04-06 09:49:57 +02:00
Carlos Valente 5e481d49da V2 beta1 (#326)
* style: labels on added time

* style: remove mentions of PiP

* refactor: unify usage of ms for timers

* refactor: create events with 0 duration

* style: several small tweaks

* refactor: keep block when applying delays

* feat: blocks have titles

* style: improvements in time entry warnings

* style: override progress bar styles

* style: prevent overflow

* feat: show character count in editor

* refactor: provide initial payload

* refactor: lower test boundary

* refactor: get colour from swatches

* style: rename title block

* chore: version bump
2023-04-05 22:08:13 +02:00
Carlos Valente 0a68377b82 feat: notify ws clients on rundown changes (#327) 2023-04-05 21:42:18 +02:00
Carlos Valente cb39a13bea Add translations for views (#325) (#328)
* Add translations for views (#325)

---------

Co-authored-by: Kevin <60135953+kev-ac@users.noreply.github.com>
2023-04-05 21:41:50 +02:00
Carlos Valente e56460a940 V2 fix roll (#323)
* chore: profile node

* fix: always roll
2023-04-04 10:22:28 +02:00
Carlos Valente 390d466673 Update build_docker_v2.yml 2023-04-03 11:50:55 +02:00
125 changed files with 2348 additions and 1791 deletions
+2 -1
View File
@@ -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
+1 -1
View File
@@ -27,7 +27,7 @@ You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `turbo build`
- __Build the UI and server__ by running `turbo build:local`
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
The build distribution assets will be at `.apps/electron/dist`
+4 -9
View File
@@ -65,6 +65,7 @@ More documentation is available [in our docs](https://cpvalente.gitbook.io/ontim
- Backstage Info
- Public Info
- Studio Clock
- Countdown
- [Make your own?](#make-your-own-viewer)
- [x] Configurable Lower Thirds
- [x] Cuesheets with user definable fields
@@ -109,11 +110,11 @@ Taking advantage of the integrations, we currently use Ontime with:
### Make your own viewer
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside of the application.
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside the application.
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language that can run in the browser).
<br />
See [this repository](https://github.com/cpvalente/ontime-viewer-template) with a small template on
See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a small template on
how to get you started and read the docs about
the [Websocket API](https://app.gitbook.com/s/-Mc0giSOToAhq0ROd0CR/control-and-feedback/websocket-api)
@@ -132,13 +133,7 @@ in [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime
docker pull getontime/ontime
```
```bash
# Port 4001 - ontime server port
# Port 8888 - OSC input, bound to localhost IP Address
docker run -p 4001:4001 -p 127.0.0.1:8888:8888/udp --mount type=bind,source="$(pwd)/ontime-db",target=/server/preloaded-db getontime/ontime
```
or if running from the docker compose
and use the included docker compose to get started
```bash
docker-compose up
+10 -11
View File
@@ -1,26 +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",
"luxon": "^3.3.0",
"framer-motion": "^10.10.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-fast-compare": "^3.2.0",
@@ -58,7 +57,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",
+4 -1
View File
@@ -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} />
+13 -1
View File
@@ -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(() => {
-2
View File
@@ -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' },
+3 -3
View File
@@ -19,13 +19,13 @@ export const githubURL = 'https://api.github.com/repos/cpvalente/ontime/releases
* @description finds server path given the current location, it
* @return {*}
*/
export const calculateServer = () => (import.meta.env.DEV ? `http://localhost:${STATIC_PORT}` : window.location.origin);
export const calculateServer = () => (import.meta.env.DEV ? `http://localhost:${window.location.port}` : window.location.origin);
export const serverURL = calculateServer();
export const websocketUrl = `ws://${window.location.hostname}:${STATIC_PORT}/ws`;
export const websocketUrl = `ws://${window.location.hostname}:${window.location.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}`} onClick={() => onClick('')}>
<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>
);
}
@@ -1,13 +1,24 @@
@use '../../../../theme/v2Styles' as *;
$input-font-size: 15px;
.delayInput {
display: flex;
gap: $element-spacing;
align-items: center;
color: $ontime-delay-text;
font-size: $text-body-size;
.inputField {
font-size: $input-font-size;
letter-spacing: 1px;
max-width: 7em;
padding-left: 16px;
color: $ontime-delay-text
}
}
.inputField {
text-align: center;
}
.delayOptions {
display: flex;
flex-direction: column;
}
@@ -1,82 +1,134 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
import { millisToString } from 'ontime-utils';
import { clamp } from '../../../utils/math';
import { useEventAction } from '../../../hooks/useEventAction';
import { forgivingStringToMillis } from '../../../utils/dateConfig';
import style from './DelayInput.module.scss';
const inputStyleProps = {
width: 20,
placeholder: '-',
size: 'sm',
color: '#E69056',
variant: 'ontime-filled',
};
interface DelayInputProps {
submitHandler: (value: number) => void;
value?: number;
eventId: string;
duration: number;
}
export default function DelayInput(props: DelayInputProps) {
const { submitHandler, value = 0 } = props;
const [_value, setValue] = useState(value);
const { eventId, duration } = props;
const { updateEvent } = useEventAction();
const [value, setValue] = useState<string>('');
const inputRef = useRef<HTMLInputElement | null>(null);
// avoid wrong submit on cancel
let ignoreChange = false;
useEffect(() => {
if (value == null) return;
setValue(value);
}, [value]);
if (typeof duration === undefined) {
return;
}
setValue(millisToString(duration));
}, [duration]);
/**
* @description Prepare delay value for update
* @param {string} value string to be parsed
* @param {string} newValue string to be parsed
*/
const validate = useCallback(
(newValue?: string) => {
if (newValue === '') setValue(0);
const delayValue = clamp(Number(newValue), -60, 60);
const validateAndSubmit = (newValue: string) => {
if (ignoreChange) {
ignoreChange = false;
return;
}
if (delayValue === value) return;
setValue(delayValue);
const isNegative = newValue.startsWith('-');
let newMillis = forgivingStringToMillis(newValue);
submitHandler(delayValue);
},
[submitHandler, value],
);
if (isNegative) {
newMillis = newMillis * -1;
}
if (newMillis === duration) {
return;
}
submitChange(newMillis);
setValue(millisToString(newMillis));
};
const submitChange = (value: number) => {
updateEvent({
id: eventId,
duration: value,
});
};
/**
* @description Selects input text on focus
*/
const handleFocus = () => inputRef.current?.select();
/**
* @description Handles common keys for submit and cancel
* @param {KeyboardEvent} event
*/
const onKeyDownHandler = useCallback((key: string) => {
if (key === 'Enter') {
const onKeyDownHandler = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
inputRef.current?.blur();
validate(inputRef.current?.value);
} else if (key === 'Escape') {
validateAndSubmit((event.target as HTMLInputElement).value);
} else if (event.key === 'Tab') {
validateAndSubmit((event.target as HTMLInputElement).value);
} else if (event.key === 'Escape') {
ignoreChange = true;
setValue(millisToString(duration));
inputRef.current?.blur();
setValue(value);
}
}, [validate, value]);
};
const labelText = `${Math.abs(value) !== 1 ? 'minutes' : 'minute'} ${
value !== undefined && value >= 0 ? 'delayed' : 'ahead'
}`;
/**
* @description handles direction change to delay
* @param newDirection
*/
const handleSlipChange = (newDirection: 'add' | 'subtract') => {
if (newDirection === 'add') {
// add time
if (duration < 0) {
submitChange(duration * -1);
}
} else if (newDirection === 'subtract') {
// subtract time
if (duration > 0) {
submitChange(duration * -1);
}
}
};
const checkedOption = value.startsWith('-') ? 'subtract' : 'add';
return (
<label className={style.delayInput}>
<div className={style.delayInput}>
<Input
{...inputStyleProps}
size='sm'
ref={inputRef}
data-testid='delay-input'
className={style.inputField}
value={_value}
onChange={(event) => setValue(Number(event.target.value))}
onBlur={(event) => validate(event.target.value)}
onKeyDown={(event) => onKeyDownHandler(event.key)}
type='number'
type='text'
placeholder='-'
variant='ontime-filled'
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => validateAndSubmit(event.target.value)}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={9}
/>
{labelText}
</label>
<RadioGroup
className={style.delayOptions}
onChange={handleSlipChange}
value={checkedOption}
variant='ontime-block'
size='sm'
>
<Radio value='add'>Add time</Radio>
<Radio value='subtract'>Subtract time</Radio>
</RadioGroup>
</div>
);
}
@@ -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,41 +2,45 @@ 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('');
const [value, setValue] = useState<string>('');
// avoid wrong submit on cancel
let ignoreChange = false;
/**
* @description Resets input value to given
*/
const resetValue = useCallback(() => {
// Todo: check if change is necessary
try {
setValue(millisToString(time + delay));
// eslint-disable-next-line -- we use ignore change to stop submit on cancel
ignoreChange = true;
setValue(millisToString(time));
} catch (error) {
emitError(`Unable to parse date: ${error}`);
}
}, [delay, emitError, time]);
}, [emitError, time]);
/**
* @description Selects input text on focus
@@ -73,11 +77,8 @@ export default function TimeInput(props: TimeInputProps) {
newValMillis = forgivingStringToMillis(newValue);
}
// Time now and time submittedVal
const originalMillis = time + delay;
// check if time is different from before
if (newValMillis === originalMillis) return false;
if (newValMillis === time) return false;
// validate with parent
if (!validationHandler(name, newValMillis)) return false;
@@ -87,7 +88,7 @@ export default function TimeInput(props: TimeInputProps) {
return true;
},
[delay, name, previousEnd, submitHandler, time, validationHandler],
[name, previousEnd, submitHandler, time, validationHandler],
);
/**
@@ -96,10 +97,16 @@ export default function TimeInput(props: TimeInputProps) {
*/
const validateAndSubmit = useCallback(
(newValue: string) => {
if (ignoreChange) {
// eslint-disable-next-line -- we use this to prevent a wrong submit
ignoreChange = false;
return;
}
const success = handleSubmit(newValue);
if (success) {
const ms = forgivingStringToMillis(newValue);
setValue(millisToString(ms + delay));
const delayed = name === 'timeEnd' ? Math.max(0, ms + delay) : Math.max(0, ms + delay);
setValue(millisToString(delayed));
} else {
resetValue();
}
@@ -139,8 +146,6 @@ export default function TimeInput(props: TimeInputProps) {
resetValue();
}, [emitError, resetValue, time]);
const isDelayed = delay != null && delay !== 0;
const ButtonInitial = () => {
if (name === 'timeStart') return 'S';
if (name === 'timeEnd') return 'E';
@@ -149,20 +154,24 @@ 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 isDelayed = delay !== 0;
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>
);
}
@@ -32,7 +32,7 @@
}
&:not(:last-child) {
padding-bottom: clamp(16px, 1.5vw, 24px);
padding-bottom: 8px;
}
&--past {
@@ -6,7 +6,7 @@ import { useInterval } from '../../hooks/useInterval';
interface ScheduleContextState {
events: OntimeEvent[];
paginatedEvents: OntimeEvent[];
selectedEventId: string;
selectedEventId: string | null;
numPages: number;
visiblePage: number;
isBackstage: boolean;
@@ -16,22 +16,20 @@ const ScheduleContext = createContext<ScheduleContextState | undefined>(undefine
interface ScheduleProviderProps {
events: OntimeEvent[];
selectedEventId: string;
selectedEventId: string | null;
isBackstage?: boolean;
eventsPerPage?: number;
time?: number;
}
export const ScheduleProvider = (
{
children,
events,
selectedEventId,
isBackstage = false,
eventsPerPage = 4,
time = 10,
}: PropsWithChildren<ScheduleProviderProps>) => {
export const ScheduleProvider = ({
children,
events,
selectedEventId,
isBackstage = false,
eventsPerPage = 8,
time = 10,
}: PropsWithChildren<ScheduleProviderProps>) => {
const [visiblePage, setVisiblePage] = useState(0);
const numPages = Math.ceil(events.length / eventsPerPage);
@@ -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;
}
}
@@ -90,6 +90,7 @@ export const useCuesheet = () => {
export const setEventPlayback = {
loadEvent: (eventId: string) => socketSendJson('loadid', eventId),
startEvent: (eventId: string) => socketSendJson('startid', eventId),
start: () => socketSendJson('start'),
pause: () => socketSendJson('pause'),
};
@@ -12,4 +12,5 @@ export type OverridableOptions = {
hideOvertime?: boolean;
hideMessagesOverlay?: boolean;
hideEndMessage?: boolean;
language?: string;
};
@@ -2,9 +2,9 @@ import {
forgivingStringToMillis,
formatDisplay,
isTimeString,
millisToDelayString,
millisToMinutes,
millisToSeconds,
timeStringToMillis,
} from '../dateConfig';
describe('test string from formatDisplay function', () => {
@@ -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);
});
});
@@ -170,88 +170,6 @@ describe('test millisToMinutes function', () => {
});
});
describe('test timeStringToMillis function', () => {
it('test with null', () => {
const t = { val: null, result: 0 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 00:00:00', () => {
const t = { val: '00:00:00', result: 0 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with -00:00:00', () => {
const t = { val: '-00:00:00', result: 0 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 00:00:01', () => {
const t = { val: '00:00:01', result: 1000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with -00:00:01', () => {
const t = { val: '-00:00:01', result: 1000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 01:00:01', () => {
const t = { val: '01:00:01', result: 3601000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 24:00:01', () => {
const t = { val: '24:00:01', result: 86401000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 00:00:5', () => {
const t = { val: '00:00:5', result: 5000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 00:1:00', () => {
const t = { val: '00:1:00', result: 60000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 1:00:00', () => {
const t = { val: '1:00:00', result: 3600000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 1', () => {
const t = { val: '1', result: 1000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 120', () => {
const t = { val: '120', result: 120000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 56', () => {
const t = { val: '56', result: 56000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 2:3', () => {
const t = { val: '2:3', result: 123000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 02:3', () => {
const t = { val: '02:3', result: 123000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 2:03', () => {
const t = { val: '2:03', result: 123000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
});
describe('test isTimeString() function', () => {
it('it validates time strings', () => {
const ts = ['2', '2:10', '2:10:22'];
@@ -450,3 +368,59 @@ describe('test forgivingStringToMillis()', () => {
});
});
});
describe('millisToDelayString()', () => {
it('returns null for null values', () => {
expect(millisToDelayString(null)).toBeNull();
});
it('returns null 0', () => {
expect(millisToDelayString(0)).toBeNull();
});
describe('converts values in seconds', () => {
it(`shows a simple string with value in seconds`, () => {
expect(millisToDelayString(10000)).toBe('+10sec');
});
it(`... and its negative counterpart`, () => {
expect(millisToDelayString(-10000)).toBe('-10sec');
});
const underAMinute = [1, 500, 1000, 6000, 55000, 59999];
underAMinute.forEach((value) => {
it(`handles ${value}`, () => {
expect(millisToDelayString(value)?.endsWith('sec')).toBe(true);
});
});
expect(millisToDelayString(null)).toBeNull();
});
describe('converts values in minutes', () => {
it(`shows a simple string with value in minutes`, () => {
expect(millisToDelayString(720000)).toBe('+12min');
});
it(`... and its negative counterpart`, () => {
expect(millisToDelayString(-720000)).toBe('-12min');
});
it(`shows a simple string with value in minutes and seconds`, () => {
expect(millisToDelayString(630000)).toBe('+00:10:30');
});
it(`... and its negative counterpart`, () => {
expect(millisToDelayString(-630000)).toBe('-00:10:30');
});
const underAnHour = [60000, 360000, 720000];
underAnHour.forEach((value) => {
it(`handles ${value}`, () => {
expect(millisToDelayString(value)?.endsWith('min')).toBe(true);
});
});
});
describe('converts values with full time string', () => {
it(`positive added time`, () => {
expect(millisToDelayString(45015000)).toBe('+12:30:15');
});
it(`negative added time`, () => {
expect(millisToDelayString(-45015000)).toBe('-12:30:15');
});
});
});
+23 -18
View File
@@ -1,3 +1,5 @@
import { formatFromMillis } from 'ontime-utils';
import { mth, mtm, mts } from './timeConstants';
export const timeFormat = 'HH:mm';
@@ -6,19 +8,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);
@@ -47,20 +49,6 @@ export const millisToMinutes = (millis: number): number => {
return millis < 0 ? Math.ceil(millis / mtm) : Math.floor(millis / mtm);
};
/**
* @description Converts timestring to milliseconds
* @param {string} string - time string "23:00:12"
* @returns {number} Amount in milliseconds
*/
export const timeStringToMillis = (string: string): number => {
if (typeof string !== 'string') return 0;
const time = string.split(':');
if (time.length === 1) return Math.abs(time[0] * mts);
if (time.length === 2) return Math.abs(time[0]) * mtm + time[1] * mts;
if (time.length === 3) return Math.abs(time[0]) * mth + time[1] * mtm + time[2] * mts;
return 0;
};
/**
* @description Validates a time string
* @param {string} string - time string "23:00:12"
@@ -150,3 +138,20 @@ export const forgivingStringToMillis = (value: string, fillLeft = true): number
return millis;
};
export function millisToDelayString(millis: number | null): undefined | string | null {
if (millis == null || millis === 0) {
return null;
}
const isNegative = millis < 0;
const absMillis = Math.abs(millis);
if (absMillis < mtm) {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 's')}sec`;
} else if (absMillis < mth && absMillis % mtm === 0) {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 'm')}min`;
} else {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 'HH:mm:ss')}`;
}
}
+2 -2
View File
@@ -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);
}
+2 -6
View File
@@ -1,6 +1,5 @@
import { DateTime } from 'luxon';
import { Settings } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { formatFromMillis, millisToString } from 'ontime-utils';
import { APP_SETTINGS } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient';
@@ -39,7 +38,6 @@ type FormatOptions = {
};
/**
/**
* @description utility function to format a date in 12 or 24 hour format
* @param {number | null} milliseconds
* @param {object} [options]
@@ -54,7 +52,5 @@ export const formatTime = (milliseconds: number | null, options: FormatOptions,
}
const timeFormat = resolver();
const { showSeconds = false, format: formatString = 'hh:mm a' } = options || {};
return timeFormat === '12'
? DateTime.fromMillis(milliseconds).toUTC().toFormat(formatString)
: millisToString(milliseconds, showSeconds);
return timeFormat === '12' ? formatFromMillis(milliseconds, formatString) : millisToString(milliseconds, showSeconds);
};
+15 -5
View File
@@ -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}>
@@ -145,7 +145,7 @@ $playback-width: 450px;
border-radius: 8px 8px 0 0;
background-color: $bg-container-l2;
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
border-top: 1px solid $white-10;
border-top: 1px solid $white-20;
position: absolute;
bottom: 0;
width: 100vw;
@@ -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,12 +64,22 @@
}
}
.inputLabel {
@mixin input-label() {
font-size: 13px;
display: block;
color: $label-gray;
}
.delayLabel {
.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,149 @@
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 { millisToDelayString } from '../../../common/utils/dateConfig';
import { cx } from '../../../common/utils/styleUtils';
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 delayTime = delay !== 0 ? millisToDelayString(delay) : null;
const startLabel = delayTime ? `New start ${millisToString(timeStart + delay)}` : 'Start time';
const endLabel = delayTime ? `New end ${millisToString(timeEnd + delay)}` : 'End time';
const inputTimeLabels = cx([style.inputLabel, delayTime ? style.delayLabel : null]);
return (
<div className={style.timeOptions}>
<div className={style.timers}>
<label className={inputTimeLabels}>{startLabel}</label>
<TimeInput
name='timeStart'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={timeStart}
delay={delay}
placeholder='Start'
warning={warning.start}
/>
<label className={inputTimeLabels}>{endLabel}</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;
+2 -2
View File
@@ -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>
</>
);
}
+1 -1
View File
@@ -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} />
+26 -26
View File
@@ -8,8 +8,8 @@ import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle'
import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline';
import { IoScan } from '@react-icons/all-files/io5/IoScan';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { downloadRundown } from '../../common/api/ontimeApi';
import { downloadRundown } from '../../common/api/ontimeApi';
import QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
import useElectronEvent from '../../common/hooks/useElectronEvent';
@@ -130,31 +130,6 @@ export default function MenuBar(props: MenuBarProps) {
isDisabled={!isElectron}
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={<FiHelpCircle />}
clickHandler={() => actionHandler('help')}
tooltip='Help'
aria-label='Help'
/>
<TooltipActionBtn
{...buttonStyle}
icon={<IoSettingsOutline />}
className={isSettingsOpen ? style.open : ''}
clickHandler={onSettingsOpen}
tooltip='Settings'
aria-label='Settings'
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={isIntegrationOpen ? <IoExtensionPuzzle /> : <IoExtensionPuzzleOutline />}
className={isIntegrationOpen ? style.open : ''}
clickHandler={onIntegrationOpen}
tooltip='Integrations'
aria-label='Integrations'
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={<FiUpload />}
@@ -170,6 +145,31 @@ export default function MenuBar(props: MenuBarProps) {
tooltip='Export showfile'
aria-label='Export showfile'
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={isIntegrationOpen ? <IoExtensionPuzzle /> : <IoExtensionPuzzleOutline />}
className={isIntegrationOpen ? style.open : ''}
clickHandler={onIntegrationOpen}
tooltip='Integrations'
aria-label='Integrations'
/>
<TooltipActionBtn
{...buttonStyle}
icon={<IoSettingsOutline />}
className={isSettingsOpen ? style.open : ''}
clickHandler={onSettingsOpen}
tooltip='Settings'
aria-label='Settings'
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={<FiHelpCircle />}
clickHandler={() => actionHandler('help')}
tooltip='Help'
aria-label='Help'
/>
</VStack>
);
}
@@ -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;
}
+4 -2
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { OntimeRundown, SupportedEvent } from 'ontime-types';
import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useRundownEditor } from '../../common/hooks/useSocket';
@@ -194,7 +194,7 @@ export default function Rundown(props: RundownProps) {
}
};
if (!entries.length) {
if (statefulEntries?.length < 1) {
return <RundownEmpty handleAddNew={() => insertAtCursor(SupportedEvent.Event, -1)} />;
}
@@ -242,6 +242,7 @@ export default function Rundown(props: RundownProps) {
previousEnd={previousEnd}
previousEventId={previousEventId}
playback={isSelected ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
/>
{((showQuickEntry && index === cursor) || isLast) && (
<QuickAddBlock
@@ -255,6 +256,7 @@ export default function Rundown(props: RundownProps) {
</div>
);
})}
<div className={style.spacer} />
</div>
</SortableContext>
</DndContext>
@@ -26,10 +26,23 @@ interface RundownEntryProps {
previousEnd: number;
previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing
isRolling: boolean; // we need to know even if not related to this event
}
export default function RundownEntry(props: RundownEntryProps) {
const { index, eventIndex, data, selected, hasCursor, next, delay, previousEnd, previousEventId, playback } = props;
const {
index,
eventIndex,
data,
selected,
hasCursor,
next,
delay,
previousEnd,
previousEventId,
playback,
isRolling,
} = props;
const { emitError } = useEmitLog();
const { addEvent, updateEvent, deleteEvent } = useEventAction();
@@ -149,6 +162,7 @@ export default function RundownEntry(props: RundownEntryProps) {
selected={selected}
hasCursor={hasCursor}
playback={playback}
isRolling={isRolling}
actionHandler={actionHandler}
/>
);
@@ -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;
@@ -23,7 +23,7 @@ $block-cursor-color: $blue-400;
}
@mixin block-spacing() {
padding: 4px 8px 4px 2px;
padding-right: 8px;
gap: 2px;
}
@@ -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';
@@ -1,14 +1,14 @@
import { useCallback, useEffect, useRef } from 'react';
import { useEffect, useRef } from 'react';
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';
import DelayInput from '../../../common/components/input/delay-input/DelayInput';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { millisToMinutes } from '../../../common/utils/dateConfig';
import { cx } from '../../../common/utils/styleUtils';
import BlockActionMenu from '../event-block/composite/BlockActionMenu';
import { EventItemActions } from '../RundownEntry';
@@ -31,7 +31,7 @@ interface DelayBlockProps {
export default function DelayBlock(props: DelayBlockProps) {
const { data, hasCursor, actionHandler } = props;
const { applyDelay, updateEvent } = useEventAction();
const { applyDelay, deleteEvent } = useEventAction();
const handleRef = useRef<null | HTMLSpanElement>(null);
const {
@@ -56,35 +56,28 @@ export default function DelayBlock(props: DelayBlockProps) {
}
}, [hasCursor]);
const applyDelayHandler = useCallback(() => {
const applyDelayHandler = () => {
applyDelay(data.id);
}, [data.id, applyDelay]);
};
const delaySubmitHandler = useCallback(
(value: number) => {
const newEvent = {
id: data.id,
duration: value * 60000,
};
updateEvent(newEvent);
},
[data.id, updateEvent],
);
const cancelDelayHandler = () => {
deleteEvent(data.id);
};
const blockClasses = cx([style.delay, hasCursor ? style.hasCursor : null]);
const delayValue = data.duration != null ? millisToMinutes(data.duration) : undefined;
return (
<div className={blockClasses} ref={setNodeRef} style={dragStyle}>
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
<IoReorderTwo />
</span>
<DelayInput value={delayValue} submitHandler={delaySubmitHandler} />
<DelayInput eventId={data.id} duration={data.duration} />
<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:
@@ -19,17 +22,36 @@
padding-right: $block-clearance;
gap: 2px;
@mixin declare-overrides(){
--status-color-override: #{$gray-200};
--status-color-active-override: #{$green-400};
}
&.selected {
background-color: $gray-1350;
}
&.play {
background-color: $green-700;
@include declare-overrides;
}
&.roll {
background-color: $blue-700;
@include declare-overrides;
}
&.pause {
background-color: $orange-700;
@include declare-overrides;
}
&.hasCursor {
outline: 1px solid $block-cursor-color;
}
&.skip {
border: 1px solid $white-3;
box-shadow: none;
.delayNote,
.eventTitle,
@@ -37,7 +59,7 @@
.binder,
.eventTimers,
.eventStatus {
opacity: $opacity-disabled;
opacity: $skip-opacity;
}
}
}
@@ -87,18 +109,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 {
@@ -156,8 +166,10 @@
justify-content: flex-end;
align-items: center;
gap: 8px;
color: var(--status-color-override, $gray-500);
.tag {
padding-top: 1px;
font-size: 0.55em;
color: $active-indicator;
}
@@ -165,12 +177,12 @@
.statusIcon {
width: 16px;
height: 16px;
color: $gray-500;
}
.statusIcon.active {
color: $active-indicator;
color: var(--status-color-active-override, $active-indicator);
}
.statusIcon.disabled {
color: $gray-1000;
}
@@ -33,6 +33,7 @@ interface EventBlockProps {
selected: boolean;
hasCursor: boolean;
playback?: Playback;
isRolling: boolean;
actionHandler: (
action: EventItemActions,
payload?:
@@ -65,6 +66,7 @@ export default function EventBlock(props: EventBlockProps) {
selected,
hasCursor,
playback,
isRolling,
actionHandler,
} = props;
@@ -128,6 +130,7 @@ export default function EventBlock(props: EventBlockProps) {
style.eventBlock,
skip ? style.skip : null,
selected ? style.selected : null,
playback ? style[playback] : null,
hasCursor ? style.hasCursor : null,
]);
@@ -157,6 +160,7 @@ export default function EventBlock(props: EventBlockProps) {
skip={skip}
selected={selected}
playback={playback}
isRolling={isRolling}
actionHandler={actionHandler}
/>
)}
@@ -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 { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
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';
@@ -54,6 +49,7 @@ interface EventBlockInnerProps {
skip: boolean;
selected: boolean;
playback?: Playback;
isRolling: boolean;
actionHandler: (action: EventItemActions, payload?: any) => void;
}
@@ -75,41 +71,18 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
skip = false,
selected,
playback,
isRolling,
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();
@@ -118,72 +91,35 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
}
}, [eventId, isOpen, removeOpenEvent, setOpenEvent]);
const eventIsPlaying = selected && playback === Playback.Play;
const eventIsPlaying = playback === Playback.Play;
const eventIsPaused = playback === Playback.Pause;
const playBtnStyles = { _hover: {} };
if (!skip && eventIsPlaying) {
playBtnStyles._hover = { bg: '#c05621' };
playBtnStyles._hover = { bg: '#c05621' }; // $ontime-paused
} else if (!skip && !eventIsPlaying) {
playBtnStyles._hover = {};
}
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>
<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} />
<EventBlockPlayback
eventId={eventId}
skip={skip}
isPlaying={eventIsPlaying}
isPaused={eventIsPaused}
selected={selected}
disablePlayback={skip || isRolling}
/>
<div className={style.statusElements}>
<span className={style.eventNote}>{note}</span>
<div className={selected ? style.progressBg : `${style.progressBg} ${style.hidden}`}>
@@ -195,16 +131,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 +172,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 <IoArrowUp className={className} />;
}
if (type === TimerType.Clock) {
return <IoTime className={className} />;
}
return <IoCaretDownCircle className={className} />;
return <IoArrowDown 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,125 @@
import { memo } from 'react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
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',
};
type StyleVariant = {
'aria-label': string;
tooltip: string;
backgroundColor: string;
_hover: { backgroundColor?: string };
};
const tooltipProps = {
openDelay: tooltipDelayMid,
};
interface EventBlockPlaybackProps {
eventId: string;
skip: boolean;
isPlaying: boolean;
isPaused: boolean;
selected: boolean;
disablePlayback: boolean;
}
const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
const { eventId, skip, isPlaying, isPaused, selected, disablePlayback } = props;
const { updateEvent } = useEventAction();
const toggleSkip = () => {
updateEvent({ id: eventId, skip: !skip });
};
const actionHandler = () => {
// is playing -> pause
// is paused -> continue
// otherwise -> start
if (isPlaying) {
setEventPlayback.pause();
} else if (isPaused) {
setEventPlayback.start();
} else {
setEventPlayback.startEvent(eventId);
}
};
const buttonVariant: Partial<StyleVariant> = {};
if (isPaused) {
// continue
buttonVariant['aria-label'] = 'Continue event';
buttonVariant.tooltip = 'Continue event';
buttonVariant.backgroundColor = '#339E4E';
buttonVariant._hover = { backgroundColor: '#339E4Eee' };
} else if (isPlaying) {
// pause
buttonVariant['aria-label'] = 'Pause event';
buttonVariant.tooltip = 'Pause event';
buttonVariant.backgroundColor = '#c05621';
buttonVariant._hover = { backgroundColor: '#c05621ee' };
} else {
// start
buttonVariant['aria-label'] = 'Start event';
buttonVariant.tooltip = 'Start event';
if (!disablePlayback) {
buttonVariant._hover = { backgroundColor: '#339E4E' };
}
}
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={disablePlayback}
{...tooltipProps}
{...blockBtnStyle}
clickHandler={() => setEventPlayback.loadEvent(eventId)}
tabIndex={-1}
/>
<TooltipActionBtn
variant='ontime-subtle-white'
aria-label='Start event'
tooltip='Start event'
icon={!isPlaying ? <IoPlay /> : <IoPause />}
isDisabled={disablePlayback}
{...tooltipProps}
{...blockBtnStyle}
{...buttonVariant}
clickHandler={actionHandler}
tabIndex={-1}
/>
</div>
);
};
export default memo(EventBlockPlayback);
@@ -1,12 +1,10 @@
@use '../../../../theme/v2Styles' as *;
.progressBar {
// layout
height: 100%;
width: 0;
border-radius: 1px 0 0 1px;
// animations
transition: 1s linear;
transition-property: width;
@@ -14,6 +12,10 @@
background-color: $playback-start;
}
&.overtime {
background-color: $playback-negative;
}
&.pause {
background-color: $ontime-paused;
}
@@ -21,8 +23,4 @@
&.roll {
background-color: $ontime-roll;
}
&.overtime {
background-color: $playback-negative;
}
}
@@ -1,90 +0,0 @@
import { useCallback } from 'react';
import { millisToString } from 'ontime-utils';
import PropTypes from 'prop-types';
import { useEmitLog } from '@/common/stores/logger';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { millisToMinutes } from '../../../../common/utils/dateConfig';
import { validateEntry } from '../../../../common/utils/timesManager';
import style from '../EventBlock.module.scss';
export default function EventBlockTimers(props) {
const { timeStart, timeEnd, duration, delay, actionHandler, previousEnd } = props;
const { emitWarning } = useEmitLog();
const delayTime = `${delay >= 0 ? '+' : '-'} ${millisToMinutes(Math.abs(delay))}`;
const newTime = millisToString(timeStart + delay);
/**
* @description Validates a time input against its pair
* @param {string} entry - field to validate: timeStart, timeEnd, durationOverride
* @param {number} val - field value
* @return {boolean}
*/
const handleValidation = useCallback(
(field, value) => {
const valid = validateEntry(field, value, timeStart, timeEnd);
if (valid.catch) {
emitWarning(`Time Input Warning: ${valid.catch}`);
}
return valid.value;
},
[emitWarning, timeEnd, timeStart]
);
const handleSubmit = useCallback(
(field, value) => {
actionHandler('update', { field, value });
},
[actionHandler]
);
return (
<div className={style.eventTimers}>
<TimeInput
name='timeStart'
submitHandler={handleSubmit}
validationHandler={handleValidation}
time={timeStart}
delay={delay}
placeholder='Start'
previousEnd={previousEnd}
/>
<TimeInput
name='timeEnd'
submitHandler={handleSubmit}
validationHandler={handleValidation}
time={timeEnd}
delay={delay}
placeholder='End'
previousEnd={previousEnd}
/>
<TimeInput
name='durationOverride'
submitHandler={handleSubmit}
validationHandler={handleValidation}
time={duration}
placeholder='Duration'
previousEnd={previousEnd}
/>
{delay !== 0 && delay !== null && (
<div className={style.delayNote}>
{`${delayTime} minutes`}
<br />
{`New start: ${newTime}`}
</div>
)}
</div>
);
}
EventBlockTimers.propTypes = {
timeStart: PropTypes.number,
timeEnd: PropTypes.number,
duration: PropTypes.number,
delay: PropTypes.number,
actionHandler: PropTypes.func,
previousEnd: PropTypes.number,
};
@@ -0,0 +1,114 @@
import { memo, useCallback, useState } from 'react';
import { OntimeEvent } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { calculateDuration, TimeEntryField, validateEntry } from '../../../../common/utils/timesManager';
import style from '../EventBlock.module.scss';
interface EventBlockTimerProps {
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
delay: number;
previousEnd: number;
}
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
* @param {string} entry - field to validate: timeStart, timeEnd, durationOverride
* @param {number} val - field value
* @return {boolean}
*/
const handleValidation = useCallback(
(field: TimeEntryField, value: number) => {
const valid = validateEntry(field, value, timeStart, timeEnd);
setWarnings((prev) => ({ ...prev, ...valid.warnings }));
return valid.value;
},
[timeEnd, timeStart],
);
const delayedStart = Math.max(0, timeStart + delay);
const newTime = millisToString(delayedStart);
const delayTime = delay !== 0 ? millisToDelayString(delay) : null;
return (
<div className={style.eventTimers}>
<TimeInput
name='timeStart'
submitHandler={handleSubmit}
validationHandler={handleValidation}
time={timeStart}
delay={delay}
placeholder='Start'
previousEnd={previousEnd}
warning={warning.start}
/>
<TimeInput
name='timeEnd'
submitHandler={handleSubmit}
validationHandler={handleValidation}
time={timeEnd}
delay={delay}
placeholder='End'
previousEnd={previousEnd}
warning={warning.end}
/>
<TimeInput
name='durationOverride'
submitHandler={handleSubmit}
validationHandler={handleValidation}
time={duration}
delay={0}
placeholder='Duration'
previousEnd={previousEnd}
warning={warning.duration}
/>
{delayTime && (
<div className={style.delayNote}>
{delayTime}
<br />
{`New start: ${newTime}`}
</div>
)}
</div>
);
};
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);
@@ -10,11 +10,7 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import PropTypes from 'prop-types';
import { TableSettingsContext } from '../../common/context/TableSettingsContext';
@@ -148,7 +144,7 @@ export default function OntimeTable({ tableData, userFields, selectedId, handleU
if (el) {
el.scrollIntoView({
behavior: 'smooth',
block: 'start',
block: 'center',
inline: 'nearest',
});
}
@@ -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',
@@ -119,7 +119,7 @@
grid-area: schedule;
overflow: hidden;
height: 100%;
margin-left: 16px;
margin-left: clamp(16px, 5vw, 64px);;
}
.schedule-nav-container {
@@ -131,6 +131,8 @@
grid-area: info;
display: flex;
gap: max(1vw, 16px);
align-self: flex-end;
overflow: hidden;
&__message {
font-size: clamp(16px, 1.5vw, 24px);
@@ -141,7 +143,7 @@
}
.qr {
margin-left: auto;
margin-left: clamp(16px, 5vw, 64px);;
padding: 4px;
background-color: white;
}
@@ -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;
@@ -99,7 +99,8 @@
grid-area: info;
display: flex;
gap: max(1vw, 16px);
min-height: max(calc(100vh / 15), 128px);
align-self: flex-end;
overflow: hidden;
&__message {
font-size: clamp(16px, 1.5vw, 24px);
@@ -110,7 +111,7 @@
}
.qr {
margin-left: auto;
margin-left: clamp(16px, 5vw, 64px);;
padding: 4px;
background-color: white;
}
@@ -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}
/>
@@ -6,6 +6,7 @@ $white-7: rgba(255, 255, 255, 0.07);
$white-9: rgba(255, 255, 255, 0.09);
$white-10: rgba(255, 255, 255, 0.10);
$white-13: rgba(255, 255, 255, 0.13);
$white-20: rgba(255, 255, 255, 0.20);
$black-10: rgba(0, 0, 0, 0.10);
+12
View File
@@ -21,6 +21,9 @@ export const ontimeButtonOutlined = {
border: '1px solid rgba(255, 255, 255, 0.10)', // white-10
_hover: {
backgroundColor: '#404040', // $gray-1000
_disabled: {
backgroundColor: '#2d2d2d', // $gray-1100
},
},
_active: {
backgroundColor: '#2d2d2d', // $gray-1100
@@ -34,6 +37,9 @@ export const ontimeButtonSubtle = {
border: '1px solid transparent',
_hover: {
background: '#404040', // $gray-1000
_disabled: {
backgroundColor: '#303030', // $gray-1050
},
},
_active: {
backgroundColor: '#2d2d2d', // $gray-1100
@@ -47,6 +53,9 @@ export const ontimeButtonSubtleOnLight = {
border: '1px solid transparent',
_hover: {
backgroundColor: '#cfcfcf', // $gray-200
_disabled: {
backgroundColor: '#ececec', // $gray-100
},
},
_active: {
backgroundColor: '#ececec', // $gray-200
@@ -60,6 +69,9 @@ export const ontimeGhostOnLight = {
_hover: {
color: '#595959', // $gray-800
backgroundColor: '#ececec', // $gray-200
_disabled: {
backgroundColor: 'transparent',
},
},
_active: {
backgroundColor: 'transparent',
+5 -5
View File
@@ -1,21 +1,21 @@
export const ontimeCheckboxOnDark = {
control: {
border: '1px',
borderColor: '#2d2d2d', // $gray-1100
backgroundColor: '#2d2d2d', // $gray-1100
borderColor: '#2d2d2d', // $gray-1100
backgroundColor: '#2d2d2d', // $gray-1100
_checked: {
borderColor: '#3182ce', // $action-blue
backgroundColor: '#3182ce', //$action-blue
},
_focus: {
boxShadow: '0 0 0 1px #578AF4'
}
boxShadow: '0 0 0 1px #578AF4', // $blue-500
},
},
label: {
fontWeight: '200',
color: '#9d9d9d', // $gray-500
_checked: {
color: '#cfcfcf', // $gray-300
}
},
},
};
+27
View File
@@ -0,0 +1,27 @@
export const ontimeBlockRadio = {
control: {
borderColor: '#262626', // $gray-1250
backgroundColor: '#262626', // $gray-1250
_checked: {
borderColor: '#262626', // $gray-1250
color: '#3182ce', // $action-blue
backgroundColor: '#3182ce', // $action-blue
},
_hover: {
color: '#3182ce', // $action-blue
backgroundColor: '#3182ce', // $action-blue
outline: 'none',
},
},
label: {
fontSize: '0.7em',
letterSpacing: '0.3px',
color: '#9d9d9d', // $gray-500
_checked: {
color: '#cfcfcf', // $gray-300
},
_hover: {
color: '#e2e2e2', // $gray-200
},
},
};
+6
View File
@@ -12,6 +12,7 @@ import { ontimeCheckboxOnDark } from './ontimeCheckbox';
import { ontimeEditable } from './ontimeEditable';
import { ontimeMenuOnDark } from './ontimeMenu';
import { ontimeModal } from './ontimeModal';
import { ontimeBlockRadio } from './ontimeRadio';
import { ontimeSelect } from './ontimeSelect';
import { lightSwitch, ontimeSwitch } from './ontimeSwitch';
import { ontimeTab } from './ontimeTab';
@@ -65,6 +66,11 @@ const theme = extendTheme({
ontime: { ...ontimeModal },
},
},
Radio: {
variants: {
'ontime-block': { ...ontimeBlockRadio },
},
},
Tabs: {
variants: {
ontime: { ...ontimeTab },
@@ -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 -1
View File
@@ -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';
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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 -3
View File
@@ -2,11 +2,9 @@
"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",
"body-parser": "^1.20.0",
"cors": "^2.8.5",
"dotenv": "^16.0.1",
@@ -48,6 +46,7 @@
"dev:test": "cross-env IS_TEST=true nodemon --exec \"ts-node-esm\" ./src/index.ts",
"prebuild": "pnpm setdb",
"build": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --outfile=dist/index.cjs",
"build:local": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs",
"build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs",
"build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --outfile=dist/index.cjs",
"lint": "eslint .",
+1 -1
View File
@@ -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));
+12
View File
@@ -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,
});
}
+7 -8
View File
@@ -6,7 +6,6 @@ import cors from 'cors';
// import utils
import { join, resolve } from 'path';
import { initSentry, reportSentryException } from './modules/sentry.js';
import { currentDirectory, environment, externalsStartDirectory, isProduction, resolvedPath } from './setup.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { OSCSettings } from 'ontime-types';
@@ -30,6 +29,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}`);
@@ -38,8 +38,6 @@ if (!isProduction) {
console.log(`Ontime directory at ${currentDirectory} `);
}
initSentry(isProduction);
// Create express APP
const app = express();
app.disable('x-powered-by');
@@ -55,7 +53,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 +130,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');
@@ -205,14 +206,12 @@ export const shutdown = async (exitCode = 0) => {
process.on('exit', (code) => console.log(`Ontime exited with code: ${code}`));
process.on('unhandledRejection', async (error) => {
reportSentryException(error);
process.on('unhandledRejection', async () => {
logger.error('SERVER', 'Error: unhandled rejection');
await shutdown(1);
});
process.on('uncaughtException', async (error) => {
reportSentryException(error);
process.on('uncaughtException', async () => {
logger.error('SERVER', 'Error: uncaught exception');
await shutdown(1);
});

Some files were not shown because too many files have changed in this diff Show More