Compare commits

..

14 Commits

Author SHA1 Message Date
Carlos Valente 085e862be9 refactor: show electron dev tools 2024-10-06 21:32:29 +02:00
Carlos Valente 384a6298cf refactor: arrow used in new window navigation 2024-10-06 21:32:08 +02:00
Carlos Valente 0398db9945 refactor: navigate in electron 2024-10-06 21:21:16 +02:00
Carlos Valente 2560b0ca2a refactor: remove unused db 2024-10-06 20:34:58 +02:00
Carlos Valente 2e0c86c20a feat: open asset directories 2024-10-06 20:34:58 +02:00
Carlos Valente 61742d1549 refactor: collocate view navigation 2024-10-06 20:34:58 +02:00
Carlos Valente 220319be8a refactor: improve native menu links 2024-10-06 20:34:54 +02:00
Carlos Valente ea5de337cf feat: allow download of current project 2024-10-03 22:28:47 +02:00
Carlos Valente a08ea3fee9 refactor: create project form state from params 2024-10-03 22:28:47 +02:00
Carlos Valente 1de062a38e refactor: remove alt shortcut 2024-10-03 08:35:56 +02:00
Carlos Valente eb7a30fcf3 refactor: improve resizing of column 2024-10-02 22:34:19 +02:00
Carlos Valente e2cbe43a2b fix: form element alignment in form 2024-10-02 21:57:52 +02:00
Carlos Valente 7877af571f fix: apply delay 2024-10-02 21:44:03 +02:00
Carlos Valente 086bd5b644 docs: add discord link 2024-10-01 08:18:19 +02:00
32 changed files with 858 additions and 566 deletions
-3
View File
@@ -40,9 +40,6 @@ dist/
ontime-db ontime-db
ontime-external/ ontime-external/
# working database
apps/server/src/preloaded-db/db.json
# versioning file # versioning file
**/ONTIME_VERSION.js **/ONTIME_VERSION.js
+19 -18
View File
@@ -14,8 +14,10 @@
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a> - Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a> - Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
## Using Ontime? ## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
## Using Ontime?
Let us know! Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback. Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
@@ -35,6 +37,16 @@ Ontime is made by entertainment and broadcast engineers and used by
- Theatres and opera houses - Theatres and opera houses
- Houses of worship - Houses of worship
## Main features
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
... and a lot more ...
### For live environments ### For live environments
Ontime is designed for use in live environments. \ Ontime is designed for use in live environments. \
@@ -83,6 +95,7 @@ IP.ADDRESS:4001/clock > Simple clock view
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
IP.ADDRESS:4001/countdown > Countdown to anything IP.ADDRESS:4001/countdown > Countdown to anything
IP.ADDRESS:4001/studio > Studio Clock IP.ADDRESS:4001/studio > Studio Clock
IP.ADDRESS:4001/timeline > Timeline
``` ```
``` ```
@@ -100,26 +113,13 @@ IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/operator > automated views for operators IP.ADDRESS:4001/operator > automated views for operators
``` ```
More documentation is available [in our docs](https://docs.getontime.no) More information is available [in our docs](https://docs.getontime.no)
## Main features
- [x] Distribute data over network and render it in the browser
- [x] Collaborative
- [x] Extendable
- [x] Send messages to different screen types
- [x] Differentiate between backstage and public data
- [x] Workflow for managing delays
- [x] Rich protocol integrations for Control and Feedback
- [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
## Roadmap ## Roadmap
### Continued development ### Continued development
Ontime is under active development. We continue adding and tweaking features in collaboration with users. Ontime is under active development. We continue adding and improving features in collaboration with users.
Have an idea? Reach out via [email](mail@getontime.no) Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new) or [open an issue](https://github.com/cpvalente/ontime/issues/new)
@@ -161,8 +161,9 @@ If you are a developer and would like to contribute with code, please open an is
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md) Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
## Links ## Links
- [Ontime website](https://getontime.no)
See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no) - [Documentation](https://docs.getontime.no)
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
## License ## License
@@ -105,23 +105,19 @@ function NavigationMenu(props: NavigationMenuProps) {
> >
<IoLockClosedOutline /> <IoLockClosedOutline />
Editor Editor
<IoArrowUp className={style.linkIcon} />
</Link> </Link>
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}> <ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
<IoLockClosedOutline /> <IoLockClosedOutline />
Cuesheet Cuesheet
<IoArrowUp className={style.linkIcon} />
</ClientLink> </ClientLink>
<ClientLink to='op' current={location.pathname === '/op'}> <ClientLink to='op' current={location.pathname === '/op'}>
<IoLockClosedOutline /> <IoLockClosedOutline />
Operator Operator
<IoArrowUp className={style.linkIcon} />
</ClientLink> </ClientLink>
<hr className={style.separator} /> <hr className={style.separator} />
{navigatorConstants.map((route) => ( {navigatorConstants.map((route) => (
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}> <ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
{route.label} {route.label}
<IoArrowUp className={style.linkIcon} />
</ClientLink> </ClientLink>
))} ))}
</DrawerBody> </DrawerBody>
@@ -147,6 +143,7 @@ function ClientLink(props: PropsWithChildren<ClientLinkProps>) {
return ( return (
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}> <button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
{children} {children}
<IoArrowUp className={style.linkIcon} />
</button> </button>
); );
} }
@@ -1,11 +1,31 @@
export default function useElectronEvent() { import { useCallback, useEffect } from 'react';
const isElectron = window?.process?.type === 'renderer'; import { useNavigate } from 'react-router-dom';
const sendToElectron = (channel: string, args?: string | Record<string, unknown>) => { const isElectron = window.process?.type === 'renderer';
if (isElectron) { const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
window?.ipcRenderer.send(channel, args);
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 }; return { isElectron, sendToElectron };
} }
@@ -1,4 +1,5 @@
import { ChangeEvent, useRef, useState } from 'react'; import { ChangeEvent, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Button, Input } from '@chakra-ui/react'; import { Button, Input } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
@@ -13,14 +14,17 @@ import ProjectList from './ProjectList';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
export default function ManageProjects() { export default function ManageProjects() {
const [isCreatingProject, setIsCreatingProject] = useState(false); const [searchParams, setSearchParams] = useSearchParams();
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState<'import' | null>(null); const [loading, setLoading] = useState<'import' | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const isCreatingProject = searchParams.get('create') === 'true';
const handleToggleCreate = () => { const handleToggleCreate = () => {
setIsCreatingProject((prev) => !prev); searchParams.set('create', isCreatingProject ? 'false' : 'true');
setSearchParams(searchParams);
}; };
const handleSelectFile = () => { const handleSelectFile = () => {
@@ -49,7 +53,8 @@ export default function ManageProjects() {
}; };
const handleCloseForm = () => { const handleCloseForm = () => {
setIsCreatingProject(false); searchParams.delete('create');
setSearchParams(searchParams);
}; };
return ( return (
@@ -78,7 +78,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
</div> </div>
</Panel.Title> </Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>} {error && <Panel.Error>{error}</Panel.Error>}
<div className={style.innerColumn}> <Panel.Section className={style.innerColumn}>
<label> <label>
Project title Project title
<Input <Input
@@ -145,7 +145,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
</label> </label>
</div> </Panel.Section>
</Panel.Section> </Panel.Section>
); );
} }
@@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_DATA } from '../../../../common/api/constants'; import { PROJECT_DATA } from '../../../../common/api/constants';
import { getDb, patchData } from '../../../../common/api/db'; import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../PanelUtils'; import * as Panel from '../PanelUtils';
import { makeProjectPatch } from './project.utils'; import { makeProjectPatch } from './project.utils';
@@ -92,7 +93,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
</div> </div>
</Panel.Title> </Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>} {error && <Panel.Error>{error}</Panel.Error>}
<div className={style.innerColumn}> <Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
<Panel.Description> <Panel.Description>
Select partial data from {`"${fileName}"`} to merge into the current project. Select partial data from {`"${fileName}"`} to merge into the current project.
<br /> This process is irreversible. <br /> This process is irreversible.
@@ -121,7 +122,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
<Switch variant='ontime' {...register('http')} /> <Switch variant='ontime' {...register('http')} />
HTTP Integration HTTP Integration
</label> </label>
</div> </Panel.Section>
</Panel.Section> </Panel.Section>
); );
} }
@@ -53,9 +53,11 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1em; gap: 1em;
}
.inlineLabels {
label { label {
display: flex; display: flex;
gap: 1em; gap: 1rem;
} }
} }
@@ -50,9 +50,15 @@ $table-header-font-size: calc(1rem - 3px);
font-size: $table-header-font-size; font-size: $table-header-font-size;
color: $gray-700; color: $gray-700;
}
th { th {
background-color: $gray-1300; background-color: $gray-1300;
&:hover {
.resizer {
width: 0.5rem;
}
} }
} }
@@ -126,30 +132,19 @@ $table-header-font-size: calc(1rem - 3px);
.resizer { .resizer {
cursor: col-resize; cursor: col-resize;
opacity: 0; opacity: $opacity-disabled;
display: inline-block; display: inline-block;
width: 3px; width: 0;
height: 100%; height: 100%;
position: absolute; position: absolute;
right: 0; right: 0;
top: 0; top: 0;
transform: translateX(50%);
background-color: $action-blue; background-color: $action-blue;
user-select: none; user-select: none;
touch-action: none; touch-action: none;
transition-duration: $transition-time-action;
transition-property: width, background-color;
&:hover { &:hover {
opacity: $opacity-disabled;
width: 6px;
}
&.isResizing {
opacity: 1; opacity: 1;
width: 6px;
background-color: $action-blue;
} }
} }
@@ -4,8 +4,6 @@ import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table'; import { Header } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types'; import { OntimeRundownEntry } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils';
import styles from '../Cuesheet.module.scss'; import styles from '../Cuesheet.module.scss';
interface SortableCellProps { interface SortableCellProps {
@@ -29,8 +27,6 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
transition, transition,
}; };
const resizerClasses = cx([styles.resizer, header.column.getIsResizing() ? styles.isResizing : null]);
return ( return (
<th ref={setNodeRef} style={dragStyle} colSpan={colSpan}> <th ref={setNodeRef} style={dragStyle} colSpan={colSpan}>
<div {...attributes} {...listeners}> <div {...attributes} {...listeners}>
@@ -41,7 +37,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
onMouseDown: header.getResizeHandler(), onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(), onTouchStart: header.getResizeHandler(),
}} }}
className={resizerClasses} className={styles.resizer}
/> />
</th> </th>
); );
+1 -1
View File
@@ -1,7 +1,7 @@
export const navigatorConstants = [ export const navigatorConstants = [
{ url: 'timer', label: 'Timer' }, { url: 'timer', label: 'Timer' },
{ url: 'clock', label: 'Clock' },
{ url: 'minimal', label: 'Minimal Timer' }, { url: 'minimal', label: 'Minimal Timer' },
{ url: 'clock', label: 'Wall Clock' },
{ url: 'backstage', label: 'Backstage' }, { url: 'backstage', label: 'Backstage' },
{ url: 'timeline', label: 'Timeline (beta)' }, { url: 'timeline', label: 'Timeline (beta)' },
{ url: 'public', label: 'Public' }, { url: 'public', label: 'Public' },
+3 -10
View File
@@ -10,7 +10,7 @@
"timer" "timer"
], ],
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"main": "main.js", "main": "src/main.js",
"devDependencies": { "devDependencies": {
"electron": "^31.2.0", "electron": "^31.2.0",
"electron-builder": "^24.13.3", "electron-builder": "^24.13.3",
@@ -77,7 +77,7 @@
}, },
"files": [ "files": [
"**/*", "**/*",
"assets/", "src/assets/",
"!**/{yarn.lock,yarn-error.log}", "!**/{yarn.lock,yarn-error.log}",
"!**/{pnpm-lock.yaml}", "!**/{pnpm-lock.yaml}",
"!**/{test,tests,__test__,__tests__}", "!**/{test,tests,__test__,__tests__}",
@@ -85,7 +85,7 @@
"!*{.spec.js,*.test.js,*.spec.ts,.test.ts}" "!*{.spec.js,*.test.js,*.spec.ts,.test.ts}"
], ],
"directories": { "directories": {
"buildResources": "./assets/" "buildResources": "./src/assets/"
}, },
"extraResources": [ "extraResources": [
{ {
@@ -112,13 +112,6 @@
"!*{.spec.js,*.test.js,*.spec.ts,.test.ts}" "!*{.spec.js,*.test.js,*.spec.ts,.test.ts}"
] ]
}, },
{
"from": "../server/src/preloaded-db/",
"to": "extraResources/preloaded-db/",
"filter": [
"**/*"
]
},
{ {
"from": "../server/src/external/", "from": "../server/src/external/",
"to": "extraResources/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}`, production: (port = 4001) => `http://localhost:${port}`,
}, },
server: { server: {
pathToEntrypoint: '../extraResources/server/index.cjs', pathToEntrypoint: '../../extraResources/server/index.cjs',
}, },
assets: { assets: {
pathToAssets: './assets/', pathToAssets: './assets/',
+98
View File
@@ -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 { app, BrowserWindow, Menu, globalShortcut, Tray, dialog, ipcMain, shell, Notification } = require('electron');
const path = require('path'); 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 { getApplicationMenu } = require('./menu/applicationMenu.js');
const isProduction = env === 'production'; const { getTrayMenu } = require('./menu/trayMenu.js');
const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
// path to server const electronConfig = require('./electron.config.js');
const nodePath = isProduction const {
? path.join(__dirname, electronConfig.server.pathToEntrypoint) env,
: path.join(__dirname, '../server/dist/index.cjs'); isProduction,
isWindows,
nodePath,
getClientUrl,
trayIcon,
appIcon,
getServerUrl,
} = require('./external.js');
if (!isProduction) { if (!isProduction) {
console.log(`Electron running in ${env} environment`); console.log(`Electron running in ${env} environment`);
@@ -20,10 +22,13 @@ if (!isProduction) {
process.traceProcessWarnings = true; process.traceProcessWarnings = true;
} }
// path to icons /** Flag holds server loading state */
const trayIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'background.png'); let loaded = 'Ontime running';
const appIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'logo.png');
let loaded = 'Nothing loaded'; /**
* Flag whether user has requested a quit
* Used to coordinate window closes without exit
*/
let isQuitting = false; let isQuitting = false;
// initialise // initialise
@@ -56,13 +61,13 @@ async function startBackend() {
/** /**
* @description utility function to create a notification * @description utility function to create a notification
* @param title * @param {string} title - Notification title
* @param text * @param {string} body - Notification body
*/ */
function showNotification(title, text) { function showNotification(title, body) {
new Notification({ new Notification({
title, title,
body: text, body,
silent: true, silent: true,
}).show(); }).show();
} }
@@ -108,6 +113,14 @@ function escalateError(error) {
dialog.showErrorBox('An unrecoverable error occurred', 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 // Ensure there isn't another instance of the app running already
const lock = app.requestSingleInstanceLock(); const lock = app.requestSingleInstanceLock();
if (!lock) { if (!lock) {
@@ -141,7 +154,7 @@ function createWindow() {
skipTaskbar: true, skipTaskbar: true,
}); });
splash.setIgnoreMouseEvents(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); splash.loadURL(splashPath);
win = new BrowserWindow({ win = new BrowserWindow({
@@ -156,7 +169,7 @@ function createWindow() {
enableWebSQL: false, enableWebSQL: false,
darkTheme: true, darkTheme: true,
webPreferences: { webPreferences: {
preload: path.join(__dirname, './src/preload.js'), preload: path.join(__dirname, './preload.js'),
nodeIntegration: true, nodeIntegration: true,
contextIsolation: false, contextIsolation: false,
}, },
@@ -173,25 +186,13 @@ app.whenReady().then(() => {
} }
createWindow(); createWindow();
// register global shortcuts
// (available regardless of whether app is in focus)
// bring focus to window
globalShortcut.register('Alt+1', () => {
bringToFront();
});
startBackend() startBackend()
.then((port) => { .then((port) => {
// Load page served by node or use React dev run const clientUrl = getClientUrl(port);
const clientUrl = isProduction const serverUrl = getServerUrl(port);
? electronConfig.reactAppUrl.production(port) const menu = getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, (url) =>
: electronConfig.reactAppUrl.development(port); win.webContents.downloadURL(url),
);
const template = getApplicationMenu(isMac, askToQuit, clientUrl, `v${version}`, (path) => {
win.loadURL(`${clientUrl}/${path}`);
});
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu); Menu.setApplicationMenu(menu);
win win
@@ -236,13 +237,9 @@ app.whenReady().then(() => {
} }
}); });
// create tray // create tray and set its context menu
tray = new Tray(trayIcon); tray = new Tray(trayIcon);
const trayContextMenu = getTrayMenu(bringToFront, askToQuit);
// Define context menu
const { getTrayMenu } = require('./src/menu/trayMenu.js');
const trayMenuTemplate = getTrayMenu(bringToFront, askToQuit);
const trayContextMenu = Menu.buildFromTemplate(trayMenuTemplate);
tray.setContextMenu(trayContextMenu); tray.setContextMenu(trayContextMenu);
}); });
@@ -268,7 +265,7 @@ ipcMain.on('shutdown', () => {
/** /**
* Handles requests to set window properties * Handles requests to set window properties
*/ */
ipcMain.on('set-window', (event, arg) => { ipcMain.on('set-window', (_event, arg) => {
switch (arg) { switch (arg) {
case 'show-dev': case 'show-dev':
win.webContents.openDevTools({ mode: 'detach' }); win.webContents.openDevTools({ mode: 'detach' });
@@ -281,6 +278,10 @@ ipcMain.on('set-window', (event, arg) => {
/** /**
* Handles requests to open external links * Handles requests to open external links
*/ */
ipcMain.on('send-to-link', (event, arg) => { ipcMain.on('send-to-link', (_event, arg) => {
shell.openExternal(arg); try {
shell.openExternal(arg);
} catch (_error) {
/** unhandled error */
}
}); });
+305 -178
View File
@@ -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 * Creates the application menu
* @param {boolean} isMac - Whether the target platform is mac
* @param {function} askToQuit - function for quitting process * @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) { function getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, download) {
return [ const template = [
...(isMac ...(isMac ? [makeMacMenu(askToQuit)] : []),
? [ makeFileMenu(serverUrl, redirectWindow, download),
{ makeViewMenu(clientUrl),
label: 'Ontime', makeSettingsMenu(redirectWindow),
submenu: [ makeHelpMenu(redirectWindow),
{ role: 'about' }, ...(isProduction ? [] : [{ label: 'Dev', submenu: [{ role: 'toggleDevTools' }] }]),
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{
label: 'Quit',
click: () => askToQuit(),
accelerator: 'Cmd+Q',
},
],
},
]
: []),
{
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' },
{ type: 'separator' },
{
label: 'Speech',
submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }],
},
]
: [{ role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }]),
],
},
{
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`);
},
},
{ 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`);
},
},
{ 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`);
},
},
],
},
{ type: 'separator' },
{ role: 'forceReload' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
],
},
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
...(isMac
? [{ type: 'separator' }, { role: 'front' }, { type: 'separator' }, { role: 'window' }]
: [{ role: 'close' }]),
],
},
{
role: 'help',
submenu: [
{
label: 'About',
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/');
},
},
],
},
]; ];
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', label: 'About Ontime' },
{ type: 'separator' },
{ role: 'hide', label: 'Hide Ontime' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{
label: 'Quit',
click: () => askToQuit(),
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: [
{
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: '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: [
makeItemOpenInBrowser('Public', `${clientUrl}/public`),
makeItemOpenInBrowser('Lower Thirds', `${clientUrl}/lower`),
{ type: 'separator' },
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' },
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' },
],
};
}
/**
* Utility function generates the settings menu
* @param {function} redirectWindow - function to redirect main window content
* @returns {Object}
*/
function makeSettingsMenu(redirectWindow) {
return {
label: 'Settings',
submenu: [
{
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: `Ontime ${releaseTag}`,
click: () => redirectWindow('/editor?settings=about'),
},
{
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 }; module.exports = { getApplicationMenu };
+8 -5
View File
@@ -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} showApp - function for making the window visible
* @param {function} askToQuit - function for quitting process * @param {function} askToQuit - function for quitting process
* @returns {Menu} - application tray menu
*/ */
function getTrayMenu(showApp, askToQuit) { function getTrayMenu(showApp, askToQuit) {
return [ return Menu.buildFromTemplate([
{ {
label: 'Show App', label: 'Show App',
click: () => showApp(), click: showApp,
}, },
{ {
label: 'Shutdown', label: 'Shutdown',
click: () => askToQuit(), click: askToQuit,
}, },
]; ]);
} }
module.exports = { getTrayMenu }; module.exports = { getTrayMenu };
+1 -1
View File
@@ -91,7 +91,7 @@
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<img src="../../assets/logo.png" /> <img src="../assets/logo.png" />
<h1>ontime · event timers</h1> <h1>ontime · event timers</h1>
<div class="lds-ellipsis"> <div class="lds-ellipsis">
<div></div> <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 * Allows downloading of project files
*/ */
+2
View File
@@ -2,6 +2,7 @@ import express from 'express';
import { import {
createProjectFile, createProjectFile,
currentProjectDownload,
deleteProjectFile, deleteProjectFile,
duplicateProjectFile, duplicateProjectFile,
getInfo, getInfo,
@@ -23,6 +24,7 @@ import {
export const router = express.Router(); export const router = express.Router();
router.get('/', currentProjectDownload);
router.post('/download', validateFilenameBody, projectDownload); router.post('/download', validateFilenameBody, projectDownload);
router.post('/upload', uploadProjectFile, postProjectFile); router.post('/upload', uploadProjectFile, postProjectFile);
@@ -51,6 +51,13 @@ function init() {
ensureDirectory(resolveCorruptDirectory); ensureDirectory(resolveCorruptDirectory);
} }
export async function getCurrentProject() {
const filename = await getLastLoadedProject();
const pathToFile = getPathToProject(filename);
return { filename, pathToFile };
}
/** /**
* Private function loads a demo project * Private function loads a demo project
* to be composed in the loading functions * to be composed in the loading functions
@@ -1,249 +1,243 @@
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types'; import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
import { apply } from '../delayUtils.js'; import { apply } from '../delayUtils.js';
import { MILLIS_PER_HOUR } from 'ontime-utils';
describe('apply() ', () => { /**
describe('in a rundown without the delay field, persisted rundown', () => { * Small utility to fill in the necessary data for the test
it('applies delays', () => { */
const delayId = '1'; function makeOntimeEvent(event: Partial<OntimeEvent>): OntimeEvent {
const testRundown: OntimeRundown = [ return { ...event, type: SupportedEvent.Event, revision: 1 } as OntimeEvent;
{ id: delayId, type: SupportedEvent.Delay, duration: 10 } as OntimeDelay, }
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
];
const expected = [ /**
{ id: '2', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } as OntimeEvent, * Small utility to make a delay event
{ id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } as OntimeEvent, */
{ id: '4', type: SupportedEvent.Block } as OntimeBlock, function makeOntimeDelay(duration: number): OntimeDelay {
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, return { id: 'delay', type: SupportedEvent.Delay, duration } as OntimeDelay;
]; }
const updatedRundown = apply(delayId, testRundown); describe('apply()', () => {
expect(updatedRundown).toStrictEqual(expected); it('applies a positive delay to the rundown', () => {
}); const testRundown = [
it('applies negative delays', () => { makeOntimeDelay(10),
const delayId = '1'; makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
const testRundown: OntimeRundown = [ makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
{ id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay, { id: '3', type: SupportedEvent.Block } as OntimeBlock,
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
{ id: '3', type: SupportedEvent.Event, timeStart: 20, timeEnd: 40, duration: 20, revision: 1 } as OntimeEvent, makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
{ id: '4', type: SupportedEvent.Block } as OntimeBlock, ];
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
];
const expected = [ const updatedRundown = apply('delay', testRundown);
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 2 } as OntimeEvent, expect(updatedRundown).not.toBe(testRundown);
{ id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 30, duration: 20, revision: 2 } as OntimeEvent, expect(updatedRundown).toMatchObject([
{ id: '4', type: SupportedEvent.Block } as OntimeBlock, { id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 },
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, { id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '1' },
]; { id: '3' },
{ id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: null },
const updatedRundown = apply(delayId, testRundown); { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: '4' },
expect(updatedRundown).toStrictEqual(expected); ]);
});
it('maintains constant duration', () => {
const delayId = '1';
const testRundown: OntimeRundown = [
{ id: delayId, type: SupportedEvent.Delay, duration: -30 } as OntimeDelay,
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, timeStart: 20, timeEnd: 40, duration: 20, revision: 1 } as OntimeEvent,
];
const expected = [
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 2 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, timeStart: 0, timeEnd: 20, duration: 20, revision: 2 } as OntimeEvent,
];
const updatedRundown = apply(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
}); });
describe('in a rundown with the delay field, cached rundown', () => {
it('applies delays', () => {
const delayId = '1';
const testRundown: OntimeRundown = [
{ id: delayId, type: SupportedEvent.Delay, duration: 10 } as OntimeDelay,
{
id: '2',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 10,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 10,
} as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{
id: '5',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 0,
} as OntimeEvent,
];
const expected = [ it('applies negative delays', () => {
{ const testRundown = [
id: '2', makeOntimeDelay(-10),
type: SupportedEvent.Event, makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
timeStart: 10, makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
timeEnd: 20, { id: '3', type: SupportedEvent.Block } as OntimeBlock,
duration: 10, makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
revision: 2, makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
delay: 0, ];
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 10,
timeEnd: 20,
duration: 10,
revision: 2,
delay: 0,
} as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{
id: '5',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 0,
} as OntimeEvent,
];
const updatedRundown = apply(delayId, testRundown); const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toStrictEqual(expected); expect(updatedRundown).toMatchObject([
}); { id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 },
it('applies negative delays', () => { { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null },
const delayId = '1'; { id: '3' },
const testRundown: OntimeRundown = [ { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: null },
{ id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay, { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '4' },
{ ]);
id: '2', });
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: -10,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 20,
timeEnd: 40,
duration: 20,
revision: 1,
delay: -10,
} as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{
id: '5',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 0,
} as OntimeEvent,
];
const expected = [ it('should account for minimum duration and start when applying negative delays', () => {
{ const testRundown: OntimeRundown = [
id: '2', makeOntimeDelay(-50),
type: SupportedEvent.Event, makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
timeStart: 0, makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: '1' }),
timeEnd: 10, ];
duration: 10,
revision: 2,
delay: 0,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 10,
timeEnd: 30,
duration: 20,
revision: 2,
delay: 0,
} as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{
id: '5',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 0,
} as OntimeEvent,
];
const updatedRundown = apply(delayId, testRundown); const expected = [
expect(updatedRundown).toStrictEqual(expected); { id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 2 } as OntimeEvent,
}); {
it('maintains constant duration', () => { id: '2',
const delayId = '1'; type: SupportedEvent.Event,
const testRundown: OntimeRundown = [ timeStart: 50,
{ id: delayId, type: SupportedEvent.Delay, duration: -30 } as OntimeDelay, timeEnd: 100,
{ duration: 50,
id: '2', linkStart: null,
type: SupportedEvent.Event, revision: 2,
timeStart: 0, } as OntimeEvent,
timeEnd: 10, ];
duration: 10,
revision: 1,
delay: -30,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 20,
timeEnd: 40,
duration: 20,
revision: 1,
delay: -30,
} as OntimeEvent,
];
const expected = [ const updatedRundown = apply('delay', testRundown);
{ expect(updatedRundown).toMatchObject(expected);
id: '2', });
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 2,
delay: 0,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 20,
duration: 20,
revision: 2,
delay: 0,
} as OntimeEvent,
];
const updatedRundown = apply(delayId, testRundown); it('unlinks events to maintain gaps when applying positive delays', () => {
expect(updatedRundown).toStrictEqual(expected); const testRundown = [
}); makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
makeOntimeDelay(50),
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
];
expect(apply('delay', testRundown)).toMatchObject([
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent,
{
id: '2',
type: SupportedEvent.Event,
timeStart: 150,
timeEnd: 200,
duration: 50,
linkStart: null,
revision: 2,
} as OntimeEvent,
]);
});
it('maintains links if there is no gap', () => {
const testRundown = [
makeOntimeDelay(50),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
];
expect(apply('delay', testRundown)).toMatchObject([
{ id: '1', type: SupportedEvent.Event, timeStart: 50, timeEnd: 150, duration: 100, revision: 2 } as OntimeEvent,
{
id: '2',
type: SupportedEvent.Event,
timeStart: 150,
timeEnd: 200,
duration: 50,
linkStart: '1',
revision: 2,
} as OntimeEvent,
]);
});
it('unlinks events to maintain gaps when applying negative delays', () => {
const testRundown = [
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
makeOntimeDelay(-50),
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
];
expect(apply('delay', testRundown)).toMatchObject([
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent,
{
id: '2',
type: SupportedEvent.Event,
timeStart: 50,
timeEnd: 100,
duration: 50,
linkStart: null,
revision: 2,
} as OntimeEvent,
]);
});
it('gaps reduce positive delay', () => {
const testRundown: OntimeRundown = [
makeOntimeDelay(100),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
// gap 50
makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50 }),
// gap 50
makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50 }),
// gap 50
makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50 }),
// linked
makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }),
];
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject([
{ id: '1', timeStart: 0 + 100, timeEnd: 100 + 100, duration: 100, revision: 2 },
// gap 50 (100 - 50)
{ id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 },
// gap 50 (50 - 50)
{ id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2 },
// gap (delay is 0)
{ id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 },
// linked
{ id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: '4' },
]);
});
it('gaps reduce positive delay (2)', () => {
const testRundown: OntimeRundown = [
makeOntimeDelay(2 * MILLIS_PER_HOUR),
makeOntimeEvent({
id: '1',
timeStart: 46800000, // 13:00:00
timeEnd: 50400000, // 14:00:00
duration: MILLIS_PER_HOUR,
}),
// gap 1h
makeOntimeEvent({
id: '2',
timeStart: 54000000, // 15:00:00
timeEnd: 57600000, // 16:00:00
duration: MILLIS_PER_HOUR,
}),
];
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject([
{ id: '1', timeStart: 54000000 /* 16 */, revision: 2 },
// gap 1h (2h - 1h)
{ id: '2', timeStart: 57600000 /* 16 */, revision: 2 },
]);
});
it('removes empty delays without applying changes', () => {
const testRundown: OntimeRundown = [
makeOntimeDelay(0),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
];
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject([{ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }]);
});
it('removes delays in last position without applying changes', () => {
const testRundown: OntimeRundown = [
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
makeOntimeDelay(100),
];
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject([{ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }]);
});
it('unlinks events to across blocks is it is the first event after the delay', () => {
const testRundown = [
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
makeOntimeDelay(50),
{ id: 'block', type: SupportedEvent.Block } as OntimeBlock,
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
];
expect(apply('delay', testRundown)).toMatchObject([
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent,
{ id: 'block', type: SupportedEvent.Block },
{
id: '2',
type: SupportedEvent.Event,
timeStart: 150,
timeEnd: 200,
duration: 50,
linkStart: null,
revision: 2,
} as OntimeEvent,
]);
}); });
}); });
@@ -1,6 +1,5 @@
import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent } from 'ontime-types'; import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent, OntimeEvent } from 'ontime-types';
import { getTimeFromPrevious, deleteAtIndex } from 'ontime-utils';
import { deleteAtIndex } from '../../../../../packages/utils/src/array-utils/arrayUtils.js';
/** /**
* Calculates all delays in a given rundown * Calculates all delays in a given rundown
@@ -92,10 +91,7 @@ export function getDelayAt(eventIndex: number, rundown: OntimeRundown): number {
/** /**
* Applies delay from given event ID, deletes the delay event after * Applies delay from given event ID, deletes the delay event after
* @param eventId
* @param rundown
* @throws {Error} if event ID not found or is not a delay * @throws {Error} if event ID not found or is not a delay
* @returns
*/ */
export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown { export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
const delayIndex = rundown.findIndex((event) => event.id === eventId); const delayIndex = rundown.findIndex((event) => event.id === eventId);
@@ -109,27 +105,71 @@ export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
throw new Error('Given event ID is not a delay'); throw new Error('Given event ID is not a delay');
} }
const updatedRundown = [...rundown]; // if the delay is empty, or the last element, we can just delete it
const delayValue = delayEvent.duration; if (delayEvent.duration === 0 || delayIndex === rundown.length - 1) {
return deleteAtIndex(delayIndex, rundown);
if (delayValue === 0 || delayIndex === rundown.length - 1) {
// nothing to apply
return updatedRundown;
} }
for (let i = delayIndex + 1; i < rundown.length; i++) { /**
const currentEvent = updatedRundown[i]; * We apply the delay to the rundown
* This logic is mostly in sync with rundownCache.generate
*/
const updatedRundown = structuredClone(rundown);
let delayValue = delayEvent.duration;
let lastEntry: OntimeEvent | null = null;
let isFirstEvent = true;
if (isOntimeBlock(currentEvent)) { for (let i = delayIndex + 1; i < updatedRundown.length; i++) {
break; const currentEntry = updatedRundown[i];
} else if (isOntimeEvent(currentEvent)) {
currentEvent.timeStart = Math.max(0, currentEvent.timeStart + delayValue); // we don't do operation on other event types
currentEvent.timeEnd = Math.max(currentEvent.duration, currentEvent.timeEnd + delayValue); if (!isOntimeEvent(currentEntry)) {
if (currentEvent.delay) { continue;
currentEvent.delay = currentEvent.delay - delayValue;
}
currentEvent.revision += 1;
} }
// we need to remove the link in the first event to maintain the gap
let shouldUnlink = isFirstEvent;
isFirstEvent = false;
// if the event is not linked, we try and maintain gaps
if (lastEntry !== null) {
const timeFromPrevious: number = getTimeFromPrevious(
currentEntry.timeStart,
lastEntry.timeStart,
lastEntry.timeEnd,
lastEntry.duration,
);
// when applying negative delays, we need to unlink the event
// if the previous event was fully consumed by the delay
if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) {
shouldUnlink = true;
}
if (timeFromPrevious > 0) {
delayValue = Math.max(delayValue - timeFromPrevious, 0);
}
if (delayValue === 0) {
// we can bail from continuing if there are no further delays to apply
break;
}
}
// save the current entry before making mutations on its values
lastEntry = { ...currentEntry };
if (shouldUnlink) {
currentEntry.linkStart = null;
shouldUnlink = false;
}
// event times move up by the delay value
// we dont update the delay value since we would need to iterate through the entire dataset
// this is handled by the rundownCache.generate function
currentEntry.timeStart = Math.max(0, currentEntry.timeStart + delayValue);
currentEntry.timeEnd = Math.max(currentEntry.duration, currentEntry.timeEnd + delayValue);
currentEntry.revision += 1;
} }
return deleteAtIndex(delayIndex, updatedRundown); return deleteAtIndex(delayIndex, updatedRundown);
+7 -7
View File
@@ -6,18 +6,18 @@ test.describe('test view navigation feature', () => {
await page.goto('http://localhost:4001/'); await page.goto('http://localhost:4001/');
await page.locator('data-test-id=timer-view'); 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.getByRole('button', { name: 'toggle menu' }).click();
await page.locator('data-test-id=navigation__menu'); await page.locator('data-test-id=navigation__menu');
await page.getByRole('link', { name: 'Minimal Timer' }).click(); await page.getByRole('link', { name: 'Minimal Timer' }).click();
await page.locator('data-test-id=minimal-timer'); await page.locator('data-test-id=minimal-timer');
await expect(page).toHaveURL('http://localhost:4001/minimal'); 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.getByRole('button', { name: 'toggle menu' }).click();
await page.locator('data-test-id=navigation__menu'); await page.locator('data-test-id=navigation__menu');
await page.getByRole('link', { name: 'Backstage' }).click(); await page.getByRole('link', { name: 'Backstage' }).click();
@@ -55,4 +55,4 @@ test.describe('test view navigation feature', () => {
await page.locator('data-test-id=timer-view'); await page.locator('data-test-id=timer-view');
await expect(page).toHaveURL('http://localhost:4001/timer'); await expect(page).toHaveURL('http://localhost:4001/timer');
}); });
}); });