refactor: small tweaks and fixes

This commit is contained in:
Carlos Valente
2024-03-06 22:42:20 +01:00
committed by GitHub
parent 8196ea584d
commit d22cc48565
61 changed files with 401 additions and 198 deletions
+15 -8
View File
@@ -1,5 +1,5 @@
[![Ontime build v2](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml/badge.svg)](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml) [![Ontime build v2](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml/badge.svg)](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Documentation in Gitbook](https://badges.aleen42.com/src/gitbook_2.svg)](https://ontime.gitbook.io) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0)
## Download the latest releases here ## Download the latest releases here
@@ -24,6 +24,8 @@ outputs.
![Views](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/overview.png) ![Views](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/overview.png)
[Read the docs to learn more](https://docs.getontime.no)
## Using Ontime ## Using Ontime
Once installed and running, Ontime starts a background server that is the heart of all processes. Once installed and running, Ontime starts a background server that is the heart of all processes.
@@ -59,7 +61,7 @@ IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
``` ```
More documentation is available [in our docs](https://ontime.gitbook.io) More documentation is available [in our docs](https://docs.getontime.no)
## Feature List (in no specific order) ## Feature List (in no specific order)
@@ -84,7 +86,7 @@ More documentation is available [in our docs](https://ontime.gitbook.io)
- WebSockets - WebSockets
- [x] Roll mode: run standalone using the system clock - [x] Roll mode: run standalone using the system clock
- [x] [Headless run](#headless-run): run server in a separate machine, configure from a browser locally - [x] [Headless run](#headless-run): run server in a separate machine, configure from a browser locally
- [x] [Countdown to anything!](https://ontime.gitbook.io/v2/views/countdown): have - [x] [Countdown to anything!](https://docs.getontime.no/features/count-to-anything/): have
a countdown to any scheduled event a countdown to any scheduled event
- [x] Multi-platform (available on Windows, MacOS and Linux) - [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime) - [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
@@ -120,9 +122,14 @@ Ontime broadcasts its data over WebSockets. This allows you to consume its data
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language 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). that can run in the browser).
<br /> <br />
See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a small template on We have prepared a few resources to help here:
how to get you started and read the docs about - Shipped with Ontime there is a small clock to get you started, it is available at `http://localhost:4001/external/demo` and the [code can be found here](https://github.com/cpvalente/ontime/tree/master/apps/server/src/external/demo)
the [Websocket API](https://ontime.gitbook.io/v2/control-and-feedback/ontime-apis#osc-and-websocket-api) - See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a template on
how to get you started
- See information about the [Websocket API](https://docs.getontime.no/api/osc-and-ws/)
<br />
More information [in the docs](https://docs.getontime.no/features/custom-views/)
### Headless run ### Headless run
@@ -130,7 +137,7 @@ You can self-host and run Ontime in a docker image.
The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime) The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
If you want to run this image in a Raspberry Pi, please see [the docs](https://ontime.gitbook.io/v2/additional-notes/use-in-raspberry-pi) If you want to run this image in a Raspberry Pi, please see [the docs](https://docs.getontime.no/additional-notes/use-with-rpi/)
## Roadmap ## Roadmap
@@ -192,7 +199,7 @@ Information about the project setup can be found in the [development documentati
# Help # Help
Help is underway! ... and can be found [here](https://ontime.gitbook.io) Help is underway! ... and can be found [here](https://docs.getontime.no)
# License # License
@@ -91,9 +91,6 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
(event: KeyboardEvent<HTMLInputElement>) => { (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
inputRef.current?.blur(); inputRef.current?.blur();
validateAndSubmit((event.target as HTMLInputElement).value);
} else if (event.key === 'Tab') {
validateAndSubmit((event.target as HTMLInputElement).value);
} }
if (event.key === 'Escape') { if (event.key === 'Escape') {
ignoreChange.current = true; ignoreChange.current = true;
@@ -101,7 +98,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
resetValue(); resetValue();
} }
}, },
[resetValue, validateAndSubmit], [resetValue],
); );
const onBlurHandler = useCallback( const onBlurHandler = useCallback(
@@ -1,7 +1,7 @@
.emptyContainer { .emptyContainer {
width: 100%; width: 100%;
text-align: center; text-align: center;
color: $gray-1350; color: $white-10;
.empty { .empty {
width: 100%; width: 100%;
@@ -5,7 +5,7 @@ import EmptyImage from '../../../assets/images/empty.svg?react';
import style from './Empty.module.scss'; import style from './Empty.module.scss';
interface EmptyProps { interface EmptyProps {
text: string; text?: string;
style?: CSSProperties; style?: CSSProperties;
} }
@@ -14,7 +14,7 @@ export default function Empty(props: EmptyProps) {
return ( return (
<div className={style.emptyContainer} {...rest}> <div className={style.emptyContainer} {...rest}>
<EmptyImage className={style.empty} /> <EmptyImage className={style.empty} />
<span className={style.text}>{text}</span> {text && <span className={style.text}>{text}</span>}
</div> </div>
); );
} }
@@ -14,6 +14,7 @@ describe('test forgivingStringToMillis()', () => {
{ value: '1h0m0s', expect: 1000 * 60 * 60 }, { value: '1h0m0s', expect: 1000 * 60 * 60 },
{ value: '23h0m0s', expect: 1000 * 60 * 60 * 23 }, { value: '23h0m0s', expect: 1000 * 60 * 60 * 23 },
{ value: '12h12m12s', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 }, { value: '12h12m12s', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
{ value: '12H12M12S', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
{ value: '2m', expect: 2 * 60 * 1000 }, { value: '2m', expect: 2 * 60 * 1000 },
{ value: '1h5s', expect: 1000 * 60 * 60 + 1000 * 5 }, { value: '1h5s', expect: 1000 * 60 * 60 + 1000 * 5 },
{ value: '1h2m', expect: 1000 * 60 * 60 + 1000 * 60 * 2 }, { value: '1h2m', expect: 1000 * 60 * 60 + 1000 * 60 * 2 },
+3 -3
View File
@@ -46,13 +46,13 @@ function checkAmPm(value: string) {
* @param {string} value * @param {string} value
*/ */
function checkMatchers(value: string) { function checkMatchers(value: string) {
const hoursMatch = /(\d+)h/.exec(value); const hoursMatch = /(\d+)h/i.exec(value);
const hoursMatchValue = hoursMatch ? parse(hoursMatch[1]) : 0; const hoursMatchValue = hoursMatch ? parse(hoursMatch[1]) : 0;
const minutesMatch = /(\d+)m/.exec(value); const minutesMatch = /(\d+)m/i.exec(value);
const minutesMatchValue = minutesMatch ? parse(minutesMatch[1]) : 0; const minutesMatchValue = minutesMatch ? parse(minutesMatch[1]) : 0;
const secondsMatch = /(\d+)s/.exec(value); const secondsMatch = /(\d+)s/i.exec(value);
const secondsMatchValue = secondsMatch ? parse(secondsMatch[1]) : 0; const secondsMatchValue = secondsMatch ? parse(secondsMatch[1]) : 0;
if (hoursMatchValue > 0 || minutesMatchValue > 0 || secondsMatchValue > 0) { if (hoursMatchValue > 0 || minutesMatchValue > 0 || secondsMatchValue > 0) {
+1 -1
View File
@@ -2,4 +2,4 @@ export const githubUrl = 'https://www.github.com/cpvalente/ontime';
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest'; export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const websiteUrl = 'https://www.getontime.no'; export const websiteUrl = 'https://www.getontime.no';
export const gitbookUrl = 'https://ontime.gitbook.io'; export const documentationUrl = 'https://docs.getontime.no';
@@ -1,6 +1,6 @@
import { version } from '../../../../../package.json'; import { version } from '../../../../../package.json';
import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { gitbookUrl, githubUrl, websiteUrl } from '../../../../externals'; import { documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../PanelUtils'; import * as Panel from '../PanelUtils';
import CheckUpdatesButton from './CheckUpdatesButton'; import CheckUpdatesButton from './CheckUpdatesButton';
@@ -18,7 +18,7 @@ export default function AboutPanel() {
</Panel.Section> </Panel.Section>
<Panel.Section> <Panel.Section>
<Panel.SubHeader>Links</Panel.SubHeader> <Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={gitbookUrl}>Read the docs over at GitBook</ExternalLink> <ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink> <ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
</Panel.Section> </Panel.Section>
<Panel.Section> <Panel.Section>
@@ -13,7 +13,7 @@ import * as Panel from '../PanelUtils';
import style from './GeneralPanel.module.scss'; import style from './GeneralPanel.module.scss';
const cssOverrideDocsUrl = 'https://ontime.gitbook.io/v2/features/custom-styling'; const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
export default function ViewSettingsForm() { export default function ViewSettingsForm() {
const { data, status, refetch, isFetching } = useViewSettings(); const { data, status, refetch, isFetching } = useViewSettings();
@@ -6,7 +6,7 @@ import * as Panel from '../PanelUtils';
import HttpIntegrations from './HttpIntegrations'; import HttpIntegrations from './HttpIntegrations';
import OscIntegrations from './OscIntegrations'; import OscIntegrations from './OscIntegrations';
const integrationDocsUrl = 'https://ontime.gitbook.io/v2/control-and-feedback/integrations'; const integrationDocsUrl = 'https://docs.getontime.no/api/integrations/';
export default function IntegrationsPanel() { export default function IntegrationsPanel() {
return ( return (
@@ -147,7 +147,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
<Input <Input
variant='ontime-filled' variant='ontime-filled'
size='sm' size='sm'
placeholder='www.ontime.gitbook.io' placeholder='http://docs.getontime.no'
autoComplete='off' autoComplete='off'
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
@@ -142,7 +142,7 @@ export default function ProjectData() {
<Input <Input
variant='ontime-filled' variant='ontime-filled'
size='sm' size='sm'
placeholder='www.ontime.gitbook.io' placeholder='http://docs.getontime.no'
autoComplete='off' autoComplete='off'
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
@@ -11,7 +11,7 @@ import * as Panel from '../PanelUtils';
import CustomFieldEntry from './CustomFieldEntry'; import CustomFieldEntry from './CustomFieldEntry';
import CustomFieldForm from './CustomFieldForm'; import CustomFieldForm from './CustomFieldForm';
const customFieldsDocsUrl = 'https://ontime.gitbook.io/v2/features/user-fields'; const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
export default function ProjectSettingsPanel() { export default function ProjectSettingsPanel() {
const { data, refetch } = useCustomFields(); const { data, refetch } = useCustomFields();
@@ -1,7 +1,7 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react'; import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/external-link/ExternalLink';
const googleSheetDocsUrl = 'https://ontime.gitbook.io/v2/features/google-sheet'; const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadsheet-gsheet/';
export default function GSheetInfo() { export default function GSheetInfo() {
return ( return (
@@ -4,7 +4,7 @@
grid-area: clk; grid-area: clk;
white-space: nowrap; white-space: nowrap;
max-width: 18.75rem; max-width: 18.75rem;
min-width: 5em;
font-family: var(--font-family-override, $viewer-font-family); font-family: var(--font-family-override, $viewer-font-family);
color: var(--timer-color-override, $timer-color); color: var(--timer-color-override, $timer-color);
@@ -11,7 +11,7 @@ interface TimerDisplayProps {
/** /**
* Displays time in ms in formatted timetag * Displays time in ms in formatted timetag
* Typically used in production views * Used in editor
*/ */
export default function TimerDisplay(props: TimerDisplayProps) { export default function TimerDisplay(props: TimerDisplayProps) {
const { time } = props; const { time } = props;
@@ -48,8 +48,7 @@ $panel-gap: 0.5rem;
} }
.left { .left {
flex-grow: 1; flex: 1 2 auto; /* flex-grow: 1, flex-shrink: 2, flex-basis: auto */
flex-shrink: 1;
min-width: $min-playback-width; min-width: $min-playback-width;
max-width: $max-playback-width; max-width: $max-playback-width;
display: flex; display: flex;
@@ -18,7 +18,7 @@ import OntimeModalFooter from '../OntimeModalFooter';
import style from './SettingsModal.module.scss'; import style from './SettingsModal.module.scss';
const aliasesDocsUrl = 'https://ontime.gitbook.io/v2/features/url-aliases'; const aliasesDocsUrl = 'https://docs.getontime.no/features/url-presets/';
// we wrap the array in an object to be simplify react-hook-form // we wrap the array in an object to be simplify react-hook-form
type Aliases = { type Aliases = {
@@ -122,7 +122,7 @@ export default function ProjectDataForm() {
{...inputProps} {...inputProps}
variant='ontime-filled-on-light' variant='ontime-filled-on-light'
size='sm' size='sm'
placeholder='www.ontime.gitbook.io' placeholder='https://docs.getontime.no'
isDisabled={disableInputs} isDisabled={disableInputs}
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
+21 -13
View File
@@ -15,7 +15,7 @@ import style from './Overview.module.scss';
* @param time * @param time
* @returns * @returns
*/ */
function formattedTime(time: MaybeNumber) { function formatedTime(time: MaybeNumber) {
return millisToString(time, { fallback: timerPlaceholder }); return millisToString(time, { fallback: timerPlaceholder });
} }
@@ -27,13 +27,13 @@ export default function Overview() {
<ErrorBoundary> <ErrorBoundary>
<TitlesOverview /> <TitlesOverview />
<div className={style.column}> <div className={style.column}>
<TimeRow label='Planned start' value={formattedTime(plannedStart)} className={style.start} /> <TimeRow label='Planned start' value={formatedTime(plannedStart)} className={style.start} />
<TimeRow label='Actual start' value={formattedTime(actualStart)} className={style.start} /> <TimeRow label='Actual start' value={formatedTime(actualStart)} className={style.start} />
</div> </div>
<RuntimeOverview /> <RuntimeOverview />
<div className={style.column}> <div className={style.column}>
<TimeRow label='Planned end' value={formattedTime(plannedEnd)} className={style.end} /> <TimeRow label='Planned end' value={formatedTime(plannedEnd)} className={style.end} />
<TimeRow label='Expected end' value={formattedTime(expectedEnd)} className={style.end} /> <TimeRow label='Expected end' value={formatedTime(expectedEnd)} className={style.end} />
</div> </div>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
@@ -51,24 +51,32 @@ function TitlesOverview() {
); );
} }
function getOffsetText(offset: MaybeNumber): string {
if (offset === null) {
return enDash;
}
const isAhead = offset <= 0;
let offsetText = millisToString(Math.abs(offset), { fallback: enDash });
if (offsetText !== enDash) {
offsetText = isAhead ? `+${offsetText}` : `${enDash}${offsetText}`;
}
return offsetText;
}
function RuntimeOverview() { function RuntimeOverview() {
const { clock, numEvents, selectedEventIndex, offset } = useRuntimePlaybackOverview(); const { clock, numEvents, selectedEventIndex, offset } = useRuntimePlaybackOverview();
const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash; const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash;
const ofTotal = numEvents || enDash; const ofTotal = numEvents || enDash;
const progressText = numEvents ? `${current} of ${ofTotal}` : '-'; const progressText = numEvents ? `${current} of ${ofTotal}` : '-';
const offsetText = getOffsetText(offset);
const isAhead = offset <= 0; const offsetClasses = offset === null ? undefined : offset > 0 ? style.behind : style.ahead;
let offsetText = millisToString(Math.abs(offset), { fallback: enDash });
if (offsetText !== enDash) {
offsetText = isAhead ? `+${offsetText}` : `${enDash}${offsetText}`;
}
return ( return (
<> <>
<TimeColumn label='Progress' value={progressText} /> <TimeColumn label='Progress' value={progressText} />
<TimeColumn label='Offset' value={offsetText} className={isAhead ? style.ahead : style.behind} /> <TimeColumn label='Offset' value={offsetText} className={offsetClasses} />
<TimeColumn label='Time now' value={formattedTime(clock)} /> <TimeColumn label='Time now' value={formatedTime(clock)} />
</> </>
); );
} }
+1 -1
View File
@@ -254,7 +254,7 @@ export default function Rundown({ data }: RundownProps) {
data={event} data={event}
loaded={isLoaded} loaded={isLoaded}
hasCursor={hasCursor} hasCursor={hasCursor}
next={isNext} isNext={isNext}
previousEnd={previousEnd} previousEnd={previousEnd}
previousEventId={previousEventId} previousEventId={previousEventId}
playback={isLoaded ? featureData.playback : undefined} playback={isLoaded ? featureData.playback : undefined}
@@ -14,7 +14,7 @@ export default function RundownEmpty(props: RundownEmptyProps) {
return ( return (
<div className={style.alignCenter}> <div className={style.alignCenter}>
<Empty text='No data yet' style={{ marginTop: '7vh' }} /> <Empty style={{ marginTop: '7vh' }} />
<Button onClick={handleAddNew} variant='ontime-filled' className={style.spaceTop} leftIcon={<IoAdd />}> <Button onClick={handleAddNew} variant='ontime-filled' className={style.spaceTop} leftIcon={<IoAdd />}>
Create Event Create Event
</Button> </Button>
@@ -22,7 +22,7 @@ interface RundownEntryProps {
loaded: boolean; loaded: boolean;
eventIndex: number; eventIndex: number;
hasCursor: boolean; hasCursor: boolean;
next: boolean; isNext: boolean;
previousEnd: MaybeNumber; previousEnd: MaybeNumber;
previousEventId?: string; previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing playback?: Playback; // we only care about this if this event is playing
@@ -30,7 +30,7 @@ interface RundownEntryProps {
} }
export default function RundownEntry(props: RundownEntryProps) { export default function RundownEntry(props: RundownEntryProps) {
const { isPast, data, loaded, hasCursor, next, previousEnd, previousEventId, playback, isRolling, eventIndex } = const { isPast, data, loaded, hasCursor, isNext, previousEnd, previousEventId, playback, isRolling, eventIndex } =
props; props;
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction(); const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
@@ -138,7 +138,7 @@ export default function RundownEntry(props: RundownEntryProps) {
previousEnd={previousEnd} previousEnd={previousEnd}
colour={data.colour} colour={data.colour}
isPast={isPast} isPast={isPast}
next={next} isNext={isNext}
skip={data.skip} skip={data.skip}
loaded={loaded} loaded={loaded}
hasCursor={hasCursor} hasCursor={hasCursor}
@@ -1,8 +1,8 @@
@use '../editors//EditorMixin' as editor; @use '../editors/EditorMixin' as editor;
.rundownExport { .rundownExport {
height: 100%; height: 100%;
flex: 1; flex: 1 1 auto; /* flex-grow: 1, flex-shrink: 1, flex-basis: auto */
&.extracted { &.extracted {
.list { .list {
@@ -34,8 +34,7 @@
padding-left: 0; padding-left: 0;
box-shadow: $box-shadow-right; box-shadow: $box-shadow-right;
flex-grow: 1; flex: 1 2 auto; /* flex-grow: 1, flex-shrink: 2, flex-basis: auto */
flex-shrink: 1;
min-width: 38rem; min-width: 38rem;
max-width: 45rem; max-width: 45rem;
} }
@@ -49,8 +48,7 @@
background-color: $gray-1325; background-color: $gray-1325;
border-radius: 0 8px 8px 0; border-radius: 0 8px 8px 0;
flex-grow: 1; flex: 1 1 auto; /* flex-grow: 1, flex-shrink: 1, flex-basis: auto */
flex-shrink: 1;
min-width: 30rem; min-width: 30rem;
max-width: 45rem; max-width: 45rem;
} }
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback } from 'react';
import { Editable, EditableInput, EditablePreview } from '@chakra-ui/react'; import { Input } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
@@ -15,39 +16,40 @@ interface TitleEditorProps {
export default function EditableBlockTitle(props: TitleEditorProps) { export default function EditableBlockTitle(props: TitleEditorProps) {
const { title, eventId, placeholder, className } = props; const { title, eventId, placeholder, className } = props;
const [blockTitle, setBlockTitle] = useState<string>(title || '');
const { updateEvent } = useEventAction(); const { updateEvent } = useEventAction();
useEffect(() => { const submitCallback = useCallback(
setBlockTitle(title);
}, [title]);
const handleTitle = useCallback(
(text: string) => { (text: string) => {
if (text === title) { if (text === title) {
return; return;
} }
const cleanVal = text.trim(); const cleanVal = text.trim();
setBlockTitle(cleanVal);
updateEvent({ id: eventId, title: cleanVal }); updateEvent({ id: eventId, title: cleanVal });
}, },
[title, updateEvent, eventId], [title, updateEvent, eventId],
); );
const classes = cx([className, style.eventTitle, !blockTitle ? style.noTitle : null]); const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(title, submitCallback, {
submitOnEnter: true,
});
const classes = cx([className, style.eventTitle, !value ? style.noTitle : null]);
return ( return (
<Editable <Input
variant='ontime' data-testid='block__title'
value={blockTitle} variant='ontime-ghosted'
value={value}
className={classes} className={classes}
placeholder={placeholder} placeholder={placeholder}
onChange={(value) => setBlockTitle(value)} onChange={onChange}
onSubmit={(value) => handleTitle(value)} onBlur={onBlur}
> onKeyDown={onKeyDown}
<EditablePreview className={style.preview} /> autoComplete='off'
<EditableInput /> fontWeight='600'
</Editable> letterSpacing='0.25px'
paddingLeft='0'
/>
); );
} }
@@ -10,7 +10,7 @@ $skip-opacity: 0.1;
grid-template-areas: grid-template-areas:
'binder ... ... ...' 'binder ... ... ...'
'binder pb-actions times actions' 'binder pb-actions times actions'
'binder pb-actions title next' 'binder pb-actions title title'
'binder pb-actions estatus estatus' 'binder pb-actions estatus estatus'
'binder ... ... ...'; 'binder ... ... ...';
@@ -127,15 +127,30 @@ $skip-opacity: 0.1;
.eventTimers { .eventTimers {
grid-area: times; grid-area: times;
display: flex; display: flex;
align-items: center;
gap: $block-clearance; gap: $block-clearance;
height: 100%; height: 100%;
} }
.eventTitle { .titleSection {
grid-area: title; grid-area: title;
overflow: hidden; display: flex;
max-height: calc(2.5em + 2px); align-items: center;
line-height: 1.25em; justify-content: space-between;
.nextTag {
font-size: 1rem;
color: $orange-500;
letter-spacing: 0.03px;
font-weight: 600;
}
.eventTitle {
overflow: hidden;
max-height: calc(2.5em + 2px);
line-height: 1.25em;
flex: 1;
}
} }
.eventActions { .eventActions {
@@ -146,10 +161,11 @@ $skip-opacity: 0.1;
.progressBg { .progressBg {
grid-area: progb; grid-area: progb;
border-radius: 2px; border-radius: 1px;
background-color: $gray-1100; background-color: $gray-1100;
opacity: 1; opacity: 1;
height: 100%; height: 100%;
overflow: hidden; /* clip foreground border radius*/
} }
.progressBg.hidden { .progressBg.hidden {
@@ -184,15 +200,6 @@ $skip-opacity: 0.1;
overflow-y: hidden; overflow-y: hidden;
} }
.nextTag {
grid-area: next;
font-size: 1rem;
color: $orange-500;
letter-spacing: 0.03px;
font-weight: 600;
text-align: right;
}
.eventStatus { .eventStatus {
grid-area: status; grid-area: status;
display: flex; display: flex;
@@ -40,7 +40,7 @@ interface EventBlockProps {
previousEnd: MaybeNumber; previousEnd: MaybeNumber;
colour: string; colour: string;
isPast: boolean; isPast: boolean;
next: boolean; isNext: boolean;
skip: boolean; skip: boolean;
loaded: boolean; loaded: boolean;
hasCursor: boolean; hasCursor: boolean;
@@ -76,7 +76,7 @@ export default function EventBlock(props: EventBlockProps) {
previousEnd, previousEnd,
colour, colour,
isPast, isPast,
next, isNext,
skip = false, skip = false,
loaded, loaded,
hasCursor, hasCursor,
@@ -240,7 +240,7 @@ export default function EventBlock(props: EventBlockProps) {
className={blockClasses} className={blockClasses}
ref={setNodeRef} ref={setNodeRef}
style={dragStyle} style={dragStyle}
onMouseDown={handleFocusClick} onClick={handleFocusClick}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
id='event-block' id='event-block'
> >
@@ -268,7 +268,7 @@ export default function EventBlock(props: EventBlockProps) {
title={title} title={title}
note={note} note={note}
delay={delay} delay={delay}
next={next} isNext={isNext}
skip={skip} skip={skip}
loaded={loaded} loaded={loaded}
playback={playback} playback={playback}
@@ -41,7 +41,7 @@ interface EventBlockInnerProps {
title: string; title: string;
note: string; note: string;
delay: number; delay: number;
next: boolean; isNext: boolean;
skip: boolean; skip: boolean;
loaded: boolean; loaded: boolean;
playback?: Playback; playback?: Playback;
@@ -63,7 +63,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
title, title,
note, note,
delay, delay,
next, isNext,
skip = false, skip = false,
loaded, loaded,
playback, playback,
@@ -100,12 +100,14 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
linkStart={linkStart} linkStart={linkStart}
/> />
</div> </div>
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} /> <div className={style.titleSection}>
{next && ( <EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
<Tooltip label='Next event' {...tooltipProps}> {isNext && (
<span className={style.nextTag}>UP NEXT</span> <Tooltip label='Next event' {...tooltipProps}>
</Tooltip> <span className={style.nextTag}>UP NEXT</span>
)} </Tooltip>
)}
</div>
<EventBlockPlayback <EventBlockPlayback
eventId={eventId} eventId={eventId}
skip={skip} skip={skip}
@@ -117,7 +119,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
<div className={style.statusElements} id='block-status' data-ispublic={isPublic}> <div className={style.statusElements} id='block-status' data-ispublic={isPublic}>
<span className={style.eventNote}>{note}</span> <span className={style.eventNote}>{note}</span>
<div className={loaded ? style.progressBg : `${style.progressBg} ${style.hidden}`}> <div className={loaded ? style.progressBg : `${style.progressBg} ${style.hidden}`}>
{loaded && <EventBlockProgressBar playback={playback} />} {loaded && <EventBlockProgressBar />}
</div> </div>
<div className={style.eventStatus} tabIndex={-1}> <div className={style.eventStatus} tabIndex={-1}>
<Tooltip label={`Time type: ${timerType}`} {...tooltipProps}> <Tooltip label={`Time type: ${timerType}`} {...tooltipProps}>
@@ -10,6 +10,7 @@ $gap-left: calc(2rem + 0.25rem + 3rem);
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
font-weight: 700;
} }
@mixin indicator($bg-colour) { @mixin indicator($bg-colour) {
@@ -1,4 +1,4 @@
import { memo } from 'react'; import { memo, MouseEvent } from 'react';
import { IoPause } from '@react-icons/all-files/io5/IoPause'; import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay'; import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoReload } from '@react-icons/all-files/io5/IoReload'; import { IoReload } from '@react-icons/all-files/io5/IoReload';
@@ -40,11 +40,13 @@ const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
const { eventId, skip, isPlaying, isPaused, loaded, disablePlayback } = props; const { eventId, skip, isPlaying, isPaused, loaded, disablePlayback } = props;
const { updateEvent } = useEventAction(); const { updateEvent } = useEventAction();
const toggleSkip = () => { const toggleSkip = (event: MouseEvent) => {
event.stopPropagation();
updateEvent({ id: eventId, skip: !skip }); updateEvent({ id: eventId, skip: !skip });
}; };
const actionHandler = () => { const actionHandler = (event: MouseEvent) => {
event.stopPropagation();
// is playing -> pause // is playing -> pause
// is paused -> continue // is paused -> continue
// otherwise -> start // otherwise -> start
@@ -57,6 +59,11 @@ const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
} }
}; };
const load = (event: MouseEvent) => {
event.stopPropagation();
setEventPlayback.loadEvent(eventId);
};
const buttonVariant: Partial<StyleVariant> = {}; const buttonVariant: Partial<StyleVariant> = {};
if (isPaused) { if (isPaused) {
@@ -103,7 +110,7 @@ const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
isDisabled={disablePlayback} isDisabled={disablePlayback}
{...tooltipProps} {...tooltipProps}
{...blockBtnStyle} {...blockBtnStyle}
clickHandler={() => setEventPlayback.loadEvent(eventId)} clickHandler={load}
tabIndex={-1} tabIndex={-1}
/> />
<TooltipActionBtn <TooltipActionBtn
@@ -5,20 +5,5 @@
transition: 1s linear; transition: 1s linear;
transition-property: width; transition-property: width;
background-color: $gray-200;
&.play {
background-color: $playback-start;
}
&.overtime {
background-color: $playback-negative;
}
&.pause {
background-color: $ontime-paused;
}
&.roll {
background-color: $ontime-roll;
}
} }
@@ -1,14 +1,10 @@
import { MaybeNumber, Playback } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { useTimer } from '../../../../common/hooks/useSocket'; import { useTimer } from '../../../../common/hooks/useSocket';
import { clamp } from '../../../../common/utils/math'; import { clamp } from '../../../../common/utils/math';
import style from './EventBlockProgressBar.module.scss'; import style from './EventBlockProgressBar.module.scss';
interface EventBlockProgressBarProps {
playback?: Playback;
}
export function getPercentComplete(remaining: MaybeNumber, total: MaybeNumber): number { export function getPercentComplete(remaining: MaybeNumber, total: MaybeNumber): number {
if (remaining === null || total === null) { if (remaining === null || total === null) {
return 0; return 0;
@@ -25,10 +21,9 @@ export function getPercentComplete(remaining: MaybeNumber, total: MaybeNumber):
return clamp(100 - (remaining * 100) / total, 0, 100); return clamp(100 - (remaining * 100) / total, 0, 100);
} }
export default function EventBlockProgressBar(props: EventBlockProgressBarProps) { export default function EventBlockProgressBar() {
const { playback } = props;
const timer = useTimer(); const timer = useTimer();
const progress = `${getPercentComplete(timer.current, timer.duration)}%`; const progress = `${getPercentComplete(timer.current, timer.duration)}%`;
return <div className={`${style.progressBar} ${playback ? style[playback] : ''}`} style={{ width: progress }} />; return <div className={style.progressBar} style={{ width: progress }} />;
} }
@@ -63,6 +63,7 @@
font-size: calc(1rem - 3px); font-size: calc(1rem - 3px);
color: $label-gray; color: $label-gray;
display: flex; display: flex;
align-items: center;
gap: 0.5rem; gap: 0.5rem;
max-width: max-content; max-width: max-content;
cursor: pointer; cursor: pointer;
@@ -135,7 +135,7 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
<div> <div>
<span className={style.inputLabel}>Event Visibility</span> <span className={style.inputLabel}>Event Visibility</span>
<label className={style.switchLabel}> <label className={style.switchLabel}>
<Switch size='sm' isChecked={isPublic} onChange={() => handleSubmit('isPublic', isPublic)} variant='ontime' /> <Switch size='md' isChecked={isPublic} onChange={() => handleSubmit('isPublic', isPublic)} variant='ontime' />
{isPublic ? 'Public' : 'Private'} {isPublic ? 'Public' : 'Private'}
</label> </label>
</div> </div>
@@ -22,15 +22,16 @@ export default function RundownHeader() {
<div className={style.header}> <div className={style.header}>
<ButtonGroup isAttached> <ButtonGroup isAttached>
<TooltipActionBtn <TooltipActionBtn
variant={appMode === AppMode.Freeze ? 'ontime-filled' : 'ontime-ghosted'} variant={appMode === AppMode.Freeze ? 'ontime-filled' : 'ontime-outlined'}
size='sm' size='sm'
icon={<IoSnowOutline />} icon={<IoSnowOutline />}
clickHandler={setFreezeMode} clickHandler={setFreezeMode}
tooltip='Freeze rundown' tooltip='Freeze rundown'
aria-label='Freeze rundown' aria-label='Freeze rundown'
isDisabled
/> />
<TooltipActionBtn <TooltipActionBtn
variant={appMode === AppMode.Run ? 'ontime-filled' : 'ontime-ghosted'} variant={appMode === AppMode.Run ? 'ontime-filled' : 'ontime-outlined'}
size='sm' size='sm'
icon={<IoPlay />} icon={<IoPlay />}
clickHandler={setRunMode} clickHandler={setRunMode}
@@ -38,7 +39,7 @@ export default function RundownHeader() {
aria-label='Run mode' aria-label='Run mode'
/> />
<TooltipActionBtn <TooltipActionBtn
variant={appMode === AppMode.Edit ? 'ontime-filled' : 'ontime-ghosted'} variant={appMode === AppMode.Edit ? 'ontime-filled' : 'ontime-outlined'}
size='sm' size='sm'
icon={<IoOptions />} icon={<IoOptions />}
clickHandler={setEditMode} clickHandler={setEditMode}
@@ -47,7 +48,7 @@ export default function RundownHeader() {
/> />
</ButtonGroup> </ButtonGroup>
<RundownMenu> <RundownMenu>
<MenuButton size='sm' as={Button} rightIcon={<IoAdd />} aria-label='Rundown menu' variant='ontime-ghosted'> <MenuButton size='sm' as={Button} rightIcon={<IoAdd />} aria-label='Rundown menu' variant='ontime-outlined'>
Rundown Rundown
</MenuButton> </MenuButton>
</RundownMenu> </RundownMenu>
@@ -26,4 +26,5 @@
color: $blue-500; color: $blue-500;
margin-right: 0.5rem; margin-right: 0.5rem;
font-size: 1.5em; font-size: 1.5em;
display: grid;
} }
@@ -30,6 +30,19 @@
transition: $viewer-transition-time; transition: $viewer-transition-time;
} }
.blackout {
position: absolute;
width: 100vw;
height: 100vh;
background-color: #000;
z-index: 2;
opacity: 0;
transition: opacity $viewer-transition-time;
&--active {
opacity: 1;
}
}
/* =================== CLOCK ===================*/ /* =================== CLOCK ===================*/
.clock-container { .clock-container {
@@ -145,7 +145,7 @@ export default function Timer(props: TimerProps) {
} }
const stageTimerCharacters = display.replace('/:/g', '').length; const stageTimerCharacters = display.replace('/:/g', '').length;
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''} ${showBlackout ? 'blackout' : ''}`; const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`;
let timerFontSize = 89 / (stageTimerCharacters - 1); let timerFontSize = 89 / (stageTimerCharacters - 1);
// we need to shrink the timer if the external is going to be there // we need to shrink the timer if the external is going to be there
if (showExternal) { if (showExternal) {
@@ -162,6 +162,7 @@ export default function Timer(props: TimerProps) {
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'> <div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
<NavigationMenu /> <NavigationMenu />
<ViewParamsEditor paramFields={timerOptions} /> <ViewParamsEditor paramFields={timerOptions} />
<div className={showBlackout ? 'blackout blackout--active' : 'blackout'} />
{!userOptions.hideMessage && ( {!userOptions.hideMessage && (
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}> <div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div> <div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
+13 -2
View File
@@ -33,8 +33,19 @@ html,
background-color: $bg-container-l1; background-color: $bg-container-l1;
} }
// workaround for chakra /* smaller root size for MacOS laptops */
// // https://github.com/chakra-ui/chakra-ui/issues/417 @media (max-width: 1600px) {
body,
html,
.App {
font-size: 14px;
}
}
/**
* workaround for chakra
* https://github.com/chakra-ui/chakra-ui/issues/417
*/
option { option {
color: initial; color: initial;
} }
-4
View File
@@ -1,7 +1,3 @@
.mirror { .mirror {
transform: rotate(180deg); transform: rotate(180deg);
} }
.blackout {
opacity: 0;
}
+13
View File
@@ -3,6 +3,7 @@ const commonStyles = {
backgroundColor: '#262626', // $gray-1200 backgroundColor: '#262626', // $gray-1200
color: '#e2e2e2', // $gray-200 color: '#e2e2e2', // $gray-200
border: '1px solid transparent', border: '1px solid transparent',
borderRadius: '3px',
_hover: { _hover: {
backgroundColor: '#2d2d2d', // $gray-1100 backgroundColor: '#2d2d2d', // $gray-1100
}, },
@@ -25,6 +26,18 @@ export const ontimeInputFilled = {
}, },
}; };
export const ontimeInputGhosted = {
field: {
...commonStyles,
backgroundColor: 'transparent',
color: '#f6f6f6', // $gray-50
_hover: {
backgroundColor: 'transparent', // $gray-1100
border: '1px solid #2B5ABC', // $blue-500
},
},
};
export const ontimeInputFilledOnLight = { export const ontimeInputFilledOnLight = {
field: { field: {
backgroundColor: 'white', backgroundColor: 'white',
+2
View File
@@ -23,6 +23,7 @@ import { ontimeTab } from './ontimeTab';
import { import {
ontimeInputFilled, ontimeInputFilled,
ontimeInputFilledOnLight, ontimeInputFilledOnLight,
ontimeInputGhosted,
ontimeTextAreaFilled, ontimeTextAreaFilled,
ontimeTextAreaFilledOnLight, ontimeTextAreaFilledOnLight,
ontimeTextAreaTransparent, ontimeTextAreaTransparent,
@@ -71,6 +72,7 @@ const theme = extendTheme({
}, },
variants: { variants: {
'ontime-filled': { ...ontimeInputFilled }, 'ontime-filled': { ...ontimeInputFilled },
'ontime-ghosted': { ...ontimeInputGhosted },
'ontime-filled-on-light': { ...ontimeInputFilledOnLight }, 'ontime-filled-on-light': { ...ontimeInputFilledOnLight },
}, },
}, },
+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"name": "ontime", "name": "ontime-prerelease",
"version": "3.0.0-alpha", "version": "3.0.0-alpha",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
@@ -29,8 +29,8 @@
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" "cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
}, },
"build": { "build": {
"productName": "ontime", "productName": "ontime-prerelease",
"appId": "no.lightdev.ontime", "appId": "no.lightdev.ontime.prerelease",
"asar": true, "asar": true,
"dmg": { "dmg": {
"artifactName": "ontime-macOS-${arch}.dmg", "artifactName": "ontime-macOS-${arch}.dmg",
+7 -1
View File
@@ -121,6 +121,12 @@ function getApplicationMenu(isMac, askToQuit) {
await shell.openExternal('http://localhost:4001/cuesheet'); await shell.openExternal('http://localhost:4001/cuesheet');
}, },
}, },
{
label: 'Operator',
click: async () => {
await shell.openExternal('http://localhost:4001/operator');
},
},
], ],
}, },
{ type: 'separator' }, { type: 'separator' },
@@ -153,7 +159,7 @@ function getApplicationMenu(isMac, askToQuit) {
{ {
label: 'Online documentation', label: 'Online documentation',
click: async () => { click: async () => {
await shell.openExternal('https://ontime.gitbook.io/'); await shell.openExternal('https://docs.getontime.no/');
}, },
}, },
], ],
@@ -85,13 +85,13 @@ const actionHandlers: Record<string, ActionHandler> = {
return { payload: 'success' }; return { payload: 'success' };
} }
if ('id' in payload) { if ('id' in payload) {
assert.isString(payload); assert.isString(payload.id);
runtimeService.startById(payload); runtimeService.startById(payload.id);
return { payload: 'success' }; return { payload: 'success' };
} }
if ('cue' in payload) { if ('cue' in payload) {
assert.isString(payload); assert.isString(payload.cue);
runtimeService.startByCue(payload); runtimeService.startByCue(payload.cue);
return { payload: 'success' }; return { payload: 'success' };
} }
} }
+1 -1
View File
@@ -48,7 +48,7 @@ const connectSocket = () => {
// we only need to read message type of ontime // we only need to read message type of ontime
if (type === 'ontime') { if (type === 'ontime') {
// destructure known data from ontime // destructure known data from ontime
// see https://cpvalente.gitbook.io/ontime/control-and-feedback/websocket-api // see https://docs.getontime.no/api/osc-and-ws/
const { timer, playback } = payload; const { timer, playback } = payload;
const timerElement = document.getElementById('timer'); const timerElement = document.getElementById('timer');
if (playback == 'stop') { if (playback == 'stop') {
+1 -1
View File
@@ -10,5 +10,5 @@
<body> <body>
<div id="timer"></div> <div id="timer"></div>
<script src="./app.js" type="module"></script> <script src="./app.js" type="text/javascript"></script>
</html> </html>
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable no-console */
import { initAssets, startIntegrations, startOSCServer, startServer } from './app.js'; import { initAssets, startIntegrations, startOSCServer, startServer } from './app.js';
async function startOntime() { async function startOntime() {
+3 -2
View File
@@ -142,17 +142,18 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
const isTimeToUpdate = state.clock - TimerService.previousUpdate >= TimerService._updateInterval; const isTimeToUpdate = state.clock - TimerService.previousUpdate >= TimerService._updateInterval;
// some changes need an immediate update // some changes need an immediate update
const hasNewLoaded = state.eventNow?.id !== TimerService.previousState?.eventNow?.id;
const hasSkippedBack = state.clock < TimerService.previousUpdate; const hasSkippedBack = state.clock < TimerService.previousUpdate;
const justStarted = !TimerService.previousState?.timer; const justStarted = !TimerService.previousState?.timer;
const hasChangedPlayback = TimerService.previousState.timer?.playback !== state.timer.playback; const hasChangedPlayback = TimerService.previousState.timer?.playback !== state.timer.playback;
const hasImmediateChanges = hasSkippedBack || justStarted || hasChangedPlayback; const hasImmediateChanges = hasNewLoaded || hasSkippedBack || justStarted || hasChangedPlayback;
if (hasImmediateChanges || (isTimeToUpdate && !deepEqual(TimerService.previousState?.timer, state.timer))) { if (hasImmediateChanges || (isTimeToUpdate && !deepEqual(TimerService.previousState?.timer, state.timer))) {
eventStore.set('timer', state.timer); eventStore.set('timer', state.timer);
TimerService.previousState.timer = { ...state.timer }; TimerService.previousState.timer = { ...state.timer };
} }
if (isTimeToUpdate && !deepEqual(TimerService.previousState?.runtime, state.runtime)) { if (hasChangedPlayback || (isTimeToUpdate && !deepEqual(TimerService.previousState?.runtime, state.runtime))) {
eventStore.set('runtime', state.runtime); eventStore.set('runtime', state.runtime);
TimerService.previousState.runtime = { ...state.runtime }; TimerService.previousState.runtime = { ...state.runtime };
} }
@@ -1387,6 +1387,9 @@ describe('getRuntimeOffset()', () => {
_timer: { _timer: {
pausedAt: null, pausedAt: null,
}, },
runtime: {
actualStart: 150,
},
} as RuntimeState; } as RuntimeState;
const offset = getRuntimeOffset(state); const offset = getRuntimeOffset(state);
@@ -1408,6 +1411,9 @@ describe('getRuntimeOffset()', () => {
_timer: { _timer: {
pausedAt: null, pausedAt: null,
}, },
runtime: {
actualStart: 100,
},
} as RuntimeState; } as RuntimeState;
const offset = getRuntimeOffset(state); const offset = getRuntimeOffset(state);
@@ -1430,9 +1436,116 @@ describe('getRuntimeOffset()', () => {
_timer: { _timer: {
pausedAt: 125, pausedAt: 125,
}, },
runtime: {
actualStart: 100,
},
} as RuntimeState; } as RuntimeState;
const offset = getRuntimeOffset(state); const offset = getRuntimeOffset(state);
expect(offset).toBe(25); expect(offset).toBe(25);
}); });
it('can only count once started', () => {
const state = {
clock: 78480789,
eventNow: {
id: 'd6a2ce',
type: 'event',
title: '',
timeStart: 77400000,
timeEnd: 81000000,
duration: 3600000,
timeStrategy: 'lock-duration',
linkStart: null,
endAction: 'none',
timerType: 'count-down',
isPublic: true,
skip: false,
note: '',
colour: '',
cue: '1',
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
custom: {},
delay: 0,
},
runtime: {
selectedEventIndex: 0,
numEvents: 2,
offset: -77400000,
plannedStart: 77400000,
plannedEnd: 84600000,
actualStart: null,
expectedEnd: null,
},
timer: {
addedTime: 0,
current: 3600000,
duration: 3600000,
elapsed: null,
expectedFinish: null,
finishedAt: null,
playback: 'armed',
secondaryTimer: null,
startedAt: null,
},
_timer: { pausedAt: null, secondaryTarget: null, finishedNow: false },
} as RuntimeState;
const offset = getRuntimeOffset(state);
expect(offset).toBe(null);
});
it('handles loaded event', () => {
const state = {
clock: 79521653,
eventNow: {
id: '835242',
type: 'event',
title: '',
timeStart: 81000000,
timeEnd: 84600000,
duration: 3600000,
timeStrategy: 'lock-duration',
linkStart: null,
endAction: 'none',
timerType: 'count-down',
isPublic: true,
skip: false,
note: '',
colour: '',
cue: '2',
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
custom: {},
delay: 0,
},
runtime: {
selectedEventIndex: 1,
numEvents: 2,
offset: -81000000,
plannedStart: 77400000,
plannedEnd: 84600000,
actualStart: 79443403,
expectedEnd: null,
},
timer: {
addedTime: 0,
current: 3600000,
duration: 3600000,
elapsed: null,
expectedFinish: null,
finishedAt: null,
playback: 'armed',
secondaryTimer: null,
startedAt: null,
},
_timer: { pausedAt: null, secondaryTarget: null, finishedNow: false },
} as RuntimeState;
const offset = getRuntimeOffset(state);
expect(offset).toBe(79521653 - 81000000);
});
}); });
@@ -14,6 +14,7 @@ import { appStateService } from '../app-state-service/AppStateService.js';
import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js'; import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js';
import { dbModel } from '../../models/dataModel.js'; import { dbModel } from '../../models/dataModel.js';
import { deleteFile } from '../../utils/parserUtils.js'; import { deleteFile } from '../../utils/parserUtils.js';
import { switchDb } from '../../setup/loadDb.js';
// init dependencies // init dependencies
init(); init();
@@ -40,6 +41,9 @@ export async function applyProjectFile(filePath: string, options?: Options) {
const newFilePath = join(resolveProjectsDirectory, filename); const newFilePath = join(resolveProjectsDirectory, filename);
await rename(filePath, newFilePath); await rename(filePath, newFilePath);
// change LowDB to point to new file
await switchDb(filename);
// apply data model // apply data model
await applyDataModel(data, options); await applyDataModel(data, options);
@@ -135,6 +139,9 @@ export async function createProjectFile(filename: string, projectData: ProjectDa
const newFile = join(resolveProjectsDirectory, filename); const newFile = join(resolveProjectsDirectory, filename);
await writeFile(newFile, JSON.stringify(data)); await writeFile(newFile, JSON.stringify(data));
// change LowDB to point to new file
await switchDb(filename);
// apply its data // apply its data
await applyDataModel(data); await applyDataModel(data);
@@ -191,10 +191,7 @@ class RuntimeService {
} }
const timedEvents = getPlayableEvents(); const timedEvents = getPlayableEvents();
const state = runtimeState.getState(); const success = runtimeState.load(event, timedEvents);
// TODO: return success boolean from runtimeState, when we work with optimising integrations
runtimeState.load(event, timedEvents);
const success = event.id === state.eventNow?.id;
if (success) { if (success) {
integrationService.dispatch(TimerLifeCycle.onLoad); integrationService.dispatch(TimerLifeCycle.onLoad);
+12 -5
View File
@@ -292,20 +292,27 @@ export const updateRoll = (state: RuntimeState) => {
/** /**
* Calculates difference between the runtime and the schedule of an event * Calculates difference between the runtime and the schedule of an event
* Positive offset is a delay
* Negative offset is time ahead
* @param state * @param state
* @returns * @returns
*/ */
export function getRuntimeOffset(state: RuntimeState): number { export function getRuntimeOffset(state: RuntimeState): MaybeNumber {
if (state.eventNow === null) { if (state.runtime.actualStart === null) {
return 0; return null;
} }
const { clock } = state;
const { timeStart } = state.eventNow; const { timeStart } = state.eventNow;
const { addedTime, current, startedAt } = state.timer; const { addedTime, current, startedAt } = state.timer;
// if we havent started, the offset is the difference to the schedule
if (startedAt === null) {
return clock - timeStart;
}
const overtime = Math.min(current, 0); const overtime = Math.min(current, 0);
const startOffset = startedAt - timeStart; const startOffset = startedAt - timeStart;
const pausedTime = state._timer.pausedAt === null ? 0 : state.clock - state._timer.pausedAt; const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
return startOffset + addedTime + pausedTime + Math.abs(overtime); return startOffset + addedTime + pausedTime + Math.abs(overtime);
} }
+2 -1
View File
@@ -97,7 +97,8 @@ const lastLoadedProject = isTest ? 'db.json' : getLastLoadedProject();
// path to public db // path to public db
export const resolveDbDirectory = join(testDbStartDirectory, isTest ? `../${config.database.testdb}` : config.projects); export const resolveDbDirectory = join(testDbStartDirectory, isTest ? `../${config.database.testdb}` : config.projects);
export const resolveDbPath = join(resolveDbDirectory, lastLoadedProject ? lastLoadedProject : config.database.filename); export const resolveDbName = lastLoadedProject ? lastLoadedProject : config.database.filename;
export const resolveDbPath = join(resolveDbDirectory, resolveDbName);
export const pathToStartDb = isTest export const pathToStartDb = isTest
? join(srcDirectory, '..', config.database.testdb, config.database.filename) ? join(srcDirectory, '..', config.database.testdb, config.database.filename)
+23 -12
View File
@@ -8,7 +8,7 @@ import { join } from 'path';
import { ensureDirectory } from '../utils/fileManagement.js'; import { ensureDirectory } from '../utils/fileManagement.js';
import { dbModel } from '../models/dataModel.js'; import { dbModel } from '../models/dataModel.js';
import { pathToStartDb, resolveDbDirectory, resolveDbPath } from './index.js'; import { pathToStartDb, resolveDbDirectory, resolveDbName } from './index.js';
import { parseProjectFile } from '../services/project-service/projectFileUtils.js'; import { parseProjectFile } from '../services/project-service/projectFileUtils.js';
import { parseJson } from '../utils/parser.js'; import { parseJson } from '../utils/parser.js';
@@ -16,25 +16,25 @@ import { parseJson } from '../utils/parser.js';
* @description ensures directories exist and populates database * @description ensures directories exist and populates database
* @return {string} - path to db file * @return {string} - path to db file
*/ */
const populateDb = (): string => { const populateDb = (directory: string, filename: string): string => {
// if everything goes well, the DB in disk is the one loaded ensureDirectory(directory);
let dbInDisk = resolveDbPath; let dbPath = join(directory, filename);
ensureDirectory(resolveDbDirectory);
// if everything goes well, the DB in disk is the one loaded
// if dbInDisk doesn't exist we want to use startup db // if dbInDisk doesn't exist we want to use startup db
if (!existsSync(dbInDisk)) { if (!existsSync(dbPath)) {
try { try {
const dbDirectory = resolveDbDirectory; const dbDirectory = resolveDbDirectory;
const newFileDirectory = join(dbDirectory, pathToStartDb.split('/').pop()); const newFileDirectory = join(dbDirectory, pathToStartDb.split('/').pop());
copyFileSync(pathToStartDb, newFileDirectory); copyFileSync(pathToStartDb, newFileDirectory);
dbInDisk = newFileDirectory; dbPath = newFileDirectory;
} catch (_) { } catch (_) {
/* we do not handle this */ /* we do not handle this */
} }
} }
return dbInDisk; return dbPath;
}; };
/** /**
@@ -55,10 +55,9 @@ const parseDatabase = async (fileToRead: string, adapterToUse: Low<DatabaseModel
/** /**
* @description loads ontime db * @description loads ontime db
* @return {Promise<{data: (*), db: Low<unknown>}>}
*/ */
async function loadDb() { async function loadDb(directory: string, filename: string) {
const dbInDisk = populateDb(); const dbInDisk = populateDb(directory, filename);
const adapter = new JSONFile<DatabaseModel>(dbInDisk); const adapter = new JSONFile<DatabaseModel>(dbInDisk);
const db = new Low(adapter, dbModel); const db = new Low(adapter, dbModel);
@@ -72,12 +71,24 @@ async function loadDb() {
export let db = {} as Low<DatabaseModel>; export let db = {} as Low<DatabaseModel>;
export let data = {} as DatabaseModel; export let data = {} as DatabaseModel;
export const dbLoadingProcess = loadDb(); export const dbLoadingProcess = loadDb(resolveDbDirectory, resolveDbName);
/**
* Initialises database at known location
*/
const init = async () => { const init = async () => {
const dbProvider = await dbLoadingProcess; const dbProvider = await dbLoadingProcess;
db = dbProvider.db; db = dbProvider.db;
data = dbProvider.data; data = dbProvider.data;
}; };
/**
* Allows to switch the database to a new file
*/
export const switchDb = async (newFileName: string) => {
const { db: newDb, data: newData } = await loadDb(resolveDbDirectory, newFileName);
db = newDb;
data = newData;
};
init(); init();
@@ -177,7 +177,7 @@ describe('mutation on runtimeState', () => {
stop(); stop();
newState = getState(); newState = getState();
expect(newState.runtime.actualStart).toBeNull(); expect(newState.runtime.actualStart).toBeNull();
expect(newState.runtime.offset).toBe(0); expect(newState.runtime.offset).toBeNull();
expect(newState.runtime.expectedEnd).toBeNull(); expect(newState.runtime.expectedEnd).toBeNull();
}); });
+12 -4
View File
@@ -17,7 +17,7 @@ import { timerConfig } from '../config/config.js';
const initialRuntime: Runtime = { const initialRuntime: Runtime = {
selectedEventIndex: null, selectedEventIndex: null,
numEvents: 0, numEvents: 0,
offset: 0, offset: null,
plannedStart: 0, plannedStart: 0,
plannedEnd: 0, plannedEnd: 0,
actualStart: null, actualStart: null,
@@ -128,7 +128,11 @@ export function updateRundownData(playableRundown: OntimeEvent[]) {
* @param rundown * @param rundown
* @param initialData * @param initialData
*/ */
export function load(event: OntimeEvent, rundown: OntimeEvent[], initialData?: Partial<TimerState & RestorePoint>) { export function load(
event: OntimeEvent,
rundown: OntimeEvent[],
initialData?: Partial<TimerState & RestorePoint>,
): boolean {
clear(); clear();
updateRundownData(rundown); updateRundownData(rundown);
@@ -153,9 +157,11 @@ export function load(event: OntimeEvent, rundown: OntimeEvent[], initialData?: P
if (firstStart === null || typeof firstStart === 'number') { if (firstStart === null || typeof firstStart === 'number') {
runtimeState.runtime.actualStart = firstStart; runtimeState.runtime.actualStart = firstStart;
runtimeState.runtime.offset = getRuntimeOffset(runtimeState); runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
runtimeState.runtime.expectedEnd = runtimeState.runtime.plannedEnd + runtimeState.runtime.offset; runtimeState.runtime.expectedEnd = (runtimeState.runtime.plannedEnd + runtimeState.runtime.offset) % dayInMs;
} }
} }
return event.id === runtimeState.eventNow?.id;
} }
export function loadNow(event: OntimeEvent, playableEvents: OntimeEvent[]) { export function loadNow(event: OntimeEvent, playableEvents: OntimeEvent[]) {
@@ -329,7 +335,9 @@ export function addTime(amount: number) {
// update runtime delays: over - under // update runtime delays: over - under
runtimeState.runtime.offset = getRuntimeOffset(runtimeState); runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
runtimeState.runtime.expectedEnd = runtimeState.runtime.plannedEnd + runtimeState.runtime.offset; if (runtimeState.runtime.offset !== null) {
runtimeState.runtime.expectedEnd = (runtimeState.runtime.plannedEnd + runtimeState.runtime.offset) % dayInMs;
}
return true; return true;
} }
+2 -4
View File
@@ -15,8 +15,6 @@ import {
OntimeEvent, OntimeEvent,
OntimeRundown, OntimeRundown,
SupportedEvent, SupportedEvent,
EndAction,
TimerType,
TimeStrategy, TimeStrategy,
CustomFields, CustomFields,
EventCustomFields, EventCustomFields,
@@ -325,8 +323,8 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
duration, duration,
timeStrategy, timeStrategy,
linkStart: validateLinkStart(maybeLinkStart), linkStart: validateLinkStart(maybeLinkStart),
endAction: validateEndAction(patchEvent.endAction, EndAction.None), endAction: validateEndAction(patchEvent.endAction, originalEvent.endAction),
timerType: validateTimerType(patchEvent.timerType, TimerType.CountDown), timerType: validateTimerType(patchEvent.timerType, originalEvent.timerType),
isPublic: typeof patchEvent.isPublic === 'boolean' ? patchEvent.isPublic : originalEvent.isPublic, isPublic: typeof patchEvent.isPublic === 'boolean' ? patchEvent.isPublic : originalEvent.isPublic,
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip, skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
note: makeString(patchEvent.note, originalEvent.note), note: makeString(patchEvent.note, originalEvent.note),
+9 -4
View File
@@ -1,4 +1,4 @@
import { test } from '@playwright/test'; import { test, expect } from '@playwright/test';
const fileToUpload = 'e2e/tests/fixtures/test-db.json'; const fileToUpload = 'e2e/tests/fixtures/test-db.json';
@@ -20,7 +20,12 @@ test('test project file upload', async ({ page }) => {
await page.getByRole('button', { name: 'close' }).click(); await page.getByRole('button', { name: 'close' }).click();
// asset test events // asset test events
await page.getByText('Albania').click(); const firstTitle = page.getByTestId('entry-1').getByTestId('block__title')
await page.getByText('Latvia').click(); await expect(firstTitle).toHaveValue('Albania');
await page.getByText('Lithuania').click();
const secondTitle = page.getByTestId('entry-2').getByTestId('block__title')
await expect(secondTitle).toHaveValue('Latvia');
const thirdTitle = page.getByTestId('entry-3').getByTestId('block__title')
await expect(thirdTitle).toHaveValue('Lithuania');
}); });
+4 -4
View File
@@ -64,11 +64,11 @@ test('delays are show correctly', async ({ page }) => {
await page.getByTestId('rundown').getByTestId('time-input-duration').click(); await page.getByTestId('rundown').getByTestId('time-input-duration').click();
await page.getByTestId('rundown').getByTestId('time-input-duration').fill('10'); await page.getByTestId('rundown').getByTestId('time-input-duration').fill('10');
await page.getByTestId('rundown').getByTestId('time-input-duration').press('Enter'); await page.getByTestId('rundown').getByTestId('time-input-duration').press('Enter');
await page.getByText('Event title').click(); await page.getByTestId('block__title').click();
await page.getByPlaceholder('Event title').fill('test'); await page.getByTestId('block__title').fill('test');
await page.getByPlaceholder('Event title').press('Enter'); await page.getByTestId('block__title').press('Enter');
await page.getByTestId('entry-1').getByText('test').click({ button: 'right' }); await page.locator('#event-block').getByText('1').click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Toggle public' }).click(); await page.getByRole('menuitem', { name: 'Toggle public' }).click();
// add a delay // add a delay
@@ -3,7 +3,7 @@ import { MaybeNumber } from '../../utils/utils.type.js';
export type Runtime = { export type Runtime = {
numEvents: number; numEvents: number;
selectedEventIndex: MaybeNumber; selectedEventIndex: MaybeNumber;
offset: number; offset: MaybeNumber;
plannedStart: MaybeNumber; plannedStart: MaybeNumber;
actualStart: MaybeNumber; actualStart: MaybeNumber;
plannedEnd: MaybeNumber; plannedEnd: MaybeNumber;