Compare commits

..

7 Commits

Author SHA1 Message Date
Carlos Valente f554605007 wip: poc for playing a sound on time end 2024-10-07 20:46:36 +02:00
Alex Christoffer Rasmussen 86e6f8b58c bump version to 3.6.1 (#1237) 2024-10-03 14:50:05 +02:00
Carlos Valente 1de062a38e refactor: remove alt shortcut 2024-10-03 08:35:56 +02:00
Carlos Valente eb7a30fcf3 refactor: improve resizing of column 2024-10-02 22:34:19 +02:00
Carlos Valente e2cbe43a2b fix: form element alignment in form 2024-10-02 21:57:52 +02:00
Carlos Valente 7877af571f fix: apply delay 2024-10-02 21:44:03 +02:00
Carlos Valente 086bd5b644 docs: add discord link 2024-10-01 08:18:19 +02:00
17 changed files with 362 additions and 312 deletions
+19 -18
View File
@@ -14,8 +14,10 @@
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a> - Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a> - Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
## Using Ontime? ## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
## Using Ontime?
Let us know! Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback. Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
@@ -35,6 +37,16 @@ Ontime is made by entertainment and broadcast engineers and used by
- Theatres and opera houses - Theatres and opera houses
- Houses of worship - Houses of worship
## Main features
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
... and a lot more ...
### For live environments ### For live environments
Ontime is designed for use in live environments. \ Ontime is designed for use in live environments. \
@@ -83,6 +95,7 @@ IP.ADDRESS:4001/clock > Simple clock view
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
IP.ADDRESS:4001/countdown > Countdown to anything IP.ADDRESS:4001/countdown > Countdown to anything
IP.ADDRESS:4001/studio > Studio Clock IP.ADDRESS:4001/studio > Studio Clock
IP.ADDRESS:4001/timeline > Timeline
``` ```
``` ```
@@ -100,26 +113,13 @@ IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/operator > automated views for operators IP.ADDRESS:4001/operator > automated views for operators
``` ```
More documentation is available [in our docs](https://docs.getontime.no) More information is available [in our docs](https://docs.getontime.no)
## Main features
- [x] Distribute data over network and render it in the browser
- [x] Collaborative
- [x] Extendable
- [x] Send messages to different screen types
- [x] Differentiate between backstage and public data
- [x] Workflow for managing delays
- [x] Rich protocol integrations for Control and Feedback
- [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
## Roadmap ## Roadmap
### Continued development ### Continued development
Ontime is under active development. We continue adding and tweaking features in collaboration with users. Ontime is under active development. We continue adding and improving features in collaboration with users.
Have an idea? Reach out via [email](mail@getontime.no) Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new) or [open an issue](https://github.com/cpvalente/ontime/issues/new)
@@ -161,8 +161,9 @@ If you are a developer and would like to contribute with code, please open an is
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md) Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
## Links ## Links
- [Ontime website](https://getontime.no)
See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no) - [Documentation](https://docs.getontime.no)
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
## License ## License
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.6.0", "version": "3.6.1",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.6.0", "version": "3.6.1",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
Binary file not shown.
@@ -176,6 +176,14 @@ export const useTimer = () => {
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
export const useTimerPhase = () => {
const featureSelector = (state: RuntimeStore) => ({
phase: state.timer.phase,
});
return useRuntimeStore(featureSelector);
};
export const useClock = () => { export const useClock = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
clock: state.clock, clock: state.clock,
@@ -78,7 +78,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
</div> </div>
</Panel.Title> </Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>} {error && <Panel.Error>{error}</Panel.Error>}
<div className={style.innerColumn}> <Panel.Section className={style.innerColumn}>
<label> <label>
Project title Project title
<Input <Input
@@ -145,7 +145,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
</label> </label>
</div> </Panel.Section>
</Panel.Section> </Panel.Section>
); );
} }
@@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_DATA } from '../../../../common/api/constants'; import { PROJECT_DATA } from '../../../../common/api/constants';
import { getDb, patchData } from '../../../../common/api/db'; import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../PanelUtils'; import * as Panel from '../PanelUtils';
import { makeProjectPatch } from './project.utils'; import { makeProjectPatch } from './project.utils';
@@ -92,7 +93,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
</div> </div>
</Panel.Title> </Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>} {error && <Panel.Error>{error}</Panel.Error>}
<div className={style.innerColumn}> <Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
<Panel.Description> <Panel.Description>
Select partial data from {`"${fileName}"`} to merge into the current project. Select partial data from {`"${fileName}"`} to merge into the current project.
<br /> This process is irreversible. <br /> This process is irreversible.
@@ -121,7 +122,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
<Switch variant='ontime' {...register('http')} /> <Switch variant='ontime' {...register('http')} />
HTTP Integration HTTP Integration
</label> </label>
</div> </Panel.Section>
</Panel.Section> </Panel.Section>
); );
} }
@@ -53,9 +53,11 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1em; gap: 1em;
}
.inlineLabels {
label { label {
display: flex; display: flex;
gap: 1em; gap: 1rem;
} }
} }
@@ -50,9 +50,15 @@ $table-header-font-size: calc(1rem - 3px);
font-size: $table-header-font-size; font-size: $table-header-font-size;
color: $gray-700; color: $gray-700;
}
th { th {
background-color: $gray-1300; background-color: $gray-1300;
&:hover {
.resizer {
width: 0.5rem;
}
} }
} }
@@ -126,30 +132,19 @@ $table-header-font-size: calc(1rem - 3px);
.resizer { .resizer {
cursor: col-resize; cursor: col-resize;
opacity: 0; opacity: $opacity-disabled;
display: inline-block; display: inline-block;
width: 3px; width: 0;
height: 100%; height: 100%;
position: absolute; position: absolute;
right: 0; right: 0;
top: 0; top: 0;
transform: translateX(50%);
background-color: $action-blue; background-color: $action-blue;
user-select: none; user-select: none;
touch-action: none; touch-action: none;
transition-duration: $transition-time-action;
transition-property: width, background-color;
&:hover { &:hover {
opacity: $opacity-disabled;
width: 6px;
}
&.isResizing {
opacity: 1; opacity: 1;
width: 6px;
background-color: $action-blue;
} }
} }
@@ -4,8 +4,6 @@ import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table'; import { Header } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types'; import { OntimeRundownEntry } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils';
import styles from '../Cuesheet.module.scss'; import styles from '../Cuesheet.module.scss';
interface SortableCellProps { interface SortableCellProps {
@@ -29,8 +27,6 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
transition, transition,
}; };
const resizerClasses = cx([styles.resizer, header.column.getIsResizing() ? styles.isResizing : null]);
return ( return (
<th ref={setNodeRef} style={dragStyle} colSpan={colSpan}> <th ref={setNodeRef} style={dragStyle} colSpan={colSpan}>
<div {...attributes} {...listeners}> <div {...attributes} {...listeners}>
@@ -41,7 +37,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
onMouseDown: header.getResizeHandler(), onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(), onTouchStart: header.getResizeHandler(),
}} }}
className={resizerClasses} className={styles.resizer}
/> />
</th> </th>
); );
@@ -1,4 +1,6 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { usePrevious } from '@mantine/hooks';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { import {
CustomFields, CustomFields,
@@ -12,12 +14,14 @@ import {
ViewSettings, ViewSettings,
} from 'ontime-types'; } from 'ontime-types';
import sound from '../../../assets/sounds/buzzer.mp3';
import { overrideStylesURL } from '../../../common/api/constants'; import { overrideStylesURL } from '../../../common/api/constants';
import { FitText } from '../../../common/components/fit-text/FitText'; import { FitText } from '../../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar'; import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import TitleCard from '../../../common/components/title-card/TitleCard'; import TitleCard from '../../../common/components/title-card/TitleCard';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useTimerPhase } from '../../../common/hooks/useSocket';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
@@ -59,12 +63,28 @@ interface TimerProps {
viewSettings: ViewSettings; viewSettings: ViewSettings;
} }
const usePhaseEvent = () => {
const { phase } = useTimerPhase();
const previousValue = usePrevious(phase);
const audio = useMemo(() => new Audio(sound), []);
if (previousValue !== TimerPhase.None && previousValue !== phase && phase === TimerPhase.Overtime) {
try {
audio.play();
} catch (error) {
console.error('Audio playback prevented', error);
}
}
};
export default function Timer(props: TimerProps) { export default function Timer(props: TimerProps) {
const { auxTimer, customFields, eventNow, eventNext, isMirrored, message, settings, time, viewSettings } = props; const { auxTimer, customFields, eventNow, eventNext, isMirrored, message, settings, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
usePhaseEvent();
useWindowTitle('Timer'); useWindowTitle('Timer');
-7
View File
@@ -174,13 +174,6 @@ app.whenReady().then(() => {
createWindow(); createWindow();
// register global shortcuts
// (available regardless of whether app is in focus)
// bring focus to window
globalShortcut.register('Alt+1', () => {
bringToFront();
});
startBackend() startBackend()
.then((port) => { .then((port) => {
// Load page served by node or use React dev run // Load page served by node or use React dev run
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "3.6.0", "version": "3.6.1",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "3.6.0", "version": "3.6.1",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
@@ -1,249 +1,243 @@
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types'; import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
import { apply } from '../delayUtils.js'; import { apply } from '../delayUtils.js';
import { MILLIS_PER_HOUR } from 'ontime-utils';
describe('apply() ', () => { /**
describe('in a rundown without the delay field, persisted rundown', () => { * Small utility to fill in the necessary data for the test
it('applies delays', () => { */
const delayId = '1'; function makeOntimeEvent(event: Partial<OntimeEvent>): OntimeEvent {
const testRundown: OntimeRundown = [ return { ...event, type: SupportedEvent.Event, revision: 1 } as OntimeEvent;
{ id: delayId, type: SupportedEvent.Delay, duration: 10 } as OntimeDelay, }
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
];
const expected = [ /**
{ id: '2', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } as OntimeEvent, * Small utility to make a delay event
{ id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } as OntimeEvent, */
{ id: '4', type: SupportedEvent.Block } as OntimeBlock, function makeOntimeDelay(duration: number): OntimeDelay {
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, return { id: 'delay', type: SupportedEvent.Delay, duration } as OntimeDelay;
]; }
const updatedRundown = apply(delayId, testRundown); describe('apply()', () => {
expect(updatedRundown).toStrictEqual(expected); it('applies a positive delay to the rundown', () => {
}); const testRundown = [
it('applies negative delays', () => { makeOntimeDelay(10),
const delayId = '1'; makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
const testRundown: OntimeRundown = [ makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
{ id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay, { id: '3', type: SupportedEvent.Block } as OntimeBlock,
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
{ id: '3', type: SupportedEvent.Event, timeStart: 20, timeEnd: 40, duration: 20, revision: 1 } as OntimeEvent, makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
{ id: '4', type: SupportedEvent.Block } as OntimeBlock, ];
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
];
const expected = [ const updatedRundown = apply('delay', testRundown);
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 2 } as OntimeEvent, expect(updatedRundown).not.toBe(testRundown);
{ id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 30, duration: 20, revision: 2 } as OntimeEvent, expect(updatedRundown).toMatchObject([
{ id: '4', type: SupportedEvent.Block } as OntimeBlock, { id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 },
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, { id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '1' },
]; { id: '3' },
{ id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: null },
const updatedRundown = apply(delayId, testRundown); { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: '4' },
expect(updatedRundown).toStrictEqual(expected); ]);
});
it('maintains constant duration', () => {
const delayId = '1';
const testRundown: OntimeRundown = [
{ id: delayId, type: SupportedEvent.Delay, duration: -30 } as OntimeDelay,
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, timeStart: 20, timeEnd: 40, duration: 20, revision: 1 } as OntimeEvent,
];
const expected = [
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 2 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, timeStart: 0, timeEnd: 20, duration: 20, revision: 2 } as OntimeEvent,
];
const updatedRundown = apply(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
}); });
describe('in a rundown with the delay field, cached rundown', () => {
it('applies delays', () => {
const delayId = '1';
const testRundown: OntimeRundown = [
{ id: delayId, type: SupportedEvent.Delay, duration: 10 } as OntimeDelay,
{
id: '2',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 10,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 10,
} as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{
id: '5',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 0,
} as OntimeEvent,
];
const expected = [ it('applies negative delays', () => {
{ const testRundown = [
id: '2', makeOntimeDelay(-10),
type: SupportedEvent.Event, makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
timeStart: 10, makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
timeEnd: 20, { id: '3', type: SupportedEvent.Block } as OntimeBlock,
duration: 10, makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
revision: 2, makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
delay: 0, ];
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 10,
timeEnd: 20,
duration: 10,
revision: 2,
delay: 0,
} as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{
id: '5',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 0,
} as OntimeEvent,
];
const updatedRundown = apply(delayId, testRundown); const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toStrictEqual(expected); expect(updatedRundown).toMatchObject([
}); { id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 },
it('applies negative delays', () => { { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null },
const delayId = '1'; { id: '3' },
const testRundown: OntimeRundown = [ { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: null },
{ id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay, { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '4' },
{ ]);
id: '2', });
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: -10,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 20,
timeEnd: 40,
duration: 20,
revision: 1,
delay: -10,
} as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{
id: '5',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 0,
} as OntimeEvent,
];
const expected = [ it('should account for minimum duration and start when applying negative delays', () => {
{ const testRundown: OntimeRundown = [
id: '2', makeOntimeDelay(-50),
type: SupportedEvent.Event, makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
timeStart: 0, makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: '1' }),
timeEnd: 10, ];
duration: 10,
revision: 2,
delay: 0,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 10,
timeEnd: 30,
duration: 20,
revision: 2,
delay: 0,
} as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{
id: '5',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 0,
} as OntimeEvent,
];
const updatedRundown = apply(delayId, testRundown); const expected = [
expect(updatedRundown).toStrictEqual(expected); { id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 2 } as OntimeEvent,
}); {
it('maintains constant duration', () => { id: '2',
const delayId = '1'; type: SupportedEvent.Event,
const testRundown: OntimeRundown = [ timeStart: 50,
{ id: delayId, type: SupportedEvent.Delay, duration: -30 } as OntimeDelay, timeEnd: 100,
{ duration: 50,
id: '2', linkStart: null,
type: SupportedEvent.Event, revision: 2,
timeStart: 0, } as OntimeEvent,
timeEnd: 10, ];
duration: 10,
revision: 1,
delay: -30,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 20,
timeEnd: 40,
duration: 20,
revision: 1,
delay: -30,
} as OntimeEvent,
];
const expected = [ const updatedRundown = apply('delay', testRundown);
{ expect(updatedRundown).toMatchObject(expected);
id: '2', });
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 2,
delay: 0,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 20,
duration: 20,
revision: 2,
delay: 0,
} as OntimeEvent,
];
const updatedRundown = apply(delayId, testRundown); it('unlinks events to maintain gaps when applying positive delays', () => {
expect(updatedRundown).toStrictEqual(expected); const testRundown = [
}); makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
makeOntimeDelay(50),
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
];
expect(apply('delay', testRundown)).toMatchObject([
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent,
{
id: '2',
type: SupportedEvent.Event,
timeStart: 150,
timeEnd: 200,
duration: 50,
linkStart: null,
revision: 2,
} as OntimeEvent,
]);
});
it('maintains links if there is no gap', () => {
const testRundown = [
makeOntimeDelay(50),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
];
expect(apply('delay', testRundown)).toMatchObject([
{ id: '1', type: SupportedEvent.Event, timeStart: 50, timeEnd: 150, duration: 100, revision: 2 } as OntimeEvent,
{
id: '2',
type: SupportedEvent.Event,
timeStart: 150,
timeEnd: 200,
duration: 50,
linkStart: '1',
revision: 2,
} as OntimeEvent,
]);
});
it('unlinks events to maintain gaps when applying negative delays', () => {
const testRundown = [
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
makeOntimeDelay(-50),
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
];
expect(apply('delay', testRundown)).toMatchObject([
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent,
{
id: '2',
type: SupportedEvent.Event,
timeStart: 50,
timeEnd: 100,
duration: 50,
linkStart: null,
revision: 2,
} as OntimeEvent,
]);
});
it('gaps reduce positive delay', () => {
const testRundown: OntimeRundown = [
makeOntimeDelay(100),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
// gap 50
makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50 }),
// gap 50
makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50 }),
// gap 50
makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50 }),
// linked
makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }),
];
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject([
{ id: '1', timeStart: 0 + 100, timeEnd: 100 + 100, duration: 100, revision: 2 },
// gap 50 (100 - 50)
{ id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 },
// gap 50 (50 - 50)
{ id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2 },
// gap (delay is 0)
{ id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 },
// linked
{ id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: '4' },
]);
});
it('gaps reduce positive delay (2)', () => {
const testRundown: OntimeRundown = [
makeOntimeDelay(2 * MILLIS_PER_HOUR),
makeOntimeEvent({
id: '1',
timeStart: 46800000, // 13:00:00
timeEnd: 50400000, // 14:00:00
duration: MILLIS_PER_HOUR,
}),
// gap 1h
makeOntimeEvent({
id: '2',
timeStart: 54000000, // 15:00:00
timeEnd: 57600000, // 16:00:00
duration: MILLIS_PER_HOUR,
}),
];
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject([
{ id: '1', timeStart: 54000000 /* 16 */, revision: 2 },
// gap 1h (2h - 1h)
{ id: '2', timeStart: 57600000 /* 16 */, revision: 2 },
]);
});
it('removes empty delays without applying changes', () => {
const testRundown: OntimeRundown = [
makeOntimeDelay(0),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
];
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject([{ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }]);
});
it('removes delays in last position without applying changes', () => {
const testRundown: OntimeRundown = [
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
makeOntimeDelay(100),
];
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject([{ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }]);
});
it('unlinks events to across blocks is it is the first event after the delay', () => {
const testRundown = [
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
makeOntimeDelay(50),
{ id: 'block', type: SupportedEvent.Block } as OntimeBlock,
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
];
expect(apply('delay', testRundown)).toMatchObject([
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent,
{ id: 'block', type: SupportedEvent.Block },
{
id: '2',
type: SupportedEvent.Event,
timeStart: 150,
timeEnd: 200,
duration: 50,
linkStart: null,
revision: 2,
} as OntimeEvent,
]);
}); });
}); });
@@ -1,6 +1,5 @@
import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent } from 'ontime-types'; import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent, OntimeEvent } from 'ontime-types';
import { getTimeFromPrevious, deleteAtIndex } from 'ontime-utils';
import { deleteAtIndex } from '../../../../../packages/utils/src/array-utils/arrayUtils.js';
/** /**
* Calculates all delays in a given rundown * Calculates all delays in a given rundown
@@ -92,10 +91,7 @@ export function getDelayAt(eventIndex: number, rundown: OntimeRundown): number {
/** /**
* Applies delay from given event ID, deletes the delay event after * Applies delay from given event ID, deletes the delay event after
* @param eventId
* @param rundown
* @throws {Error} if event ID not found or is not a delay * @throws {Error} if event ID not found or is not a delay
* @returns
*/ */
export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown { export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
const delayIndex = rundown.findIndex((event) => event.id === eventId); const delayIndex = rundown.findIndex((event) => event.id === eventId);
@@ -109,27 +105,71 @@ export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
throw new Error('Given event ID is not a delay'); throw new Error('Given event ID is not a delay');
} }
const updatedRundown = [...rundown]; // if the delay is empty, or the last element, we can just delete it
const delayValue = delayEvent.duration; if (delayEvent.duration === 0 || delayIndex === rundown.length - 1) {
return deleteAtIndex(delayIndex, rundown);
if (delayValue === 0 || delayIndex === rundown.length - 1) {
// nothing to apply
return updatedRundown;
} }
for (let i = delayIndex + 1; i < rundown.length; i++) { /**
const currentEvent = updatedRundown[i]; * We apply the delay to the rundown
* This logic is mostly in sync with rundownCache.generate
*/
const updatedRundown = structuredClone(rundown);
let delayValue = delayEvent.duration;
let lastEntry: OntimeEvent | null = null;
let isFirstEvent = true;
if (isOntimeBlock(currentEvent)) { for (let i = delayIndex + 1; i < updatedRundown.length; i++) {
break; const currentEntry = updatedRundown[i];
} else if (isOntimeEvent(currentEvent)) {
currentEvent.timeStart = Math.max(0, currentEvent.timeStart + delayValue); // we don't do operation on other event types
currentEvent.timeEnd = Math.max(currentEvent.duration, currentEvent.timeEnd + delayValue); if (!isOntimeEvent(currentEntry)) {
if (currentEvent.delay) { continue;
currentEvent.delay = currentEvent.delay - delayValue;
}
currentEvent.revision += 1;
} }
// we need to remove the link in the first event to maintain the gap
let shouldUnlink = isFirstEvent;
isFirstEvent = false;
// if the event is not linked, we try and maintain gaps
if (lastEntry !== null) {
const timeFromPrevious: number = getTimeFromPrevious(
currentEntry.timeStart,
lastEntry.timeStart,
lastEntry.timeEnd,
lastEntry.duration,
);
// when applying negative delays, we need to unlink the event
// if the previous event was fully consumed by the delay
if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) {
shouldUnlink = true;
}
if (timeFromPrevious > 0) {
delayValue = Math.max(delayValue - timeFromPrevious, 0);
}
if (delayValue === 0) {
// we can bail from continuing if there are no further delays to apply
break;
}
}
// save the current entry before making mutations on its values
lastEntry = { ...currentEntry };
if (shouldUnlink) {
currentEntry.linkStart = null;
shouldUnlink = false;
}
// event times move up by the delay value
// we dont update the delay value since we would need to iterate through the entire dataset
// this is handled by the rundownCache.generate function
currentEntry.timeStart = Math.max(0, currentEntry.timeStart + delayValue);
currentEntry.timeEnd = Math.max(currentEntry.duration, currentEntry.timeEnd + delayValue);
currentEntry.revision += 1;
} }
return deleteAtIndex(delayIndex, updatedRundown); return deleteAtIndex(delayIndex, updatedRundown);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "3.6.0", "version": "3.6.1",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"keywords": [ "keywords": [
"ontime", "ontime",