diff --git a/README.md b/README.md
index 696ec4bde2..a9cd590d1f 100644
--- a/README.md
+++ b/README.md
@@ -14,8 +14,10 @@
- Download AppImage for Linux
- Get from Docker hub
-## 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!
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
- 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
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/countdown > Countdown to anything
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
```
-More documentation 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)
+More information is available [in our docs](https://docs.getontime.no)
## Roadmap
### 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)
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)
## Links
-
-See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no)
+- [Ontime website](https://getontime.no)
+- [Documentation](https://docs.getontime.no)
+- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
## License
diff --git a/apps/cli/package.json b/apps/cli/package.json
index ca36565ae1..48c0e093a4 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
- "version": "3.6.0",
+ "version": "3.6.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
diff --git a/apps/client/package.json b/apps/client/package.json
index 644e56fa65..859971265a 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
- "version": "3.6.0",
+ "version": "3.6.1",
"private": true,
"type": "module",
"dependencies": {
diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx
index 4669561771..059d2cea41 100644
--- a/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx
+++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx
@@ -78,7 +78,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
{error && {error}}
-
+
);
}
diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectMergeForm.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectMergeForm.tsx
index a3fafb9d84..72c976acdf 100644
--- a/apps/client/src/features/app-settings/panel/project-panel/ProjectMergeForm.tsx
+++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectMergeForm.tsx
@@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_DATA } from '../../../../common/api/constants';
import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
+import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../PanelUtils';
import { makeProjectPatch } from './project.utils';
@@ -92,7 +93,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
{error && {error}}
-
+
Select partial data from {`"${fileName}"`} to merge into the current project.
This process is irreversible.
@@ -121,7 +122,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
HTTP Integration
-
+
);
}
diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss
index e1947a3cb2..08689c91c1 100644
--- a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss
+++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss
@@ -53,9 +53,11 @@
display: flex;
flex-direction: column;
gap: 1em;
+}
+.inlineLabels {
label {
display: flex;
- gap: 1em;
+ gap: 1rem;
}
}
diff --git a/apps/client/src/features/cuesheet/Cuesheet.module.scss b/apps/client/src/features/cuesheet/Cuesheet.module.scss
index dadb04e3ee..a6b20136bd 100644
--- a/apps/client/src/features/cuesheet/Cuesheet.module.scss
+++ b/apps/client/src/features/cuesheet/Cuesheet.module.scss
@@ -50,9 +50,15 @@ $table-header-font-size: calc(1rem - 3px);
font-size: $table-header-font-size;
color: $gray-700;
+}
+
+th {
+ background-color: $gray-1300;
- th {
- background-color: $gray-1300;
+ &:hover {
+ .resizer {
+ width: 0.5rem;
+ }
}
}
@@ -126,30 +132,19 @@ $table-header-font-size: calc(1rem - 3px);
.resizer {
cursor: col-resize;
- opacity: 0;
+ opacity: $opacity-disabled;
display: inline-block;
- width: 3px;
+ width: 0;
height: 100%;
position: absolute;
right: 0;
top: 0;
- transform: translateX(50%);
background-color: $action-blue;
user-select: none;
touch-action: none;
- transition-duration: $transition-time-action;
- transition-property: width, background-color;
-
&:hover {
- opacity: $opacity-disabled;
- width: 6px;
- }
-
- &.isResizing {
opacity: 1;
- width: 6px;
- background-color: $action-blue;
}
}
diff --git a/apps/client/src/features/cuesheet/cuesheet-table-elements/SortableCell.tsx b/apps/client/src/features/cuesheet/cuesheet-table-elements/SortableCell.tsx
index f20535c218..07cb0509c1 100644
--- a/apps/client/src/features/cuesheet/cuesheet-table-elements/SortableCell.tsx
+++ b/apps/client/src/features/cuesheet/cuesheet-table-elements/SortableCell.tsx
@@ -4,8 +4,6 @@ import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
-import { cx } from '../../../common/utils/styleUtils';
-
import styles from '../Cuesheet.module.scss';
interface SortableCellProps {
@@ -29,8 +27,6 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
transition,
};
- const resizerClasses = cx([styles.resizer, header.column.getIsResizing() ? styles.isResizing : null]);
-
return (
@@ -41,7 +37,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(),
}}
- className={resizerClasses}
+ className={styles.resizer}
/>
|
);
diff --git a/apps/electron/main.js b/apps/electron/main.js
index c7929f6554..edc3966cb3 100644
--- a/apps/electron/main.js
+++ b/apps/electron/main.js
@@ -174,13 +174,6 @@ app.whenReady().then(() => {
createWindow();
- // register global shortcuts
- // (available regardless of whether app is in focus)
- // bring focus to window
- globalShortcut.register('Alt+1', () => {
- bringToFront();
- });
-
startBackend()
.then((port) => {
// Load page served by node or use React dev run
diff --git a/apps/electron/package.json b/apps/electron/package.json
index 50fc82fd7f..a1f7b43057 100644
--- a/apps/electron/package.json
+++ b/apps/electron/package.json
@@ -1,6 +1,6 @@
{
"name": "ontime",
- "version": "3.6.0",
+ "version": "3.6.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
diff --git a/apps/server/package.json b/apps/server/package.json
index 368d93805f..c195bde985 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
- "version": "3.6.0",
+ "version": "3.6.1",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
diff --git a/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts b/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts
index 5454d860b0..4c06f79d02 100644
--- a/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts
+++ b/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts
@@ -1,249 +1,243 @@
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
import { apply } from '../delayUtils.js';
+import { MILLIS_PER_HOUR } from 'ontime-utils';
-describe('apply() ', () => {
- describe('in a rundown without the delay field, persisted 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 } 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,
- { id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } 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 updatedRundown = apply(delayId, testRundown);
- expect(updatedRundown).toStrictEqual(expected);
- });
- it('applies negative 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 } as OntimeEvent,
- { id: '3', type: SupportedEvent.Event, timeStart: 20, timeEnd: 40, duration: 20, 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: 0, timeEnd: 10, duration: 10, revision: 2 } as OntimeEvent,
- { id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 30, duration: 20, revision: 2 } 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 updatedRundown = apply(delayId, testRundown);
- 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);
- });
+/**
+ * Small utility to fill in the necessary data for the test
+ */
+function makeOntimeEvent(event: Partial): OntimeEvent {
+ return { ...event, type: SupportedEvent.Event, revision: 1 } as OntimeEvent;
+}
+
+/**
+ * Small utility to make a delay event
+ */
+function makeOntimeDelay(duration: number): OntimeDelay {
+ return { id: 'delay', type: SupportedEvent.Delay, duration } as OntimeDelay;
+}
+
+describe('apply()', () => {
+ it('applies a positive delay to the rundown', () => {
+ const testRundown = [
+ makeOntimeDelay(10),
+ makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
+ makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
+ { id: '3', type: SupportedEvent.Block } as OntimeBlock,
+ makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
+ makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
+ ];
+
+ const updatedRundown = apply('delay', testRundown);
+ expect(updatedRundown).not.toBe(testRundown);
+ expect(updatedRundown).toMatchObject([
+ { id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 },
+ { 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 },
+ { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: '4' },
+ ]);
+ });
+
+ it('applies negative delays', () => {
+ const testRundown = [
+ makeOntimeDelay(-10),
+ makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
+ makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
+ { id: '3', type: SupportedEvent.Block } as OntimeBlock,
+ makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
+ makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
+ ];
+
+ const updatedRundown = apply('delay', testRundown);
+ expect(updatedRundown).toMatchObject([
+ { id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 },
+ { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null },
+ { id: '3' },
+ { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: null },
+ { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '4' },
+ ]);
+ });
+
+ it('should account for minimum duration and start when applying negative delays', () => {
+ const testRundown: OntimeRundown = [
+ makeOntimeDelay(-50),
+ makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
+ makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: '1' }),
+ ];
+
+ const expected = [
+ { id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 2 } as OntimeEvent,
+ {
+ id: '2',
+ type: SupportedEvent.Event,
+ timeStart: 50,
+ timeEnd: 100,
+ duration: 50,
+ linkStart: null,
+ revision: 2,
+ } as OntimeEvent,
+ ];
+
+ const updatedRundown = apply('delay', testRundown);
+ expect(updatedRundown).toMatchObject(expected);
+ });
+
+ it('unlinks events to maintain gaps when applying positive 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: 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,
+ ]);
});
- 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 = [
- {
- id: '2',
- type: SupportedEvent.Event,
- timeStart: 10,
- timeEnd: 20,
- duration: 10,
- revision: 2,
- 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);
- expect(updatedRundown).toStrictEqual(expected);
- });
- it('applies negative 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: 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 = [
- {
- id: '2',
- type: SupportedEvent.Event,
- timeStart: 0,
- 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);
- 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,
- delay: -30,
- } as OntimeEvent,
- {
- id: '3',
- type: SupportedEvent.Event,
- timeStart: 20,
- timeEnd: 40,
- duration: 20,
- revision: 1,
- delay: -30,
- } as OntimeEvent,
- ];
-
- const 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);
- expect(updatedRundown).toStrictEqual(expected);
- });
+
+ 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,
+ ]);
});
});
diff --git a/apps/server/src/services/rundown-service/delayUtils.ts b/apps/server/src/services/rundown-service/delayUtils.ts
index e5805aa855..a1abeb83ab 100644
--- a/apps/server/src/services/rundown-service/delayUtils.ts
+++ b/apps/server/src/services/rundown-service/delayUtils.ts
@@ -1,6 +1,5 @@
-import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent } from 'ontime-types';
-
-import { deleteAtIndex } from '../../../../../packages/utils/src/array-utils/arrayUtils.js';
+import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent, OntimeEvent } from 'ontime-types';
+import { getTimeFromPrevious, deleteAtIndex } from 'ontime-utils';
/**
* 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
- * @param eventId
- * @param rundown
* @throws {Error} if event ID not found or is not a delay
- * @returns
*/
export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
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');
}
- const updatedRundown = [...rundown];
- const delayValue = delayEvent.duration;
-
- if (delayValue === 0 || delayIndex === rundown.length - 1) {
- // nothing to apply
- return updatedRundown;
+ // if the delay is empty, or the last element, we can just delete it
+ if (delayEvent.duration === 0 || delayIndex === rundown.length - 1) {
+ return deleteAtIndex(delayIndex, rundown);
}
- 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;
+
+ for (let i = delayIndex + 1; i < updatedRundown.length; i++) {
+ const currentEntry = updatedRundown[i];
+
+ // we don't do operation on other event types
+ if (!isOntimeEvent(currentEntry)) {
+ continue;
+ }
+
+ // 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 (isOntimeBlock(currentEvent)) {
- break;
- } else if (isOntimeEvent(currentEvent)) {
- currentEvent.timeStart = Math.max(0, currentEvent.timeStart + delayValue);
- currentEvent.timeEnd = Math.max(currentEvent.duration, currentEvent.timeEnd + delayValue);
- if (currentEvent.delay) {
- currentEvent.delay = currentEvent.delay - delayValue;
+ 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;
}
- currentEvent.revision += 1;
}
+
+ // 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);
diff --git a/package.json b/package.json
index 0cf1b9fc17..a5b65faf8b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ontime",
- "version": "3.6.0",
+ "version": "3.6.1",
"description": "Time keeping for live events",
"keywords": [
"ontime",