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",