Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 085e862be9 | |||
| 384a6298cf | |||
| 0398db9945 | |||
| 2560b0ca2a | |||
| 2e0c86c20a | |||
| 61742d1549 | |||
| 220319be8a | |||
| ea5de337cf | |||
| a08ea3fee9 | |||
| 1de062a38e | |||
| eb7a30fcf3 | |||
| e2cbe43a2b | |||
| 7877af571f | |||
| 086bd5b644 |
@@ -40,9 +40,6 @@ dist/
|
||||
ontime-db
|
||||
ontime-external/
|
||||
|
||||
# working database
|
||||
apps/server/src/preloaded-db/db.json
|
||||
|
||||
# versioning file
|
||||
**/ONTIME_VERSION.js
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
- 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>
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -105,23 +105,19 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Editor
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
|
||||
<IoLockClosedOutline />
|
||||
Cuesheet
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</ClientLink>
|
||||
<ClientLink to='op' current={location.pathname === '/op'}>
|
||||
<IoLockClosedOutline />
|
||||
Operator
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</ClientLink>
|
||||
<hr className={style.separator} />
|
||||
{navigatorConstants.map((route) => (
|
||||
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
|
||||
{route.label}
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</ClientLink>
|
||||
))}
|
||||
</DrawerBody>
|
||||
@@ -147,6 +143,7 @@ function ClientLink(props: PropsWithChildren<ClientLinkProps>) {
|
||||
return (
|
||||
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
|
||||
{children}
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
export default function useElectronEvent() {
|
||||
const isElectron = window?.process?.type === 'renderer';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const sendToElectron = (channel: string, args?: string | Record<string, unknown>) => {
|
||||
if (isElectron) {
|
||||
window?.ipcRenderer.send(channel, args);
|
||||
const isElectron = window.process?.type === 'renderer';
|
||||
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
|
||||
|
||||
export default function useElectronEvent() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const sendToElectron = useCallback((channel: string, args?: string | Record<string, unknown>) => {
|
||||
if (isElectron && ipcRenderer) {
|
||||
ipcRenderer.send(channel, args);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// listen to requests to change the editor location
|
||||
useEffect(() => {
|
||||
if (isElectron) {
|
||||
ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => {
|
||||
navigate(location, { relative: 'route' });
|
||||
});
|
||||
}
|
||||
|
||||
// Clean the listener after the component is dismounted
|
||||
return () => {
|
||||
ipcRenderer?.removeAllListeners();
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
return { isElectron, sendToElectron };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChangeEvent, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Button, Input } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
|
||||
@@ -13,14 +14,17 @@ import ProjectList from './ProjectList';
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
export default function ManageProjects() {
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState<'import' | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isCreatingProject = searchParams.get('create') === 'true';
|
||||
|
||||
const handleToggleCreate = () => {
|
||||
setIsCreatingProject((prev) => !prev);
|
||||
searchParams.set('create', isCreatingProject ? 'false' : 'true');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
const handleSelectFile = () => {
|
||||
@@ -49,7 +53,8 @@ export default function ManageProjects() {
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setIsCreatingProject(false);
|
||||
searchParams.delete('create');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
</div>
|
||||
</Panel.Title>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<div className={style.innerColumn}>
|
||||
<Panel.Section className={style.innerColumn}>
|
||||
<label>
|
||||
Project title
|
||||
<Input
|
||||
@@ -145,7 +145,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
{...register('backstageUrl')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Panel.Section>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
</div>
|
||||
</Panel.Title>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<div className={style.innerColumn}>
|
||||
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
|
||||
<Panel.Description>
|
||||
Select partial data from {`"${fileName}"`} to merge into the current project.
|
||||
<br /> This process is irreversible.
|
||||
@@ -121,7 +122,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
|
||||
<Switch variant='ontime' {...register('http')} />
|
||||
HTTP Integration
|
||||
</label>
|
||||
</div>
|
||||
</Panel.Section>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,9 +53,11 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.inlineLabels {
|
||||
label {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<th ref={setNodeRef} style={dragStyle} colSpan={colSpan}>
|
||||
<div {...attributes} {...listeners}>
|
||||
@@ -41,7 +37,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
|
||||
onMouseDown: header.getResizeHandler(),
|
||||
onTouchStart: header.getResizeHandler(),
|
||||
}}
|
||||
className={resizerClasses}
|
||||
className={styles.resizer}
|
||||
/>
|
||||
</th>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const navigatorConstants = [
|
||||
{ url: 'timer', label: 'Timer' },
|
||||
{ url: 'clock', label: 'Clock' },
|
||||
{ url: 'minimal', label: 'Minimal Timer' },
|
||||
{ url: 'clock', label: 'Wall Clock' },
|
||||
{ url: 'backstage', label: 'Backstage' },
|
||||
{ url: 'timeline', label: 'Timeline (beta)' },
|
||||
{ url: 'public', label: 'Public' },
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"timer"
|
||||
],
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "main.js",
|
||||
"main": "src/main.js",
|
||||
"devDependencies": {
|
||||
"electron": "^31.2.0",
|
||||
"electron-builder": "^24.13.3",
|
||||
@@ -77,7 +77,7 @@
|
||||
},
|
||||
"files": [
|
||||
"**/*",
|
||||
"assets/",
|
||||
"src/assets/",
|
||||
"!**/{yarn.lock,yarn-error.log}",
|
||||
"!**/{pnpm-lock.yaml}",
|
||||
"!**/{test,tests,__test__,__tests__}",
|
||||
@@ -85,7 +85,7 @@
|
||||
"!*{.spec.js,*.test.js,*.spec.ts,.test.ts}"
|
||||
],
|
||||
"directories": {
|
||||
"buildResources": "./assets/"
|
||||
"buildResources": "./src/assets/"
|
||||
},
|
||||
"extraResources": [
|
||||
{
|
||||
@@ -112,13 +112,6 @@
|
||||
"!*{.spec.js,*.test.js,*.spec.ts,.test.ts}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../server/src/preloaded-db/",
|
||||
"to": "extraResources/preloaded-db/",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../server/src/external/",
|
||||
"to": "extraResources/external/",
|
||||
|
||||
|
Before Width: | Height: | Size: 567 B After Width: | Height: | Size: 567 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
@@ -7,7 +7,7 @@ module.exports = {
|
||||
production: (port = 4001) => `http://localhost:${port}`,
|
||||
},
|
||||
server: {
|
||||
pathToEntrypoint: '../extraResources/server/index.cjs',
|
||||
pathToEntrypoint: '../../extraResources/server/index.cjs',
|
||||
},
|
||||
assets: {
|
||||
pathToAssets: './assets/',
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* This file contains a list of constants that may need to be resolved at runtime
|
||||
*/
|
||||
const path = require('path');
|
||||
|
||||
const { version } = require('../package.json');
|
||||
|
||||
const electronConfig = require('./electron.config.js');
|
||||
|
||||
// external links
|
||||
const linkToDocs = 'https://docs.getontime.no/';
|
||||
const linkToGitHub = 'https://github.com/cpvalente/ontime';
|
||||
const linkToDiscord = 'https://discord.com/invite/eje3CSUEXm';
|
||||
|
||||
// environment and platform constants
|
||||
const env = process.env.NODE_ENV || 'production';
|
||||
const isProduction = env === 'production';
|
||||
const isMac = process.platform === 'darwin';
|
||||
const isWindows = process.platform === 'win32';
|
||||
const isLinux = process.platform === 'linux';
|
||||
|
||||
const releaseTag = `v${version}`;
|
||||
|
||||
/** path to server directory */
|
||||
const nodePath = isProduction
|
||||
? path.join(__dirname, electronConfig.server.pathToEntrypoint)
|
||||
: path.join(__dirname, '../../server/dist/index.cjs');
|
||||
|
||||
/**
|
||||
* Resolves correct URL for client
|
||||
* @param {number | undefined} port - the port at which the server is running
|
||||
* @returns {string}
|
||||
*/
|
||||
const getClientUrl = (port) =>
|
||||
isProduction ? electronConfig.reactAppUrl.production(port) : electronConfig.reactAppUrl.development(port);
|
||||
|
||||
/**
|
||||
* Resolves correct URL for server
|
||||
* @param {number | undefined} port - the port at which the server is running
|
||||
* @returns {string}
|
||||
*/
|
||||
const getServerUrl = (port) => `http://localhost:${port}`;
|
||||
|
||||
/** Resolves URL path to download resources */
|
||||
const downloadPath = '/data/db/';
|
||||
|
||||
/**
|
||||
* @description Returns public path depending on OS
|
||||
* This is the correct path for the app running in production mode
|
||||
*/
|
||||
function getAppDataPath() {
|
||||
if (isMac) {
|
||||
return path.join(process.env.HOME, 'Library', 'Application Support', 'Ontime');
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
return path.join(process.env.APPDATA, 'Ontime');
|
||||
}
|
||||
|
||||
if (isLinux) {
|
||||
return path.join(process.env.HOME, '.Ontime');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
const projectsPath = path.join(getAppDataPath(), 'projects');
|
||||
const corruptProjectsPath = path.join(getAppDataPath(), 'corrupt files');
|
||||
const crashLogPath = path.join(getAppDataPath(), 'crash logs');
|
||||
const stylesPath = path.join(getAppDataPath(), 'styles');
|
||||
const externalPath = path.join(getAppDataPath(), 'external');
|
||||
|
||||
/** path to tray icon */
|
||||
const trayIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'background.png');
|
||||
/** path to app icon directory */
|
||||
const appIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'logo.png');
|
||||
|
||||
module.exports = {
|
||||
linkToDocs,
|
||||
linkToGitHub,
|
||||
linkToDiscord,
|
||||
env,
|
||||
isProduction,
|
||||
isMac,
|
||||
isWindows,
|
||||
releaseTag,
|
||||
nodePath,
|
||||
getClientUrl,
|
||||
getServerUrl,
|
||||
projectsPath,
|
||||
corruptProjectsPath,
|
||||
crashLogPath,
|
||||
stylesPath,
|
||||
externalPath,
|
||||
downloadPath,
|
||||
trayIcon,
|
||||
appIcon,
|
||||
};
|
||||
@@ -1,18 +1,20 @@
|
||||
const { app, BrowserWindow, Menu, globalShortcut, Tray, dialog, ipcMain, shell, Notification } = require('electron');
|
||||
const path = require('path');
|
||||
const electronConfig = require('./electron.config');
|
||||
const { version } = require('./package.json');
|
||||
const { getApplicationMenu } = require('./src/menu/applicationMenu.js');
|
||||
|
||||
const env = process.env.NODE_ENV || 'production';
|
||||
const isProduction = env === 'production';
|
||||
const isMac = process.platform === 'darwin';
|
||||
const isWindows = process.platform === 'win32';
|
||||
const { getApplicationMenu } = require('./menu/applicationMenu.js');
|
||||
const { getTrayMenu } = require('./menu/trayMenu.js');
|
||||
|
||||
// path to server
|
||||
const nodePath = isProduction
|
||||
? path.join(__dirname, electronConfig.server.pathToEntrypoint)
|
||||
: path.join(__dirname, '../server/dist/index.cjs');
|
||||
const electronConfig = require('./electron.config.js');
|
||||
const {
|
||||
env,
|
||||
isProduction,
|
||||
isWindows,
|
||||
nodePath,
|
||||
getClientUrl,
|
||||
trayIcon,
|
||||
appIcon,
|
||||
getServerUrl,
|
||||
} = require('./external.js');
|
||||
|
||||
if (!isProduction) {
|
||||
console.log(`Electron running in ${env} environment`);
|
||||
@@ -20,10 +22,13 @@ if (!isProduction) {
|
||||
process.traceProcessWarnings = true;
|
||||
}
|
||||
|
||||
// path to icons
|
||||
const trayIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'background.png');
|
||||
const appIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'logo.png');
|
||||
let loaded = 'Nothing loaded';
|
||||
/** Flag holds server loading state */
|
||||
let loaded = 'Ontime running';
|
||||
|
||||
/**
|
||||
* Flag whether user has requested a quit
|
||||
* Used to coordinate window closes without exit
|
||||
*/
|
||||
let isQuitting = false;
|
||||
|
||||
// initialise
|
||||
@@ -56,13 +61,13 @@ async function startBackend() {
|
||||
|
||||
/**
|
||||
* @description utility function to create a notification
|
||||
* @param title
|
||||
* @param text
|
||||
* @param {string} title - Notification title
|
||||
* @param {string} body - Notification body
|
||||
*/
|
||||
function showNotification(title, text) {
|
||||
function showNotification(title, body) {
|
||||
new Notification({
|
||||
title,
|
||||
body: text,
|
||||
body,
|
||||
silent: true,
|
||||
}).show();
|
||||
}
|
||||
@@ -108,6 +113,14 @@ function escalateError(error) {
|
||||
dialog.showErrorBox('An unrecoverable error occurred', error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows electron to ask react app redirect
|
||||
* @param string location
|
||||
*/
|
||||
function redirectWindow(location) {
|
||||
win.webContents.send('request-editor-location', location);
|
||||
}
|
||||
|
||||
// Ensure there isn't another instance of the app running already
|
||||
const lock = app.requestSingleInstanceLock();
|
||||
if (!lock) {
|
||||
@@ -141,7 +154,7 @@ function createWindow() {
|
||||
skipTaskbar: true,
|
||||
});
|
||||
splash.setIgnoreMouseEvents(true);
|
||||
const splashPath = path.join('file://', __dirname, '/src/splash/splash.html');
|
||||
const splashPath = path.join('file://', __dirname, '/splash/splash.html');
|
||||
splash.loadURL(splashPath);
|
||||
|
||||
win = new BrowserWindow({
|
||||
@@ -156,7 +169,7 @@ function createWindow() {
|
||||
enableWebSQL: false,
|
||||
darkTheme: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, './src/preload.js'),
|
||||
preload: path.join(__dirname, './preload.js'),
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
},
|
||||
@@ -173,25 +186,13 @@ 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
|
||||
const clientUrl = isProduction
|
||||
? electronConfig.reactAppUrl.production(port)
|
||||
: electronConfig.reactAppUrl.development(port);
|
||||
|
||||
const template = getApplicationMenu(isMac, askToQuit, clientUrl, `v${version}`, (path) => {
|
||||
win.loadURL(`${clientUrl}/${path}`);
|
||||
});
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
const clientUrl = getClientUrl(port);
|
||||
const serverUrl = getServerUrl(port);
|
||||
const menu = getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, (url) =>
|
||||
win.webContents.downloadURL(url),
|
||||
);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
win
|
||||
@@ -236,13 +237,9 @@ app.whenReady().then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// create tray
|
||||
// create tray and set its context menu
|
||||
tray = new Tray(trayIcon);
|
||||
|
||||
// Define context menu
|
||||
const { getTrayMenu } = require('./src/menu/trayMenu.js');
|
||||
const trayMenuTemplate = getTrayMenu(bringToFront, askToQuit);
|
||||
const trayContextMenu = Menu.buildFromTemplate(trayMenuTemplate);
|
||||
const trayContextMenu = getTrayMenu(bringToFront, askToQuit);
|
||||
tray.setContextMenu(trayContextMenu);
|
||||
});
|
||||
|
||||
@@ -268,7 +265,7 @@ ipcMain.on('shutdown', () => {
|
||||
/**
|
||||
* Handles requests to set window properties
|
||||
*/
|
||||
ipcMain.on('set-window', (event, arg) => {
|
||||
ipcMain.on('set-window', (_event, arg) => {
|
||||
switch (arg) {
|
||||
case 'show-dev':
|
||||
win.webContents.openDevTools({ mode: 'detach' });
|
||||
@@ -281,6 +278,10 @@ ipcMain.on('set-window', (event, arg) => {
|
||||
/**
|
||||
* Handles requests to open external links
|
||||
*/
|
||||
ipcMain.on('send-to-link', (event, arg) => {
|
||||
ipcMain.on('send-to-link', (_event, arg) => {
|
||||
try {
|
||||
shell.openExternal(arg);
|
||||
} catch (_error) {
|
||||
/** unhandled error */
|
||||
}
|
||||
});
|
||||
@@ -1,185 +1,312 @@
|
||||
const { shell } = require('electron');
|
||||
const { Menu, shell } = require('electron');
|
||||
|
||||
const {
|
||||
linkToGitHub,
|
||||
linkToDocs,
|
||||
linkToDiscord,
|
||||
isProduction,
|
||||
isMac,
|
||||
releaseTag,
|
||||
projectsPath,
|
||||
corruptProjectsPath,
|
||||
crashLogPath,
|
||||
stylesPath,
|
||||
externalPath,
|
||||
downloadPath,
|
||||
} = require('../external');
|
||||
|
||||
/**
|
||||
* Build description of application menu
|
||||
* @param {boolean} isMac - Whether the target platform is mac
|
||||
* Creates the application menu
|
||||
* @param {function} askToQuit - function for quitting process
|
||||
* @param {string} clientUrl - base url for the application
|
||||
* @param {string} serverUrl - base url for the application
|
||||
* @param {function} redirectWindow - function to redirect main window content
|
||||
* @param {function} download - function to download a resource from url
|
||||
* @returns {Menu} - application menu
|
||||
*/
|
||||
function getApplicationMenu(isMac, askToQuit, urlBase, version, redirectWindow) {
|
||||
return [
|
||||
...(isMac
|
||||
? [
|
||||
{
|
||||
function getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, download) {
|
||||
const template = [
|
||||
...(isMac ? [makeMacMenu(askToQuit)] : []),
|
||||
makeFileMenu(serverUrl, redirectWindow, download),
|
||||
makeViewMenu(clientUrl),
|
||||
makeSettingsMenu(redirectWindow),
|
||||
makeHelpMenu(redirectWindow),
|
||||
...(isProduction ? [] : [{ label: 'Dev', submenu: [{ role: 'toggleDevTools' }] }]),
|
||||
];
|
||||
return Menu.buildFromTemplate(template);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function generates the app menu (macOS only)
|
||||
* @param {function} askToQuit - function for quitting process
|
||||
* @returns {Object}
|
||||
*/
|
||||
function makeMacMenu(askToQuit) {
|
||||
return {
|
||||
label: 'Ontime',
|
||||
submenu: [
|
||||
{ role: 'about' },
|
||||
{ role: 'about', label: 'About Ontime' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' },
|
||||
{ role: 'hide', label: 'Hide Ontime' },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => askToQuit(),
|
||||
accelerator: 'Cmd+Q',
|
||||
accelerator: isMac ? 'Cmd+Q' : 'Alt+F4',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function generates the file menu
|
||||
* @param {string} serverUrl - base url for the application
|
||||
* @param {function} redirectWindow - function to redirect main window content
|
||||
* @param {function} download - function to download a resource from url
|
||||
* @returns {Object}
|
||||
*/
|
||||
function makeFileMenu(serverUrl, redirectWindow, download) {
|
||||
const downloadProject = () => {
|
||||
try {
|
||||
download(serverUrl + downloadPath);
|
||||
} catch (_error) {
|
||||
/** unhandled error */
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
label: 'File',
|
||||
submenu: [isMac ? { role: 'close' } : { role: 'quit' }],
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ role: 'undo' },
|
||||
{ role: 'redo' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
...(isMac
|
||||
? [
|
||||
{ role: 'pasteAndMatchStyle' },
|
||||
{ role: 'delete' },
|
||||
{ role: 'selectAll' },
|
||||
{
|
||||
label: 'New project...',
|
||||
click: () => redirectWindow('/editor?settings=project__manage&create=true'),
|
||||
},
|
||||
{
|
||||
label: 'Edit project info',
|
||||
click: () => redirectWindow('/editor?settings=project__data'),
|
||||
},
|
||||
{
|
||||
label: 'Manage projects...',
|
||||
click: () => redirectWindow('/editor?settings=project__manage'),
|
||||
},
|
||||
{
|
||||
label: 'Download project',
|
||||
click: downloadProject,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Speech',
|
||||
submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }],
|
||||
},
|
||||
]
|
||||
: [{ role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }]),
|
||||
label: 'Open directory',
|
||||
submenu: [
|
||||
makeItemOpenInDesktop('Projects', projectsPath),
|
||||
makeItemOpenInDesktop('Corrupted projects', corruptProjectsPath),
|
||||
makeItemOpenInDesktop('Crash logs', crashLogPath),
|
||||
makeItemOpenInDesktop('CSS override', stylesPath),
|
||||
makeItemOpenInDesktop('External', externalPath),
|
||||
],
|
||||
},
|
||||
{
|
||||
{ type: 'separator' },
|
||||
{ role: isMac ? 'close' : 'quit' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function generates the views menu
|
||||
* @param {string} clientUrl - base url for the application
|
||||
* @returns {Object}
|
||||
*/
|
||||
function makeViewMenu(clientUrl) {
|
||||
return {
|
||||
label: 'Views',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Ontime Views (opens in browser)',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Public',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/public`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Lower Thirds',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/lower`);
|
||||
},
|
||||
},
|
||||
makeItemOpenInBrowser('Public', `${clientUrl}/public`),
|
||||
makeItemOpenInBrowser('Lower Thirds', `${clientUrl}/lower`),
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Timer',
|
||||
accelerator: 'CmdOrCtrl+V',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/timer`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Clock',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/clock`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Minimal Timer',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/minimal`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Backstage',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/backstage`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Timeline (beta)',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/timeline`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Studio Clock',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/studio`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Countdown',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/countdown`);
|
||||
},
|
||||
},
|
||||
makeItemOpenInBrowser('Timer', `${clientUrl}/timer`),
|
||||
makeItemOpenInBrowser('Minimal Timer', `${clientUrl}/minimal`),
|
||||
makeItemOpenInBrowser('Clock', `${clientUrl}/clock`),
|
||||
makeItemOpenInBrowser('Backstage', `${clientUrl}/backstage`),
|
||||
makeItemOpenInBrowser('Timeline (beta)', `${clientUrl}/timeline`),
|
||||
makeItemOpenInBrowser('Studio Clock', `${clientUrl}/studio`),
|
||||
makeItemOpenInBrowser('Countdown', `${clientUrl}/countdown`),
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Editor',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/editor`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Cuesheet',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/cuesheet`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Operator',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/op`);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
makeItemOpenInBrowser('Editor', `${clientUrl}/editor`),
|
||||
makeItemOpenInBrowser('Cuesheet', `${clientUrl}/cuesheet`),
|
||||
makeItemOpenInBrowser('Operator', `${clientUrl}/op`),
|
||||
|
||||
{ type: 'separator' },
|
||||
{ role: 'forceReload' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Window',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function generates the settings menu
|
||||
* @param {function} redirectWindow - function to redirect main window content
|
||||
* @returns {Object}
|
||||
*/
|
||||
function makeSettingsMenu(redirectWindow) {
|
||||
return {
|
||||
label: 'Settings',
|
||||
submenu: [
|
||||
{ role: 'minimize' },
|
||||
{ role: 'zoom' },
|
||||
...(isMac
|
||||
? [{ type: 'separator' }, { role: 'front' }, { type: 'separator' }, { role: 'window' }]
|
||||
: [{ role: 'close' }]),
|
||||
{
|
||||
label: 'Open Settings',
|
||||
accelerator: 'CommandOrControl+,',
|
||||
click: () => redirectWindow('/editor?settings=project'),
|
||||
},
|
||||
{
|
||||
label: 'Project',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Project data',
|
||||
click: () => redirectWindow('/editor?settings=project__data'),
|
||||
},
|
||||
{
|
||||
label: 'Manage projects',
|
||||
click: () => redirectWindow('/editor?settings=project__manage'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'App Settings',
|
||||
submenu: [
|
||||
{
|
||||
label: 'General settings',
|
||||
click: () => redirectWindow('/editor?settings=general__settings'),
|
||||
},
|
||||
{
|
||||
label: 'Editor settings',
|
||||
click: () => redirectWindow('/editor?settings=general__editor'),
|
||||
},
|
||||
{
|
||||
label: 'View settings',
|
||||
click: () => redirectWindow('/editor?settings=general__view'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Feature Settings',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Custom fields',
|
||||
click: () => redirectWindow('/editor?settings=feature_settings__custom'),
|
||||
},
|
||||
{
|
||||
label: 'URL presets',
|
||||
click: () => redirectWindow('/editor?settings=feature_settings__urlpresets'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Data Sources',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Import spreadsheet',
|
||||
click: () => redirectWindow('/editor?settings=sources__xlsx'),
|
||||
},
|
||||
{
|
||||
label: 'Sync with Google Sheet',
|
||||
click: () => redirectWindow('/editor?settings=sources__gsheet'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Integrations',
|
||||
submenu: [
|
||||
{
|
||||
label: 'OSC settings',
|
||||
click: () => redirectWindow('/editor?settings=integrations__osc'),
|
||||
},
|
||||
{
|
||||
label: 'HTTP settings',
|
||||
click: () => redirectWindow('/editor?settings=integrations__http'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Network',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Event log',
|
||||
click: () => redirectWindow('/editor?settings=network__log'),
|
||||
},
|
||||
{
|
||||
label: 'Manage cleints',
|
||||
click: () => redirectWindow('/editor?settings=network__clients'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function generates the help menu
|
||||
* @param {function} redirectWindow - function to redirect main window content
|
||||
* @returns {Object}
|
||||
*/
|
||||
function makeHelpMenu(redirectWindow) {
|
||||
return {
|
||||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'About',
|
||||
click: () => redirectWindow('editor?settings=about'),
|
||||
label: `Ontime ${releaseTag}`,
|
||||
click: () => redirectWindow('/editor?settings=about'),
|
||||
},
|
||||
{
|
||||
label: version,
|
||||
click: () => redirectWindow('editor?settings=about'),
|
||||
},
|
||||
{
|
||||
label: 'See on github',
|
||||
click: async () => {
|
||||
await shell.openExternal('https://github.com/cpvalente/ontime');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Online documentation',
|
||||
click: async () => {
|
||||
await shell.openExternal('https://docs.getontime.no/');
|
||||
},
|
||||
type: 'separator',
|
||||
},
|
||||
makeItemOpenInBrowser('See on github', linkToGitHub),
|
||||
makeItemOpenInBrowser('Online documentation', linkToDocs),
|
||||
makeItemOpenInBrowser('Join us on Discord', linkToDiscord),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to safely open a URL in the default browser
|
||||
* @param {string} label
|
||||
* @param {string} url
|
||||
* @returns {object} - MenuItem
|
||||
*/
|
||||
function makeItemOpenInBrowser(label, url) {
|
||||
return {
|
||||
label: `${label} ↗`,
|
||||
click: async () => {
|
||||
try {
|
||||
await shell.openExternal(url);
|
||||
} catch (_error) {
|
||||
/** unhandled error */
|
||||
}
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to open a file in the OS explorer / finder
|
||||
* @param {string} label
|
||||
* @param {string} path
|
||||
* @returns {object} - MenuItem
|
||||
*/
|
||||
function makeItemOpenInDesktop(label, path) {
|
||||
return {
|
||||
label,
|
||||
click: () => {
|
||||
try {
|
||||
shell.openPath(path);
|
||||
} catch (_error) {
|
||||
/** unhandled error */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getApplicationMenu };
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
const { Menu } = require('electron');
|
||||
|
||||
/**
|
||||
* Build description of tray context menu
|
||||
* Creates the application tray menu
|
||||
* @param {function} showApp - function for making the window visible
|
||||
* @param {function} askToQuit - function for quitting process
|
||||
* @returns {Menu} - application tray menu
|
||||
*/
|
||||
function getTrayMenu(showApp, askToQuit) {
|
||||
return [
|
||||
return Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Show App',
|
||||
click: () => showApp(),
|
||||
click: showApp,
|
||||
},
|
||||
{
|
||||
label: 'Shutdown',
|
||||
click: () => askToQuit(),
|
||||
click: askToQuit,
|
||||
},
|
||||
];
|
||||
]);
|
||||
}
|
||||
|
||||
module.exports = { getTrayMenu };
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<img src="../../assets/logo.png" />
|
||||
<img src="../assets/logo.png" />
|
||||
<h1>ontime · event timers</h1>
|
||||
<div class="lds-ellipsis">
|
||||
<div></div>
|
||||
|
||||
@@ -58,6 +58,19 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows downloading of current project file
|
||||
*/
|
||||
export async function currentProjectDownload(_req: Request, res: Response) {
|
||||
const { filename, pathToFile } = await projectService.getCurrentProject();
|
||||
res.download(pathToFile, filename, (error) => {
|
||||
if (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(500).send({ message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows downloading of project files
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ import express from 'express';
|
||||
|
||||
import {
|
||||
createProjectFile,
|
||||
currentProjectDownload,
|
||||
deleteProjectFile,
|
||||
duplicateProjectFile,
|
||||
getInfo,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
router.get('/', currentProjectDownload);
|
||||
router.post('/download', validateFilenameBody, projectDownload);
|
||||
router.post('/upload', uploadProjectFile, postProjectFile);
|
||||
|
||||
|
||||
@@ -51,6 +51,13 @@ function init() {
|
||||
ensureDirectory(resolveCorruptDirectory);
|
||||
}
|
||||
|
||||
export async function getCurrentProject() {
|
||||
const filename = await getLastLoadedProject();
|
||||
const pathToFile = getPathToProject(filename);
|
||||
|
||||
return { filename, pathToFile };
|
||||
}
|
||||
|
||||
/**
|
||||
* Private function loads a demo project
|
||||
* to be composed in the loading functions
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* Small utility to fill in the necessary data for the test
|
||||
*/
|
||||
function makeOntimeEvent(event: Partial<OntimeEvent>): 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()', () => {
|
||||
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,
|
||||
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 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);
|
||||
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 delayId = '1';
|
||||
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 = [
|
||||
{ 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,
|
||||
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: '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,
|
||||
{ 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(delayId, testRundown);
|
||||
expect(updatedRundown).toStrictEqual(expected);
|
||||
const updatedRundown = apply('delay', testRundown);
|
||||
expect(updatedRundown).toMatchObject(expected);
|
||||
});
|
||||
it('maintains constant duration', () => {
|
||||
const delayId = '1';
|
||||
|
||||
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,
|
||||
]);
|
||||
});
|
||||
|
||||
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 = [
|
||||
{ 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,
|
||||
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 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('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' },
|
||||
]);
|
||||
});
|
||||
|
||||
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';
|
||||
it('gaps reduce positive delay (2)', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
{ id: delayId, type: SupportedEvent.Delay, duration: 10 } as OntimeDelay,
|
||||
{
|
||||
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',
|
||||
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,
|
||||
timeStart: 54000000, // 15:00:00
|
||||
timeEnd: 57600000, // 16:00:00
|
||||
duration: MILLIS_PER_HOUR,
|
||||
}),
|
||||
];
|
||||
|
||||
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);
|
||||
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('applies negative delays', () => {
|
||||
const delayId = '1';
|
||||
|
||||
it('removes empty delays without applying changes', () => {
|
||||
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,
|
||||
makeOntimeDelay(0),
|
||||
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
|
||||
];
|
||||
|
||||
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);
|
||||
const updatedRundown = apply('delay', testRundown);
|
||||
expect(updatedRundown).toMatchObject([{ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }]);
|
||||
});
|
||||
it('maintains constant duration', () => {
|
||||
const delayId = '1';
|
||||
|
||||
it('removes delays in last position without applying changes', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
{ id: delayId, type: SupportedEvent.Delay, duration: -30 } as OntimeDelay,
|
||||
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: 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,
|
||||
timeStart: 150,
|
||||
timeEnd: 200,
|
||||
duration: 50,
|
||||
linkStart: null,
|
||||
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);
|
||||
});
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
if (isOntimeBlock(currentEvent)) {
|
||||
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 (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;
|
||||
} 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;
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -6,18 +6,18 @@ test.describe('test view navigation feature', () => {
|
||||
await page.goto('http://localhost:4001/');
|
||||
await page.locator('data-test-id=timer-view');
|
||||
|
||||
await page.getByRole('button', { name: 'toggle menu' }).click();
|
||||
await page.locator('data-test-id=navigation__menu');
|
||||
await page.getByRole('link', { name: 'Clock', exact: true }).click();
|
||||
await page.locator('data-test-id=clock-view');
|
||||
await expect(page).toHaveURL('http://localhost:4001/clock');
|
||||
|
||||
await page.getByRole('button', { name: 'toggle menu' }).click();
|
||||
await page.locator('data-test-id=navigation__menu');
|
||||
await page.getByRole('link', { name: 'Minimal Timer' }).click();
|
||||
await page.locator('data-test-id=minimal-timer');
|
||||
await expect(page).toHaveURL('http://localhost:4001/minimal');
|
||||
|
||||
await page.getByRole('button', { name: 'toggle menu' }).click();
|
||||
await page.locator('data-test-id=navigation__menu');
|
||||
await page.getByRole('link', { name: 'Wall Clock', exact: true }).click();
|
||||
await page.locator('data-test-id=clock-view');
|
||||
await expect(page).toHaveURL('http://localhost:4001/clock');
|
||||
|
||||
await page.getByRole('button', { name: 'toggle menu' }).click();
|
||||
await page.locator('data-test-id=navigation__menu');
|
||||
await page.getByRole('link', { name: 'Backstage' }).click();
|
||||
|
||||