Compare commits

...

67 Commits

Author SHA1 Message Date
Carlos Valente 48c4e5f429 bump version to 3.3.0 2024-06-15 15:10:24 +02:00
Carlos Valente ad0da6def4 fix: persistence of custom fields (#1078) 2024-06-15 15:07:20 +02:00
Alex Christoffer Rasmussen a15eb0db4c Client remote control (#930)
---------

Co-authored-by: kellhogs <fabianpos99@gmail.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-06-15 12:26:41 +02:00
jwetzell 70e8fbf327 add warning and danger transitions as timer life cycle events (#1071)
* setup detection of TImerPhase changes

* add integration event for transition to warning

* add integration event for transition to danger

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-06-15 09:54:29 +02:00
jwetzell 1044dfac01 Merge pull request #1051 from cpvalente/feat/ontime-headless
add a headless version of ontime
2024-06-13 20:11:00 -05:00
jwetzell b5e5c113e2 update lock file 2024-06-13 20:06:11 -05:00
Carlos Valente cbcff5e7ab docs: initial npm documentation 2024-06-13 20:04:08 -05:00
jwetzell 83cb2f82f5 add SIGHUP and SIGTERM to cli shutdown 2024-06-13 20:01:25 -05:00
jwetzell 837fd53dfd remove pkg bundling of cli 2024-06-13 20:01:04 -05:00
jwetzell 4962288a53 update package name for organization scoping 2024-06-13 19:59:47 -05:00
jwetzell 75a35e8537 add access flag to publish to organization 2024-06-13 19:59:47 -05:00
jwetzell 6e839fcefb rename bin to ontime 2024-06-13 19:59:47 -05:00
jwetzell 052a8e4ccb switch pkg to only build linux x64 and arm64 binaries 2024-06-13 19:59:47 -05:00
jwetzell 969c2bad14 add step to publish to NPM as well 2024-06-13 19:59:47 -05:00
jwetzell 9717222491 remove dev script from cli package.json 2024-06-13 19:59:47 -05:00
jwetzell 081c0bdac4 add necessary files to package.json for publishing 2024-06-13 19:59:47 -05:00
jwetzell 8b99e91883 add github workflow to build cli 2024-06-13 19:59:47 -05:00
jwetzell 4af2e688d1 add basic cli app for bundling a headless ontime 2024-06-13 19:59:47 -05:00
Luke Stein cf8c8d4b2e bump version to 3.2.0 2024-06-13 22:46:32 +02:00
Alex Christoffer Rasmussen 9e63261b35 None colour from API (#1063) 2024-06-12 21:31:08 +02:00
Alex Christoffer Rasmussen b1838a5e9a display multiple custom fields in operator view (#1001)
---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-06-12 14:25:26 +02:00
Alex Christoffer Rasmussen 9d7b5568e4 use port 3000 in dev mode (#1065) 2024-06-11 13:37:10 +02:00
Alex Christoffer Rasmussen 657aed1244 Fix coerce enum (#1064) 2024-06-11 13:03:34 +02:00
Carlos Valente 13c49f3652 chore: improve dev scripts 2024-06-10 18:26:18 +02:00
Carlos Valente 86b4ddadb5 chore: prepare log levels 2024-06-10 18:26:18 +02:00
Carlos Valente e9f806b88a style: organise console levels and semantics 2024-06-10 18:26:18 +02:00
Carlos Valente 4f672670ed chore: document aux functions 2024-06-10 18:26:18 +02:00
Carlos Valente 8742b790f3 refactor: escalate severe to electron 2024-06-10 18:26:18 +02:00
Carlos Valente d41cd78f2e refactor: add severe log level 2024-06-10 18:26:18 +02:00
Carlos Valente 715600a514 refactor: improve console visibility 2024-06-10 18:26:18 +02:00
Carlos Valente b99cf5f255 refactor: improve error states in spreadsheet flow 2024-06-09 22:04:41 +02:00
Carlos Valente 9a9a0eb7cf refactor: use timer phase while waiting 2024-06-08 13:47:26 +02:00
Carlos Valente 4bf2422ebc refactor: show next when waiting to roll 2024-06-08 13:47:26 +02:00
Michael Foster add769fe1e fix for MultiPartProgressBar styling (#1022) 2024-06-07 21:08:17 +02:00
Carlos Valente 3af4c64ff3 chore: upgrade lint dependencies 2024-06-07 20:15:43 +02:00
Alex Christoffer Rasmussen f405e65c55 Custom for timer title (#1055) 2024-06-07 15:24:29 +02:00
Alex Christoffer Rasmussen 0e32b3a3af Fix: link events in sheet when there is nothing to link (#1053)
* create failing tests

* fix missing optional chaining
2024-06-06 22:38:52 +02:00
Alex Christoffer Rasmussen c2fa115946 Validate project filename (#1046) 2024-06-06 22:37:41 +02:00
jwetzell edb48a2fc7 consider custom fields when loading operator main field (#1049) 2024-06-06 21:57:50 +02:00
Alex Christoffer Rasmussen 6f7846bbf7 fix and test only space osc payload (#1052) 2024-06-06 11:19:12 +02:00
Alex Christoffer Rasmussen 48ebd31c54 bump version to 3.1.1 (#1045) 2024-06-04 15:57:52 +02:00
Carlos Valente 6d1a911ab8 refactor: rename negative to overtime 2024-06-04 15:51:00 +02:00
Carlos Valente ee1753ef29 fix: account for pending timers 2024-06-04 15:51:00 +02:00
Carlos Valente 147e5f5a27 chore: remove unused dependencies 2024-06-03 22:38:57 +02:00
Carlos Valente a02b3faacc chore: bump version to 3.1.0 2024-06-03 22:38:57 +02:00
Alex Christoffer Rasmussen a3b21072eb keep the index row as a colour chip (#1039) 2024-06-03 22:38:22 +02:00
Carlos Valente 2076080c5f refactor: supress modifiers in count-up timers 2024-06-03 22:36:05 +02:00
Alex Christoffer Rasmussen 8b3abe9d61 fix: insert above shortcut (#1020)
refactor: unify setCursor and useEventSelection
2024-06-03 21:34:00 +02:00
Carlos Valente c2039866b4 refactor: fix missed first update (#1031) 2024-06-03 20:27:51 +02:00
Carlos Valente 73e4a15718 style: add label to auxiliary timer 2024-06-03 20:26:25 +02:00
asharonbaltazar c4359af2a0 feat:toggle index column (#1037)
* feat:add `showIndexColumn` boolean property to `CuesheetSettings`

* feat:use `showIndexColumn` to hide column index th/tr

* feat:pass `showIndexColumn` to components

* fix:update column headers naming
2024-06-03 14:21:11 -04:00
Carlos Valente f75f45b19a refactor: simplify client logic on timer phases 2024-06-03 20:18:19 +02:00
Carlos Valente fd2eee32aa refactor: declare timer phase 2024-06-03 20:18:19 +02:00
Carlos Valente a53d354b16 refactor: continue roll 2024-06-03 20:17:41 +02:00
Alex Christoffer Rasmussen ec55236eb2 fix: osc new project (#1032) 2024-06-02 18:01:36 +02:00
Alex Christoffer Rasmussen 3895b37572 Allow time change from api (#1009)
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-06-02 15:21:09 +02:00
Julien Recurt d8626ff324 Add option to display the full time (HH:mm:ss) in the timer view (#988)
* Add option to display the full time (HH:mm:ss) in the timer view

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-06-01 13:39:15 +02:00
arc-alex 6c3870c15b catch getWorksheetNames 2024-05-31 22:17:17 +02:00
Carlos Valente 4fadb23b07 refactor: timer overrides do not affect minimal 2024-05-31 22:17:03 +02:00
Carlos Valente 199ed1617f fix: menu link to operator 2024-05-31 18:57:45 +02:00
Carlos Valente 2d618e0657 refactor: remove double import 2024-05-30 22:41:50 +02:00
Carlos Valente f928c1dc95 refactor: avoid unnecessary computer property 2024-05-30 22:41:50 +02:00
jwetzell 8d526c7d31 Merge pull request #1019 from cpvalente/cleanup-server-setup
remove redundant path evaluation
2024-05-30 07:57:23 -05:00
Carlos Valente 686c6108bf refactor: improve payload type for OSC (#954)
---------

Co-authored-by: Joel Wetzell <jwetzell@yahoo.com>
Co-authored-by: arc-alex <ac@omnivox.dk>
2024-05-30 13:02:22 +02:00
Carlos Valente ea3d33ca93 fix: correct key on shortcuts 2024-05-30 12:15:57 +02:00
jwetzell 6bf94ff645 remove redundant path evaluation 2024-05-29 16:25:15 -05:00
Carlos Valente 930cd71ffd chore: upgrade electron 2024-05-29 08:33:44 +02:00
140 changed files with 3234 additions and 1203 deletions
+50
View File
@@ -0,0 +1,50 @@
name: Ontime CLI build
on:
push:
tags: [ "*" ]
workflow_dispatch:
jobs:
build_cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Copy server
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
- name: Copy client
run: cp -R apps/client/build apps/cli/client
- name: Copy external
run: cp -R apps/server/src/external apps/cli/external
- name: Publish to NPM
run: pnpm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/cli
+3
View File
@@ -81,6 +81,9 @@ From the project root, run the following commands
The build distribution assets will be at `.apps/electron/dist`
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
Use the `turbo dist-mac:local` command to build a MacOS distribution locally.
## DOCKER
Ontime provides a docker-compose file to aid with building and running docker images.
+16
View File
@@ -0,0 +1,16 @@
{
"parserOptions": {
"sourceType": "module"
},
"env": {
"browser": true,
"node": true
},
"extends": [
"eslint:recommended"
],
"plugins": [],
"rules": {
"@typescript-eslint/no-var-requires": "off"
}
}
+3
View File
@@ -0,0 +1,3 @@
server
external
client
+9
View File
@@ -0,0 +1,9 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 120
}
+44
View File
@@ -0,0 +1,44 @@
# Ontime CLI
![Ontime](https://github.com/cpvalente/ontime/blob/9e63261b350e5b7b61c7ffb17c9e75a6c5a8c711/.github/aux-images/editor.png)
> Ontime is an application for managing rundown and event timers.
Congratulations! You got this far into Ontime's rabbit hole and want to manage your installation.
The CLI tool is a fast and lightweight way of installing Ontime and is perfect for self-hosting and headless installs.
## Getting started
### Prerequisites
You will need to have installed the correct version of node.js
Please check [the app engines declaration](https://github.com/cpvalente/ontime/blob/master/package.json) for the correct version.
### Running Ontime
To get Ontime running, all you need to do is install it in your system using your package manager of choice:
Install globally in your system
```bash
npm install -g @getontime/cli
```
... and run using the installed script
```bash
ontime
```
Or install and run ontime (the installation here is temporary for the duration of the session)
```bash
npx install @getontime/cli
```
## Links
- [Ontime's repository](https://github.com/cpvalente/ontime)
- [Ontime's documentation](https://docs.getontime.no/)
- [Ontime's website](https://getontime.no/)
## Sponsoring
You can help the development of this project or say thank you with a one time donation. \
See the [terms of fonations](https://github.com/cpvalente/ontime/blob/master/SPONSOR.md)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
// NOTE: for now the following needs to be in place: ./server/index.cjs, ./client, ./external
const ontimeServer = require('./server/index.cjs');
const { initAssets, startServer, startIntegrations, shutdown } = ontimeServer;
async function startOntime() {
await initAssets();
await startServer();
await startIntegrations();
}
startOntime();
process.on(['SIGHUP', 'SIGINT', 'SIGTERM'], () => {
shutdown();
});
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@getontime/cli",
"version": "3.1.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
"keywords": [
"lighdev",
"ontime",
"timer"
],
"license": "AGPL-3.0-only",
"main": "main.js",
"bin": {
"ontime": "main.js"
},
"files": [
"client",
"external",
"server"
],
"devDependencies": {
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"prettier": "^3.0.3"
}
}
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.0.4",
"version": "3.3.0",
"private": true,
"type": "module",
"dependencies": {
@@ -70,13 +70,13 @@
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@typescript-eslint/eslint-plugin": "^v7.12.0",
"@typescript-eslint/parser": "^7.12.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-jest": "^27.6.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^28.6.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
@@ -84,7 +84,7 @@
"jsdom": "^21.1.0",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "^3.0.3",
"prettier": "^3.3.1",
"sass": "^1.57.1",
"typescript": "^5.4.3",
"vite": "^5.2.11",
+6 -3
View File
@@ -4,16 +4,15 @@ import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
import { AppContextProvider } from './common/context/AppContext';
import { ontimeQueryClient } from './common/queryClient';
import { socketClientName } from './common/stores/connectionName';
import { connectSocket } from './common/utils/socket';
import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
const preferredClientName = socketClientName.getState().name;
connectSocket(preferredClientName);
connectSocket();
function App() {
return (
@@ -24,11 +23,15 @@ function App() {
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
<IdentifyOverlay />
<AppRouter />
</TranslationProvider>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
</div>
<ErrorBoundary>
<div id='identify-portal' />
</ErrorBoundary>
</BrowserRouter>
</AppContextProvider>
</QueryClientProvider>
+4
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { useClientPath } from './common/hooks/useClientPath';
import Log from './features/log/Log';
import withPreset from './features/PresetWrapper';
import withData from './features/viewers/ViewWrapper';
@@ -34,6 +35,9 @@ const TimerControl = lazy(() => import('./features/control/playback/TimerControl
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
export default function AppRouter() {
// handle client path changes
useClientPath();
return (
<Suspense fallback={null}>
<Routes>
+1
View File
@@ -11,6 +11,7 @@ export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList'];
// resolve location
const location = window.location;
+3 -3
View File
@@ -11,8 +11,8 @@ const dbPath = `${apiEntryUrl}/db`;
/**
* HTTP request to the current DB
*/
async function getDb(fileName?: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download/`, { fileName });
async function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download/`, { filename });
}
/**
@@ -123,7 +123,7 @@ export async function renameProject(filename: string, newFilename: string): Prom
const url = `${dbPath}/${filename}/rename`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.put(decodedUrl, {
newFilename,
filename: newFilename,
});
return res.data;
}
@@ -0,0 +1,6 @@
.buttonSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $element-spacing;
margin-top: -$element-spacing;
}
@@ -0,0 +1,69 @@
import { useState } from 'react';
import {
Button,
Input,
InputGroup,
InputLeftAddon,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { setClientRemote } from '../../hooks/useSocket';
import style from './ClientModal.module.scss';
interface RedirectClientModalProps {
id: string;
name?: string;
path?: string;
isOpen: boolean;
onClose: () => void;
}
export function RedirectClientModal(props: RedirectClientModalProps) {
const { id, isOpen, name = '', path: currentPath = '', onClose } = props;
const [path, setPath] = useState(currentPath);
const { setRedirect } = setClientRemote;
const handleRedirect = () => {
if (path !== currentPath && path !== '') {
setRedirect({ target: id, redirect: path });
}
onClose();
};
const host = `${window.location.origin}/`;
const canSubmit = path !== currentPath && path !== '';
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
<ModalOverlay />
<ModalContent>
<ModalHeader>Redirect: {name}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<InputGroup variant='ontime-filled' size='md'>
<InputLeftAddon>{host}</InputLeftAddon>
<Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} />
</InputGroup>
</ModalBody>
<ModalFooter>
<div className={style.buttonSection}>
<Button size='md' variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
Submit
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,68 @@
import { useState } from 'react';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { setClientRemote } from '../../hooks/useSocket';
import style from './ClientModal.module.scss';
interface RenameClientModalProps {
id: string;
name?: string;
isOpen: boolean;
onClose: () => void;
}
export function RenameClientModal(props: RenameClientModalProps) {
const { id, name: currentName = '', isOpen, onClose } = props;
const [name, setName] = useState(currentName);
const { setClientName } = setClientRemote;
const handleRename = () => {
if (name !== currentName && name !== '') {
setClientName({ target: id, rename: name });
}
onClose();
};
const canSubmit = name !== currentName && name !== '';
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
<ModalOverlay />
<ModalContent>
<ModalHeader>Rename: {currentName}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Input
variant='ontime-filled'
size='md'
placeholder='new name'
value={name}
onChange={(event) => setName(event.target.value)}
/>
</ModalBody>
<ModalFooter>
<div className={style.buttonSection}>
<Button size='md' variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
Submit
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,34 @@
.overlay {
position: absolute;
top: 0;
left: 0;
margin: 0;
width: 100vw;
height: 100vh;
padding: 0;
box-sizing: border-box;
border: 10px solid $ui-white;
background-color: $ui-black;
color: $ui-white;
display: grid;
place-content: center;
text-align: center;
z-index: 100;
cursor: pointer;
}
.name {
font-weight: 600;
margin-top: -10vh;
line-height: 1em;
font-size: 12vw;
text-transform: uppercase;
}
.message {
color: $label-gray;
line-height: 2em;
font-size: 3vw;
}
@@ -0,0 +1,64 @@
import { useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { setClientRemote } from '../../hooks/useSocket';
import { useClientStore } from '../../stores/clientStore';
import style from './IdentifyOverlay.module.scss';
export default function IdentifyOverlay() {
const clients = useClientStore((store) => store.clients);
const id = useClientStore((store) => store.id);
const showOverlay = clients[id]?.identify;
if (!showOverlay) {
return null;
}
const portalRoot = document.getElementById('identify-portal');
if (!portalRoot) {
return null;
}
return createPortal(<Overlay />, portalRoot);
}
function Overlay() {
const clients = useClientStore((store) => store.clients);
const id = useClientStore((store) => store.id);
const name = useClientStore((store) => store.name);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const { setIdentify } = setClientRemote;
const showOverlay = clients[id]?.identify;
const handleClose = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
setIdentify({ target: id, identify: false });
}, [id, setIdentify]);
// start a timer that will close the overlay after some time
useEffect(() => {
if (showOverlay) {
timerRef.current = setTimeout(handleClose, MILLIS_PER_MINUTE);
}
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, [showOverlay, id, setIdentify, handleClose]);
console.log('here2');
return (
<div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}>
<div className={style.name}>{name}</div>
<div className={style.message}>Click to close</div>
</div>
);
}
@@ -7,10 +7,6 @@ $progress-bar-br: 3px;
position: relative;
width: 100%;
height: $progress-bar-size;
border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
display: flex;
overflow: hidden;
&--hidden {
display: none;
@@ -18,31 +14,42 @@ $progress-bar-br: 3px;
}
}
.multiprogress-bar__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: var(--progress-bar-br, $progress-bar-br);
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.multiprogress-bar__indicator {
position: absolute;
height: inherit;
background-color: black;
inset: 0;
margin: -1px;
margin-left: 0;
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.multiprogress-bar__indicator-bar {
background-color: var(--background-color-override, $ui-black);
opacity: 0.8;
transition: 1s linear;
transition-property: width;
right: 0;
.multiprogress-bar--ignore-css-override & {
background-color: $ui-black;
}
}
.multiprogress-bar__bg-normal {
position: absolute;
height: inherit;
right: 0;
width: 100%;
}
.multiprogress-bar__bg-warning {
position: absolute;
height: inherit;
right: 0;
flex: 1;
}
.multiprogress-bar__bg-danger {
position: absolute;
height: inherit;
right: 0;
}
flex-shrink: 0;
}
@@ -13,31 +13,51 @@ interface MultiPartProgressBar {
danger?: MaybeNumber;
dangerColor: string;
hidden?: boolean;
ignoreCssOverride?: boolean;
className?: string;
}
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
const {
now,
complete,
normalColor,
warning,
warningColor,
danger,
dangerColor,
hidden,
ignoreCssOverride,
className = '',
} = props;
const percentRemaining = complete === 0 ? 0 : 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
const warningWidth = warning ? clamp((warning / complete) * 100, 0, 100) : 0;
const warningWidth = warning ? clamp((warning / complete) * 100 - dangerWidth, 0, 100) : 0;
return (
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
<div
className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${
ignoreCssOverride ? 'multiprogress-bar--ignore-css-override' : ''
} ${className}`}
>
{now !== null && (
<>
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
<div
className='multiprogress-bar__bg-warning'
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
/>
<div
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
/>
<div className='multiprogress-bar__indicator' style={{ width: `${percentRemaining}%` }} />
<div className='multiprogress-bar__bg'>
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
<div
className='multiprogress-bar__bg-warning'
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
/>
<div
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
/>
</div>
<div className='multiprogress-bar__indicator'>
<div className='multiprogress-bar__indicator-bar' style={{ width: `${percentRemaining}%` }} />
</div>
</>
)}
</div>
@@ -19,10 +19,10 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import RenameClientModal from './rename-client-modal/RenameClientModal';
import { RenameClientModal } from '../client-modal/RenameClientModal';
import style from './NavigationMenu.module.scss';
@@ -34,8 +34,10 @@ interface NavigationMenuProps {
function NavigationMenu(props: NavigationMenuProps) {
const { isOpen, onClose } = props;
const { isOpen: isRenameOpen, onOpen: onRenameOpen, onClose: onRenameClose } = useDisclosure();
const id = useClientStore((store) => store.id);
const name = useClientStore((store) => store.name);
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore();
@@ -45,7 +47,7 @@ function NavigationMenu(props: NavigationMenuProps) {
return createPortal(
<div id='navigation-menu-portal' ref={menuRef}>
<RenameClientModal isOpen={isRenameOpen} onClose={onRenameClose} />
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay />
<DrawerContent>
@@ -1,71 +0,0 @@
import { useEffect, useState } from 'react';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { setClientName } from '../../../hooks/useSocket';
import { useSocketClientName } from '../../../stores/connectionName';
interface RenameClientModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function RenameClientModal({ isOpen, onClose }: RenameClientModalProps) {
const { name: clientName, persistName } = useSocketClientName();
const [newName, setNewName] = useState(clientName);
useEffect(() => {
setNewName(clientName);
}, [isOpen, clientName]);
const handleRename = async () => {
if (newName) {
setClientName(newName);
persistName(newName);
onClose();
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size='sm'
closeOnOverlayClick={false}
motionPreset='slideInBottom'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Rename client</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Input
placeholder='Connection must have a name'
defaultValue={newName}
onChange={(e) => setNewName(e.target.value)}
variant='ontime-filled'
/>
<Button
isDisabled={newName === clientName || !newName}
onClick={handleRename}
width='100%'
variant='ontime-filled'
>
Save
</Button>
</ModalBody>
</ModalContent>
</Modal>
);
}
@@ -1,5 +1,18 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react';
import {
Button,
Input,
InputGroup,
InputLeftElement,
Menu,
MenuButton,
MenuItemOption,
MenuList,
MenuOptionGroup,
Select,
Switch,
} from '@chakra-ui/react';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
@@ -34,6 +47,10 @@ export default function ParamInput(props: EditFormInputProps) {
);
}
if (type === 'multi-option') {
return <MultiOption paramField={paramField} />;
}
if (type === 'boolean') {
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
@@ -70,3 +87,47 @@ export default function ParamInput(props: EditFormInputProps) {
</InputGroup>
);
}
interface EditFormMultiOptionProps {
paramField: ParamField & { type: 'multi-option' };
}
function MultiOption(props: EditFormMultiOptionProps) {
const [searchParams] = useSearchParams();
const { paramField } = props;
const { id, defaultValue } = paramField;
const optionFromParams = (searchParams.get(id) ?? '').toLocaleLowerCase();
const defaultOptionValue = optionFromParams || defaultValue?.toLocaleLowerCase() || '';
const [paramState, setParamState] = useState<string>(defaultOptionValue);
return (
<>
<input name={id} hidden readOnly value={paramState} />
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
{paramField.title}
</MenuButton>
<MenuList>
<MenuOptionGroup
type='checkbox'
value={paramState.split('_')}
onChange={(value) => {
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
}}
>
{Object.values(paramField.values).map((option) => {
const { value, label } = option;
return (
<MenuItemOption value={value} key={value}>
{label}
</MenuItemOption>
);
})}
</MenuOptionGroup>
</MenuList>
</Menu>
</>
);
}
@@ -1,12 +1,10 @@
import { CustomFields } from 'ontime-types';
import { capitaliseFirstLetter } from '../../../features/viewers/common/viewUtils';
import { ParamField } from './types';
import { type ParamField } from './types';
const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => {
const customFieldOptions = Object.keys(customFields).reduce((acc, key) => {
return { ...acc, [`custom-${key}`]: `Custom: ${capitaliseFirstLetter(key)}` };
const customFieldOptions = Object.entries(customFields).reduce((acc, [key, value]) => {
return { ...acc, [`custom-${key}`]: `Custom: ${value.label}` };
}, additionalOptions ?? {});
return customFieldOptions;
};
@@ -30,6 +28,14 @@ const hideTimerSeconds: ParamField = {
defaultValue: false,
};
const showLeadingZeros: ParamField = {
id: 'showLeadingZeros',
title: 'Show leading zeros in timer',
description: 'Whether to show leading zeros in the running timer',
type: 'boolean',
defaultValue: false,
};
export const getClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
{
@@ -103,10 +109,12 @@ export const getClockOptions = (timeFormat: string): ParamField[] => [
];
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title' });
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
getTimeOption(timeFormat),
hideTimerSeconds,
showLeadingZeros,
{
id: 'hideClock',
title: 'Hide Time Now',
@@ -114,6 +122,14 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
type: 'boolean',
defaultValue: false,
},
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'Title',
},
{
id: 'secondary-src',
title: 'Secondary text',
@@ -230,13 +246,6 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
type: 'boolean',
defaultValue: false,
},
{
id: 'hidemessages',
title: 'Hide Message Overlay',
description: 'Whether to hide the overlay from showing timer screen messages',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideendmessage',
title: 'Hide End Message',
@@ -453,8 +462,8 @@ export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const customFieldSelect = Object.keys(customFields).reduce((acc, key) => {
return { ...acc, [key]: `Custom: ${capitaliseFirstLetter(key)}` };
const customFieldSelect = Object.entries(customFields).reduce((acc, [key, field]) => {
return { ...acc, [key]: { value: key, label: field.label, colour: field.colour } };
}, {});
return [
@@ -486,9 +495,8 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a custom field to highlight',
type: 'option',
type: 'multi-option',
values: customFieldSelect,
defaultValue: '',
},
{
id: 'shouldEdit',
@@ -9,8 +9,15 @@ type OptionsField = {
values: Record<string, string>;
defaultValue?: string;
};
type MultiOptionsField = {
type: 'multi-option';
values: Record<string, { value: string; label: string; colour: string }>;
defaultValue?: string;
};
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean };
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField);
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField | MultiOptionsField);
@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useClientStore } from '../stores/clientStore';
import { socketSendJson } from '../utils/socket';
export const useClientPath = () => {
const navigate = useNavigate();
const { pathname, search } = useLocation();
const redirect = useClientStore((store) => store.redirect);
const setRedirect = useClientStore((store) => store.setRedirect);
// notify of client path changes
useEffect(() => {
//remove leading '/' from path
const fullPath = (pathname.startsWith('/') ? pathname.slice(1) : pathname) + search;
socketSendJson('set-client-path', fullPath);
}, [pathname, search]);
// navigate to new path when received from server
useEffect(() => {
if (redirect === '') {
return;
}
// clear redirect
setRedirect('');
// navigate if there is a path change
if (redirect !== pathname + search) {
navigate(redirect);
}
}, [navigate, pathname, redirect, search, setRedirect]);
};
@@ -3,6 +3,12 @@ import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket';
export const setClientRemote = {
setIdentify: (payload: { target: string; identify: boolean }) => socketSendJson('client', payload),
setRedirect: (payload: { target: string; redirect: string }) => socketSendJson('client', payload),
setClientName: (payload: { target: string; rename: string }) => socketSendJson('client', payload),
};
export const useRundownEditor = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
@@ -1,4 +1,4 @@
import { MaybeNumber, Playback, TimerType } from 'ontime-types';
import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types';
// first set extends TimerState
export type ViewExtendedTimer = {
@@ -8,12 +8,11 @@ export type ViewExtendedTimer = {
elapsed: MaybeNumber;
expectedFinish: MaybeNumber;
finishedAt: MaybeNumber;
phase: TimerPhase;
playback: Playback;
secondaryTimer: MaybeNumber;
startedAt: MaybeNumber;
clock: number;
timeDanger: MaybeNumber;
timeWarning: MaybeNumber;
timerType: TimerType;
};
@@ -19,13 +19,10 @@ function persistModeToSession(mode: AppMode) {
type AppModeStore = {
mode: AppMode;
setMode: (mode: AppMode) => void;
cursor: string | null;
setCursor: (cursor: string | null) => void;
};
export const useAppMode = create<AppModeStore>()((set) => ({
mode: getModeFromSession(),
cursor: null,
setMode: (mode: AppMode) => {
persistModeToSession(mode);
@@ -33,5 +30,4 @@ export const useAppMode = create<AppModeStore>()((set) => ({
return { mode };
});
},
setCursor: (cursor: string | null) => set({ cursor }),
}));
@@ -0,0 +1,82 @@
import { ClientList } from 'ontime-types';
import { create } from 'zustand';
interface ClientStore {
name?: string;
setName: (newValue: string) => void;
id: string;
setId: (newValue: string) => void;
redirect: string;
setRedirect: (newValue: string) => void;
clients: ClientList;
setClients: (clients: ClientList) => void;
}
const clientNameKey = 'ontime-client-name';
function persistNameInStorage(newValue: string) {
localStorage.setItem(clientNameKey, newValue);
}
export const useClientStore = create<ClientStore>((set) => ({
name: localStorage.getItem(clientNameKey) ?? undefined,
setName: (name: string) =>
set(() => {
persistNameInStorage(name);
return { name };
}),
id: '',
setId: (id) => set({ id }),
redirect: '',
setRedirect: (redirect: string) => set({ redirect }),
clients: {},
setClients: (clients: ClientList) => set({ clients }),
}));
/**
* Allows getting client name (outside react)
*/
export function getClientName(): string | undefined {
return useClientStore.getState().name;
}
/**
* Allows updating current client name (outside react)
*/
export function setClientName(name: string): void {
useClientStore.getState().setName(name);
}
/**
* Allows updating current client name (outside react)
*/
export function setClientId(id: string): void {
useClientStore.getState().setId(id);
}
/**
* Allows getting client name (outside react)
*/
export function getClientId(): string | undefined {
return useClientStore.getState().id;
}
/**
* Allows updating redirect (outside react)
*/
export function setClientRedirect(path: string): void {
useClientStore.getState().setRedirect(path);
}
/**
* Allows setting clients (outside react)
*/
export function setClients(clients: ClientList): void {
return useClientStore.getState().setClients(clients);
}
@@ -1,26 +0,0 @@
import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla';
interface SocketClientNameState {
name?: string;
setName: (newValue: string) => void;
persistName: (newValue: string) => void;
}
const clientNameKey = 'ontime-client-name';
function persistKeyToStorage(newValue: string) {
localStorage.setItem(clientNameKey, newValue);
}
export const socketClientName = createStore<SocketClientNameState>((set) => ({
name: localStorage.getItem(clientNameKey) ?? undefined,
setName: (newValue: string) => set(() => ({ name: newValue })),
persistName: (newValue: string) =>
set(() => {
persistKeyToStorage(newValue);
return { name: newValue };
}),
}));
export const useSocketClientName = () => useStore(socketClientName);
+2 -1
View File
@@ -1,5 +1,5 @@
import isEqual from 'react-fast-compare';
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types';
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
export const runtimeStorePlaceholder: RuntimeStore = {
@@ -11,6 +11,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
elapsed: null,
expectedFinish: null,
finishedAt: null,
phase: TimerPhase.None,
playback: Playback.Stop,
secondaryTimer: null,
startedAt: null,
+2 -2
View File
@@ -3,6 +3,6 @@ export function isMacOS() {
return userAgent.includes('macintosh') || userAgent.includes('mac os');
}
export const deviceAlt = isMacOS() ? '' : 'Alt';
export const deviceAlt = isMacOS() ? 'Option' : 'Alt';
export const deviceMod = isMacOS() ? '' : 'Ctrl';
export const deviceMod = isMacOS() ? 'Cmd' : 'Ctrl';
+1
View File
@@ -9,4 +9,5 @@ export const startsWithHttp = /^http:\/\//;
export const startsWithSlash = /^\//;
export const isAlphanumeric = /^[a-z0-9]+$/i;
export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex
export const isASCIIorEmpty = /^$|^[ -~]+$/; //https://catonmat.net/my-favorite-regex
export const isNotEmpty = /\S/;
+56 -5
View File
@@ -1,21 +1,31 @@
import { Log, RuntimeStore } from 'ontime-types';
import { isProduction, RUNTIME, websocketUrl } from '../api/constants';
import { CLIENT_LIST, isProduction, RUNTIME, websocketUrl } from '../api/constants';
import { ontimeQueryClient } from '../queryClient';
import { socketClientName } from '../stores/connectionName';
import {
getClientId,
getClientName,
setClientId,
setClientName,
setClientRedirect,
setClients,
} from '../stores/clientStore';
import { addLog } from '../stores/logger';
import { patchRuntime, runtimeStore } from '../stores/runtime';
export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
const reconnectInterval = 1000;
export let shouldReconnect = true;
export let hasConnected = false;
export let reconnectAttempts = 0;
export const connectSocket = (preferredClientName?: string) => {
export const connectSocket = () => {
websocket = new WebSocket(websocketUrl);
const preferredClientName = getClientName();
websocket.onopen = () => {
clearTimeout(reconnectTimeout as NodeJS.Timeout);
hasConnected = true;
@@ -24,6 +34,9 @@ export const connectSocket = (preferredClientName?: string) => {
if (preferredClientName) {
socketSendJson('set-client-name', preferredClientName);
}
socketSendJson('set-client-type', 'ontime');
socketSendJson('set-client-path', location.pathname);
};
websocket.onclose = () => {
@@ -54,10 +67,48 @@ export const connectSocket = (preferredClientName?: string) => {
}
switch (type) {
case 'client-name': {
socketClientName.getState().setName(payload);
case 'client-id': {
if (typeof payload === 'string') {
setClientId(payload);
}
break;
}
case 'client-name': {
if (typeof payload === 'string') {
setClientName(payload);
}
break;
}
case 'client-rename': {
if (typeof payload === 'object') {
const id = getClientId();
if (payload.target && payload.target === id) {
setClientName(payload.name);
}
}
break;
}
case 'client-redirect': {
if (typeof payload === 'object') {
const id = getClientId();
if (payload.target && payload.target === id) {
setClientRedirect(payload.path);
}
}
break;
}
case 'client-list': {
setClients(payload);
if (!isProduction) {
ontimeQueryClient.setQueryData(CLIENT_LIST, payload);
}
break;
}
case 'ontime-log': {
addLog(payload as Log);
break;
+6
View File
@@ -17,4 +17,10 @@ declare global {
}
}
declare module 'react' {
interface CSSProperties {
[key: `--${string}`]: string | number;
}
}
export default {};
@@ -3,10 +3,11 @@ import { ErrorBoundary } from '@sentry/react';
import { useKeyDown } from '../../common/hooks/useKeyDown';
import AboutPanel from './panel/about-panel/AboutPanel';
import ClientControlPanel from './panel/client-control-panel/ClientControlPanel';
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
import GeneralPanel from './panel/general-panel/GeneralPanel';
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
import LogPanel from './panel/log-panel/LogPanel';
import NetworkLogPanel from './panel/network-panel/NetworkLogPanel';
import ProjectPanel from './panel/project-panel/ProjectPanel';
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
import SourcesPanel from './panel/sources-panel/SourcesPanel';
@@ -30,8 +31,9 @@ export default function AppSettings() {
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />}
{panel === 'integrations' && <IntegrationsPanel location={location} />}
{panel === 'client_control' && <ClientControlPanel />}
{panel === 'about' && <AboutPanel />}
{panel === 'log' && <LogPanel />}
{panel === 'network' && <NetworkLogPanel location={location} />}
{panel === 'shutdown' && <ShutdownPanel />}
</PanelContent>
</ErrorBoundary>
@@ -73,12 +73,12 @@ $inner-padding: 1rem;
top: 0;
z-index: 3;
box-shadow: 0 1px $white-10;
background-color: $gray-1350;
}
th {
font-weight: 400;
color: $gray-400;
background-color: $gray-1350;
white-space: nowrap;
text-transform: capitalize;
}
@@ -0,0 +1,24 @@
.fullWidth {
width: 100%;
}
.actionButtons {
display: flex;
gap: $element-spacing;
}
.halfWidth {
width: 50%;
white-space: nowrap;
}
.pathList {
width: 100%;
}
.badgeList {
width: 100%;
* {
margin-right: $element-spacing;
}
}
@@ -0,0 +1,15 @@
import * as Panel from '../PanelUtils';
import ClientList from './ClientList';
export default function ClientControlPanel() {
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Manage clients</Panel.SubHeader>
<Panel.Divider />
<ClientList />
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,147 @@
import { useState } from 'react';
import { Badge, Button, useDisclosure } from '@chakra-ui/react';
import { RedirectClientModal } from '../../../../common/components/client-modal/RedirectClientModal';
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
import { setClientRemote } from '../../../../common/hooks/useSocket';
import { useClientStore } from '../../../../common/stores/clientStore';
import * as Panel from '../PanelUtils';
import style from './ClientControlPanel.module.scss';
export default function ClientList() {
const id = useClientStore((store) => store.id);
const clients = useClientStore((store) => store.clients);
const { isOpen: isOpenRedirect, onOpen: onOpenRedirect, onClose: onCloseRedirect } = useDisclosure();
const { isOpen: isOpenRename, onOpen: onOpenRename, onClose: onCloseRename } = useDisclosure();
const { setIdentify } = setClientRemote;
const [targetId, setTargetId] = useState('');
const openRename = (targetId: string) => {
setTargetId(targetId);
onOpenRename();
};
const openRedirect = (targetId: string) => {
setTargetId(targetId);
onOpenRedirect();
};
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
const targetClient = clients[targetId];
return (
<>
{isOpenRedirect && (
<RedirectClientModal
id={targetId}
name={targetClient?.name}
path={targetClient?.path}
isOpen={isOpenRedirect}
onClose={onCloseRedirect}
/>
)}
{isOpenRename && (
<RenameClientModal id={targetId} name={targetClient?.name} isOpen={isOpenRename} onClose={onCloseRename} />
)}
<Panel.Section>
<Panel.Title>Ontime Clients ({ontimeClients.length})</Panel.Title>
<Panel.Table>
<thead>
<tr>
<td className={style.fullWidth}>Client Name (Connection ID)</td>
<td className={style.fullWidth}>Path</td>
<td />
</tr>
</thead>
<tbody>
{ontimeClients.map(([key, client]) => {
const { identify, name, path } = client;
const isCurrent = id === key;
return (
<tr key={key}>
<td className={style.badgeList}>
<Badge variant='outline' size='xs'>
{key}
</Badge>
{isCurrent && (
<Badge variant='outline' colorScheme='yellow' size='xs'>
self
</Badge>
)}
{name}
</td>
{isCurrent ? <td /> : <td className={style.pathList}>{path}</td>}
<td className={style.actionButtons}>
<Button
size='xs'
className={`${identify ? style.blink : ''}`}
isDisabled={isCurrent}
variant={identify ? 'ontime-filled' : 'ontime-subtle'}
data-testid={isCurrent ? '' : 'not-self-identify'}
onClick={() => {
setIdentify({ target: key, identify: !identify });
}}
>
Identify
</Button>
<Button
size='xs'
variant='ontime-subtle'
data-testid={isCurrent ? '' : 'not-self-rename'}
onClick={() => openRename(key)}
>
Rename
</Button>
<Button
size='xs'
variant='ontime-subtle'
isDisabled={isCurrent}
data-testid={isCurrent ? '' : 'not-self-redirect'}
onClick={() => openRedirect(key)}
>
Redirect
</Button>
</td>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
<Panel.Divider />
<Panel.Section>
<Panel.Title>Other Clients ({otherClients.length})</Panel.Title>
<Panel.Table>
<thead>
<tr>
<td className={style.halfWidth}>Client Name (Connection ID)</td>
<td className={style.halfWidth}>Client type</td>
</tr>
</thead>
<tbody>
{otherClients.map(([key, client]) => {
const { name, type } = client;
return (
<tr key={key}>
<td className={style.badgeList}>
<Badge variant='outline' size='sx'>
{key}
</Badge>
{name}
</td>
<td>{type}</td>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</>
);
}
@@ -67,7 +67,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
return (
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
<div className={style.column}>
<Panel.Description>Label</Panel.Description>
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
<Input
{...register('label', {
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
@@ -8,7 +9,7 @@ import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/utils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { isASCII, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils';
import { cycles } from './integrationUtils';
@@ -40,6 +41,13 @@ export default function OscIntegrations() {
control,
});
// update form if we get new data from server
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const onSubmit = async (values: OSCSettings) => {
if (values.portIn === values.portOut) {
setError('portIn', { message: 'OSC IN and OUT Ports cant be the same' });
@@ -221,7 +229,7 @@ export default function OscIntegrations() {
<th>Enabled</th>
<th>Cycle</th>
<th className={style.halfWidth}>Address</th>
<th className={style.halfWidth}>Payload</th>
<th className={style.halfWidth}>Arguments</th>
<th />
</tr>
</thead>
@@ -277,7 +285,7 @@ export default function OscIntegrations() {
{...register(`subscriptions.${index}.payload`, {
validate: {
oscStringIsAscii: (value) =>
isASCII.test(value) || 'OSC payloads only allow ASCII characters',
isASCIIorEmpty.test(value) || 'OSC arguments only allow ASCII characters',
},
})}
/>
@@ -14,4 +14,6 @@ export const cycles: CycleLabel[] = [
{ id: 5, label: 'Every second', value: 'onClock' },
{ id: 5, label: 'On Timer Update', value: 'onUpdate' },
{ id: 6, label: 'On Finish', value: 'onFinish' },
{ id: 7, label: 'On Warning', value: 'onWarning' },
{ id: 8, label: 'On Danger', value: 'onDanger' },
];
@@ -1,17 +0,0 @@
import * as Panel from '../PanelUtils';
import LogExport from './LogExport';
import InfoNif from './NetworkInterfaces';
export default function LogPanel() {
return (
<>
<Panel.Header>Log</Panel.Header>
<Panel.Section>
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
</Panel.Section>
<InfoNif />
<LogExport />
</>
);
}
@@ -6,7 +6,7 @@ import { handleLinks } from '../../../../common/utils/linkUtils';
import Log from '../../../log/Log';
import * as Panel from '../PanelUtils';
import style from './LogExport.module.scss';
import style from './NetworkLogExport.module.scss';
export default function LogExport() {
const extract = (event: MouseEvent) => {
@@ -0,0 +1,28 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore';
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
import * as Panel from '../PanelUtils';
import InfoNif from './NetworkInterfaces';
import LogExport from './NetworkLogExport';
export default function NetworkLogPanel({ location }: PanelBaseProps) {
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
return (
<>
<Panel.Header>Network</Panel.Header>
<Panel.Section>
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
</Panel.Section>
<InfoNif />
<div ref={logRef}>
<LogExport />
</div>
<div ref={clientsRef}>
<ClientControlPanel />
</div>
</>
);
}
@@ -50,7 +50,8 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
const handleSubmitCreate = async (values: ProjectCreateFormValues) => {
try {
setError(null);
const filename = values.title?.trim();
const filename = values.title ?? 'untitled';
await createProject({
...values,
@@ -1,5 +1,5 @@
.uploadSection,
.successSection {
.finishSection {
margin-top: 1rem;
display: flex;
padding: 3rem 1rem;
@@ -15,12 +15,18 @@
gap: 2rem;
}
.successSection {
color: $green-500;
.finishSection {
font-size: 1.5rem;
text-align: center;
flex-direction: column;
gap: 1rem;
.error {
color: $red-500;
}
.success {
color: $green-500;
}
}
.buttonRow {
@@ -76,8 +76,13 @@ export default function SourcesPanel() {
setAuthenticationStatus(result.authenticated);
setSheetId(result.sheetId);
if (result.authenticated === 'authenticated' && result.sheetId) {
const names = await getWorksheetNames(result.sheetId);
setWorksheets(names);
try {
const names = await getWorksheetNames(result.sheetId);
setWorksheets(names);
} catch (error) {
const message = maybeAxiosError(error);
setError(`Error getting worksheets: ${message}`);
}
}
}
setImportFlow('gsheet');
@@ -129,11 +134,20 @@ export default function SourcesPanel() {
await exportRundown(sheetId, importMap);
};
const resetFlow = () => {
setImportFlow('none');
setRundown(null);
setHasFile('none');
setWorksheets(null);
setCustomFields(null);
setError('');
};
const isExcelFlow = importFlow === 'excel';
const isGSheetFlow = importFlow === 'gsheet';
const isAuthenticated = authenticationStatus === 'authenticated';
const showInput = importFlow === 'none';
const showSuccess = importFlow === 'finished';
const showCompleted = importFlow === 'finished';
const showAuth = isGSheetFlow && !isAuthenticated;
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
const showReview = rundown !== null && customFields !== null;
@@ -184,10 +198,18 @@ export default function SourcesPanel() {
</div>
</>
)}
{showSuccess && (
<div className={style.successSection}>
<span>Import successful</span>
<Button variant='ontime-filled' size='sm' onClick={() => setImportFlow('none')}>
{showCompleted && (
<div className={style.finishSection}>
{error ? (
<span key='finish__error' className={style.error}>
Import failed
</span>
) : (
<span key='finish__success' className={style.success}>
Import successful
</span>
)}
<Button variant='ontime-filled' size='sm' onClick={resetFlow}>
Return
</Button>
</div>
@@ -195,6 +217,7 @@ export default function SourcesPanel() {
{showAuth && <GSheetSetup onCancel={cancelGSheetFlow} />}
{showImportMap && !showReview && (
<ImportMapForm
hasErrors={Boolean(error)}
isSpreadsheet={isExcelFlow}
onCancel={cancelImportMap}
onSubmitExport={handleSubmitExport}
@@ -15,14 +15,15 @@ import { convertToImportMap, getPersistedOptions, NamedImportMap, persistImportM
import style from '../SourcesPanel.module.scss';
interface ImportMapFormProps {
isSpreadsheet?: boolean;
hasErrors: boolean;
isSpreadsheet: boolean;
onCancel: () => void;
onSubmitExport: (importMap: ImportMap) => Promise<void>;
onSubmitImport: (importMap: ImportMap) => Promise<void>;
}
export default function ImportMapForm(props: ImportMapFormProps) {
const { isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props;
const { hasErrors, isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props;
const namedImportMap = getPersistedOptions();
const { revoke } = useGoogleSheet();
const {
@@ -78,7 +79,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
const isLoading = Boolean(loading);
const canSubmitSpreadsheet = isSpreadsheet && !isLoading;
const canSubmitGSheet = !isLoading;
const canSubmit = isValid && (canSubmitSpreadsheet || canSubmitGSheet);
const canSubmit = !hasErrors && isValid && (canSubmitSpreadsheet || canSubmitGSheet);
return (
<Panel.Section as='form' id='import-map'>
@@ -50,7 +50,21 @@ export const settingPanels: Readonly<SettingsOption[]> = [
{ id: 'integrations__http', label: 'HTTP settings' },
],
},
{ id: 'log', label: 'Log', split: true },
{
id: 'network',
label: 'Network',
split: true,
secondary: [
{
id: 'network__log',
label: 'Application log',
},
{
id: 'network__clients',
label: 'Manage clients',
},
],
},
{
id: 'about',
label: 'About',
@@ -1,5 +1,12 @@
.extraRow {
.label {
display: block;
margin-top: 2rem;
font-size: $inner-section-text-size;
color: $label-gray;
}
.controls {
margin-top: 0.25rem;
display: flex;
gap: 0.5rem;
margin-top: 2rem;
}
@@ -29,33 +29,36 @@ export function AuxTimer() {
};
return (
<div className={style.extraRow}>
<AuxTimerInput />
<TapButton onClick={toggleDirection} aspect='tight'>
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid='aux-timer-direction' />}
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid='aux-timer-direction' />}
</TapButton>
<label className={style.label}>
Auxiliary Timer
<div className={style.controls}>
<AuxTimerInput />
<TapButton onClick={toggleDirection} aspect='tight'>
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid='aux-timer-direction' />}
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid='aux-timer-direction' />}
</TapButton>
<TapButton
onClick={start}
theme={Playback.Play}
active={playback === SimplePlayback.Start}
disabled={!userCan.start}
>
<IoPlay data-testid='aux-timer-start' />
</TapButton>
<TapButton
onClick={pause}
theme={Playback.Pause}
active={playback === SimplePlayback.Pause}
disabled={!userCan.pause}
>
<IoPause data-testid='aux-timer-pause' />
</TapButton>
<TapButton onClick={stop} theme={Playback.Stop} disabled={!userCan.stop}>
<IoStop data-testid='aux-timer-stop' />
</TapButton>
</div>
<TapButton
onClick={start}
theme={Playback.Play}
active={playback === SimplePlayback.Start}
disabled={!userCan.start}
>
<IoPlay data-testid='aux-timer-start' />
</TapButton>
<TapButton
onClick={pause}
theme={Playback.Pause}
active={playback === SimplePlayback.Pause}
disabled={!userCan.pause}
>
<IoPause data-testid='aux-timer-pause' />
</TapButton>
<TapButton onClick={stop} theme={Playback.Stop} disabled={!userCan.stop}>
<IoStop data-testid='aux-timer-stop' />
</TapButton>
</div>
</label>
);
}
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { Playback } from 'ontime-types';
import { Playback, TimerPhase } from 'ontime-types';
import { dayInMs, millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
import { useTimer } from '../../../../common/hooks/useSocket';
@@ -43,8 +43,8 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
const finish = millisToString(expectedFinish);
const isRolling = playback === Playback.Roll;
const isWaiting = timer.secondaryTimer !== null && timer.secondaryTimer > 0 && timer.current === null;
const isOvertime = timer.current !== null && timer.current < 0;
const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime);
const rollLabel = isRolling ? 'Roll mode active' : '';
@@ -16,12 +16,9 @@ interface TimerDisplayProps {
export default function TimerDisplay(props: TimerDisplayProps) {
const { time } = props;
if (time == null) {
return <div className={style.timer}>{timerPlaceholder}</div>;
}
const isNegative = time < 0;
const display = millisToString(Math.abs(time), { fallback: timerPlaceholder });
const isNegative = (time ?? 0) < 0;
const display =
time == null ? timerPlaceholder : millisToString(time, { fallback: timerPlaceholder }).replace('-', '');
const classes = cx([style.timer, isNegative ? style.finished : null]);
return <div className={classes}>{display}</div>;
@@ -36,7 +36,6 @@ $table-header-font-size: calc(1rem - 3px);
min-width: 2rem;
text-align: right;
font-weight: 400;
position: sticky;
left: 0;
z-index: 1;
@@ -25,7 +25,7 @@ interface CuesheetProps {
}
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
const { followSelected, showSettings, showDelayBlock, showPrevious } = useCuesheetSettings();
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} });
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
@@ -112,7 +112,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
)}
<div ref={tableContainerRef} className={style.cuesheetContainer}>
<table className={style.cuesheet}>
<CuesheetHeader headerGroups={headerGroups} saveColumnOrder={reorder} />
<CuesheetHeader headerGroups={headerGroups} saveColumnOrder={reorder} showIndexColumn={showIndexColumn} />
<tbody>
{rowModel.rows.map((row) => {
const key = row.original.id;
@@ -165,6 +165,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
selectedRef={isSelected ? selectedRef : undefined}
skip={row.original.skip}
colour={row.original.colour}
showIndexColumn={showIndexColumn}
>
{row.getVisibleCells().map((cell) => {
return (
@@ -19,6 +19,7 @@ export default function CuesheetProgress() {
danger={timeDanger}
dangerColor={data!.dangerColor}
className={styles.progressOverride}
ignoreCssOverride
/>
);
}
@@ -1,4 +1,3 @@
import { Tooltip } from '@chakra-ui/react';
import {
closestCorners,
DndContext,
@@ -13,7 +12,6 @@ import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { SortableCell } from './SortableCell';
@@ -22,10 +20,11 @@ import style from '../Cuesheet.module.scss';
interface CuesheetHeaderProps {
headerGroups: HeaderGroup<OntimeRundownEntry>[];
saveColumnOrder: (fromId: string, toId: string) => void;
showIndexColumn: boolean;
}
export default function CuesheetHeader(props: CuesheetHeaderProps) {
const { headerGroups, saveColumnOrder } = props;
const { headerGroups, saveColumnOrder, showIndexColumn } = props;
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
@@ -61,11 +60,7 @@ export default function CuesheetHeader(props: CuesheetHeaderProps) {
return (
<DndContext key={key} sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleOnDragEnd}>
<tr key={headerGroup.id}>
<th className={style.indexColumn}>
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
#
</Tooltip>
</th>
<th className={style.indexColumn}>{showIndexColumn && '#'}</th>
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const width = header.getSize();
@@ -8,6 +8,7 @@ const pastOpacity = '0.2';
interface EventRowProps {
eventIndex: number;
showIndexColumn: boolean;
isPast?: boolean;
selectedRef?: MutableRefObject<HTMLTableRowElement | null>;
skip?: boolean;
@@ -15,7 +16,7 @@ interface EventRowProps {
}
function EventRow(props: PropsWithChildren<EventRowProps>) {
const { children, eventIndex, isPast, selectedRef, skip, colour } = props;
const { children, eventIndex, isPast, selectedRef, skip, colour, showIndexColumn } = props;
const ownRef = useRef<HTMLTableRowElement>(null);
const [isVisible, setIsVisible] = useState(false);
@@ -56,7 +57,7 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
ref={selectedRef ?? ownRef}
>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour.color }}>
{eventIndex}
{showIndexColumn && eventIndex}
</td>
{isVisible ? children : null}
</tr>
@@ -24,11 +24,13 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
const {
followSelected,
showIndexColumn,
toggleFollow,
showPrevious,
togglePreviousVisibility,
showDelayBlock,
showDelayedTimes,
toggleIndexColumn,
toggleDelayedTimes,
toggleDelayVisibility,
} = useCuesheetSettings();
@@ -63,6 +65,10 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
<Switch variant='ontime' size='sm' isChecked={showPrevious} onChange={() => togglePreviousVisibility()} />
Show past events
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showIndexColumn} onChange={() => toggleIndexColumn()} />
Show Event Order
</label>
</div>
<div className={style.sectionTitle}>Delay Flow</div>
<div className={style.options}>
@@ -4,6 +4,7 @@ import { booleanFromLocalStorage } from '../../../common/utils/localStorage';
interface CuesheetSettings {
showSettings: boolean;
showIndexColumn: boolean;
followSelected: boolean;
showPrevious: boolean;
showDelayBlock: boolean;
@@ -12,6 +13,7 @@ interface CuesheetSettings {
toggleSettings: (newValue?: boolean) => void;
toggleFollow: (newValue?: boolean) => void;
togglePreviousVisibility: (newValue?: boolean) => void;
toggleIndexColumn: (newValue?: boolean) => void;
toggleDelayVisibility: (newValue?: boolean) => void;
toggleDelayedTimes: (newValue?: boolean) => void;
}
@@ -27,11 +29,13 @@ enum CuesheetKeys {
Follow = 'ontime-cuesheet-follow-selected',
DelayVisibility = 'ontime-cuesheet-show-delay',
PreviousVisibility = 'ontime-cuesheet-show-previous',
ColumnIndex = 'ontime-cuesheet-show-index-column',
DelayedTimes = 'ontime-cuesheet-show-delayed',
}
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
showSettings: false,
showIndexColumn: booleanFromLocalStorage(CuesheetKeys.ColumnIndex, true),
followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false),
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
@@ -44,6 +48,12 @@ export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
localStorage.setItem(CuesheetKeys.Follow, String(followSelected));
return { followSelected };
}),
toggleIndexColumn: (newValue?: boolean) =>
set((state) => {
const showIndexColumn = toggle(state.showIndexColumn, newValue);
localStorage.setItem(CuesheetKeys.ColumnIndex, String(showIndexColumn));
return { showIndexColumn };
}),
togglePreviousVisibility: (newValue?: boolean) =>
set((state) => {
const showPrevious = toggle(state.showPrevious, newValue);
+21 -21
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CustomField, CustomFields, isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import Empty from '../../common/components/state/Empty';
@@ -27,11 +27,9 @@ import style from './Operator.module.scss';
const selectedOffset = 50;
export type Subscribed = { id: string; label: string; colour: string; value: string }[];
type TitleFields = Pick<OntimeEvent, 'title'>;
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { fieldLabel?: string; fieldValue: string };
export type PartialEdit = EditEvent & {
field: keyof CustomFields;
};
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { subscriptions: Subscribed };
export default function Operator() {
const { data, status } = useRundown();
@@ -45,7 +43,7 @@ export default function Operator() {
const { data: settings } = useSettings();
const [showEditPrompt, setShowEditPrompt] = useState(false);
const [editEvent, setEditEvent] = useState<PartialEdit | null>(null);
const [editEvent, setEditEvent] = useState<EditEvent | null>(null);
const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null);
@@ -102,16 +100,9 @@ export default function Operator() {
debouncedHandleScroll();
};
const handleEdit = useCallback(
(event: EditEvent) => {
const field = searchParams.get('subscribe') as keyof CustomField | null;
if (field) {
setEditEvent({ ...event, field });
}
},
[searchParams],
);
const handleEdit = useCallback((event: EditEvent) => {
setEditEvent({ ...event });
}, []);
const missingData = !data || !customFields || !projectData;
const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending';
@@ -122,8 +113,13 @@ export default function Operator() {
// get fields which the user subscribed to
const shouldEdit = searchParams.get('shouldEdit');
const subscribe = searchParams.get('subscribe') as keyof CustomFields;
const canEdit = shouldEdit && subscribe;
const subscriptions = (searchParams.get('subscribe') ?? '')
.toLocaleLowerCase()
.split('_')
.filter((value) => Object.hasOwn(customFields, value));
const canEdit = shouldEdit && subscriptions;
const main = searchParams.get('main') as keyof TitleFields | null;
const secondary = searchParams.get('secondary');
@@ -171,9 +167,14 @@ export default function Operator() {
return null;
}
const mainField = main ? entry?.[main] || entry.title : entry.title;
const mainField = main ? getPropertyValue(entry, main) ?? '' : entry.title;
const secondaryField = getPropertyValue(entry, secondary) ?? '';
const subscribedData = entry.custom[subscribe];
const subscribedData = subscriptions
? subscriptions.map((id) => {
const { label, colour } = customFields[id];
return { id, label, colour, value: entry.custom[id] };
})
: null;
return (
<OperatorEvent
@@ -189,7 +190,6 @@ export default function Operator() {
delay={entry.delay}
isSelected={isSelected}
subscribed={subscribedData}
subscribeLabel={subscribe}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
onLongPress={canEdit ? handleEdit : () => undefined}
@@ -1,48 +1,69 @@
import { useRef, useState } from 'react';
import { Button, Textarea } from '@chakra-ui/react';
import { OntimeEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction';
import type { PartialEdit } from '../Operator';
import type { EditEvent } from '../Operator';
import style from './EditModal.module.scss';
interface EditModalProps {
event: PartialEdit;
event: EditEvent;
onClose: () => void;
}
export default function EditModal(props: EditModalProps) {
const { event, onClose } = props;
const { updateCustomField } = useEventAction();
const { updateEvent } = useEventAction();
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const inputRef = useRef<HTMLTextAreaElement[]>(new Array<HTMLTextAreaElement>());
const handleSave = async () => {
if (!inputRef.current) return;
setLoading(true);
const newValue = inputRef.current?.value;
if (newValue === undefined) {
return;
const patchObject: Partial<OntimeEvent> = { id: event.id };
inputRef.current.forEach((element) => {
if (element.dataset.field && element.defaultValue != element.value) {
if (patchObject.custom) {
patchObject.custom[element.dataset.field] = element.value;
} else {
Object.assign(patchObject, { custom: { [element.dataset.field]: element.value } });
}
}
});
if (patchObject.custom) {
await updateEvent(patchObject);
}
await updateCustomField(event.id, event.field, newValue);
setLoading(false);
onClose();
};
const fieldLabel = event?.fieldLabel ?? event.field;
return (
<div className={style.editModal}>
<div>{`Editing field ${fieldLabel} in cue ${event.cue}`}</div>
<Textarea
ref={inputRef}
variant='ontime-filled'
placeholder={`Add value for ${fieldLabel} field`}
defaultValue={event.fieldValue}
isDisabled={loading}
resize='none'
/>
<div>{`Editing fields in cue ${event.cue}`}</div>
{event.subscriptions.map((field) => {
return (
<div key={field.label}>
<label>{field.label}</label>
<Textarea
ref={(element) => {
if (element) inputRef.current.push(element);
}}
variant='ontime-filled'
placeholder={`Add value for ${field.label} field`}
defaultValue={field.value}
data-field={field.id}
isDisabled={loading}
resize='none'
/>
</div>
);
})}
<div className={style.buttonRow}>
<Button variant='ontime-subtle' onClick={onClose} isDisabled={loading}>
Cancel
@@ -20,9 +20,9 @@
grid-template-rows: auto auto auto;
column-gap: 0.5rem;
grid-template-areas:
"binder main schedule"
"binder secondary running"
"binder fields fields";
'binder main schedule'
'binder secondary running'
'binder fields fields';
&.subscribed {
background-color: $gray-1250;
@@ -93,16 +93,25 @@
font-weight: 400;
color: $ui-black;
margin: 0.25rem 0;
display: flex;
flex-wrap: wrap;
.field {
font-weight: 600;
padding: 0 0.25rem;
background-color: var(--operator-highlight-override, $orange-600);
margin-right: 0.5rem;
margin-right: 0.25rem;
}
.noColour {
outline: 0.15rem solid var(--operator-highlight-override, $ui-white);
outline-offset: -0.15rem;
padding-right: 0.3rem;
color: $ui-white;
}
.value {
color: $orange-500;
color: var(--operator-highlight-override, $ui-white);
margin-right: 1rem;
}
}
@@ -6,7 +6,7 @@ import { useTimer } from '../../../common/hooks/useSocket';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import ClockTime from '../../viewers/common/clock-time/ClockTime';
import RunningTime from '../../viewers/common/running-time/RunningTime';
import type { EditEvent } from '../Operator';
import type { EditEvent, Subscribed } from '../Operator';
import style from './OperatorEvent.module.scss';
@@ -21,8 +21,7 @@ interface OperatorEventProps {
duration: number;
delay?: number;
isSelected: boolean;
subscribed?: string;
subscribeLabel: string;
subscribed: Subscribed | null;
isPast: boolean;
selectedRef?: RefObject<HTMLDivElement>;
onLongPress: (event: EditEvent) => void;
@@ -47,7 +46,6 @@ function OperatorEvent(props: OperatorEventProps) {
delay,
isSelected,
subscribed,
subscribeLabel: subscribedAlias,
isPast,
selectedRef,
onLongPress,
@@ -56,7 +54,9 @@ function OperatorEvent(props: OperatorEventProps) {
const handleLongPress = (event?: SyntheticEvent) => {
// we dont have an event out of useLongPress
event?.preventDefault();
onLongPress({ id, cue, fieldLabel: subscribedAlias, fieldValue: subscribed ?? '' });
if (subscribed) {
onLongPress({ id, cue, subscriptions: subscribed });
}
};
const mouseHandlers = useLongPress(handleLongPress, { threshold: 800 });
@@ -89,12 +89,22 @@ function OperatorEvent(props: OperatorEventProps) {
</span>
<div className={style.fields}>
{subscribed && (
<>
<span className={style.field}>{subscribedAlias}</span>
<span className={style.value}>{subscribed}</span>
</>
)}
{subscribed &&
subscribed
.filter((field) => field.value)
.map((field) => {
const fieldClasses = cx([style.field, !field.colour ? style.noColour : null]);
return (
<div key={field.id}>
<span className={fieldClasses} style={{ backgroundColor: field.colour }}>
{field.label}
</span>
<span className={style.value} style={{ color: field.colour }}>
{field.value}
</span>
</div>
);
})}
</div>
</div>
);
@@ -105,5 +105,5 @@
}
.progressOverride {
border-radius: 0;
--progress-bar-br: 0;
}
@@ -24,6 +24,7 @@ export default function StatusBarProgress(props: StatusBarProgressProps) {
danger={timeDanger}
dangerColor={viewSettings.dangerColor}
className={styles.progressOverride}
ignoreCssOverride
/>
);
}
+55 -40
View File
@@ -14,6 +14,7 @@ import { cloneEvent } from '../../common/utils/eventsManager';
import QuickAddBlock from './quick-add-block/QuickAddBlock';
import RundownEmpty from './RundownEmpty';
import { useEventSelection } from './useEventSelection';
import style from './Rundown.module.scss';
@@ -33,7 +34,9 @@ export default function Rundown({ data }: RundownProps) {
const { entryCopyId, setEntryCopyId } = useEntryCopy();
// cursor
const { cursor, mode: appMode, setCursor } = useAppMode();
const { mode: appMode } = useAppMode();
const { clearSelectedEvents, setSelectedEvents, cursor } = useEventSelection();
const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run });
@@ -44,34 +47,42 @@ export default function Rundown({ data }: RundownProps) {
const deleteAtCursor = useCallback(
(cursor: string | null) => {
if (!cursor) return;
const previous = getPreviousNormal(rundown, order, cursor).entry?.id ?? null;
const { entry, index } = getPreviousNormal(rundown, order, cursor);
deleteEvent([cursor]);
setCursor(previous);
if (entry && index !== null) {
setSelectedEvents({ id: entry.id, selectMode: 'click', index });
}
},
[deleteEvent, order, rundown, setCursor],
[rundown, order, deleteEvent, setSelectedEvents],
);
const insertAtCursor = useCallback(
(type: SupportedEvent | 'clone', cursor: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(rundown, order, cursor ?? '').entry?.id ?? null : cursor;
if (adjustedCursor === null) {
const insertCopyAtId = useCallback(
(atId: string | null, copyId: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(rundown, order, atId ?? '').entry?.id ?? null : atId;
if (copyId === null) {
// we cant clone without selection
if (type === 'clone') {
return;
}
return;
}
const cloneEntry = rundown[copyId];
if (cloneEntry?.type === SupportedEvent.Event) {
//if we don't have a cursor add the new event on top
const newEvent = cloneEvent(cloneEntry, adjustedCursor ?? undefined);
addEvent(newEvent);
}
},
[addEvent, order, rundown],
);
const insertAtId = useCallback(
(type: SupportedEvent, id: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(rundown, order, id ?? '').entry?.id ?? null : id;
if (adjustedCursor === null) {
// the only thing to do is adding an event at top
addEvent({ type });
return;
}
if (type === 'clone') {
const cursorEvent = rundown[adjustedCursor];
if (cursorEvent?.type === SupportedEvent.Event) {
const newEvent = cloneEvent(cursorEvent, cursorEvent.id);
addEvent(newEvent);
}
} else if (type === SupportedEvent.Event) {
if (type === SupportedEvent.Event) {
const newEvent = {
type: SupportedEvent.Event,
};
@@ -92,23 +103,26 @@ export default function Rundown({ data }: RundownProps) {
if (order.length < 1) {
return;
}
let newCursor: string | undefined;
let newCursor: string | null;
let newIndex: number | null;
if (cursor === null) {
// there is no cursor, we select the first or last depending on direction if it exists
newCursor = direction === 'up' ? getLastNormal(rundown, order)?.id : getFirstNormal(rundown, order)?.id;
newCursor =
(direction === 'up' ? getLastNormal(rundown, order)?.id : getFirstNormal(rundown, order)?.id) ?? null;
newIndex = direction === 'up' ? order.length : 0;
} else {
// otherwise we select the next or previous
newCursor =
direction === 'up'
? getPreviousNormal(rundown, order, cursor).entry?.id
: getNextNormal(rundown, order, cursor).entry?.id;
const selected =
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
newCursor = selected.entry?.id ?? null;
newIndex = selected.index;
}
if (newCursor) {
setCursor(newCursor);
if (newCursor && newIndex !== null) {
setSelectedEvents({ id: newCursor, selectMode: 'click', index: newIndex });
}
},
[order, rundown, setCursor],
[order, rundown, setSelectedEvents],
);
const moveEntry = useCallback(
@@ -134,22 +148,22 @@ export default function Rundown({ data }: RundownProps) {
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }],
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }],
['Escape', () => setCursor(null), { preventDefault: true }],
['Escape', () => clearSelectedEvents(), { preventDefault: true }],
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
['alt + E', () => insertAtCursor(SupportedEvent.Event, cursor), { preventDefault: true }],
['alt + shift + E', () => insertAtCursor(SupportedEvent.Event, cursor, true), { preventDefault: true }],
['alt + E', () => insertAtId(SupportedEvent.Event, cursor), { preventDefault: true }],
['alt + shift + E', () => insertAtId(SupportedEvent.Event, cursor, true), { preventDefault: true }],
['alt + B', () => insertAtCursor(SupportedEvent.Block, cursor), { preventDefault: true }],
['alt + shift + B', () => insertAtCursor(SupportedEvent.Block, cursor, true), { preventDefault: true }],
['alt + B', () => insertAtId(SupportedEvent.Block, cursor), { preventDefault: true }],
['alt + shift + B', () => insertAtId(SupportedEvent.Block, cursor, true), { preventDefault: true }],
['alt + D', () => insertAtCursor(SupportedEvent.Delay, cursor), { preventDefault: true }],
['alt + shift + D', () => insertAtCursor(SupportedEvent.Delay, cursor, true), { preventDefault: true }],
['alt + D', () => insertAtId(SupportedEvent.Delay, cursor), { preventDefault: true }],
['alt + shift + D', () => insertAtId(SupportedEvent.Delay, cursor, true), { preventDefault: true }],
['mod + C', () => setEntryCopyId(cursor), { preventDefault: true }],
['mod + V', () => insertAtCursor('clone', entryCopyId), { preventDefault: true }],
['mod + shift + V', () => insertAtCursor('clone', entryCopyId, true), { preventDefault: true }],
['mod + V', () => insertCopyAtId(cursor, entryCopyId), { preventDefault: true }],
['mod + shift + V', () => insertCopyAtId(cursor, entryCopyId, true), { preventDefault: true }],
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
]);
@@ -165,8 +179,9 @@ export default function Rundown({ data }: RundownProps) {
if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
return;
}
setCursor(featureData.selectedEventId);
}, [appMode, featureData.selectedEventId, setCursor]);
const index = order.findIndex((id) => id === featureData.selectedEventId);
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
}, [appMode, featureData.selectedEventId, order, setSelectedEvents]);
const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
@@ -185,7 +200,7 @@ export default function Rundown({ data }: RundownProps) {
};
if (statefulEntries.length < 1) {
return <RundownEmpty handleAddNew={() => insertAtCursor(SupportedEvent.Event, null)} />;
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
}
let previousStart: MaybeNumber = null;
@@ -3,7 +3,6 @@ import { MaybeNumber, OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent
import { useEventAction } from '../../common/hooks/useEventAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { useAppMode } from '../../common/stores/appModeStore';
import { useEmitLog } from '../../common/stores/logger';
import { cloneEvent } from '../../common/utils/eventsManager';
@@ -56,22 +55,15 @@ export default function RundownEntry(props: RundownEntryProps) {
} = props;
const { emitError } = useEmitLog();
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
const cursor = useAppMode((state) => state.cursor);
const setCursor = useAppMode((state) => state.setCursor);
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
const removeOpenEvent = useCallback(() => {
unselect(data.id);
// clear cursor if we are deleting the event that is currently selected
if (cursor === data.id) {
setCursor(null);
}
}, [unselect, data.id, cursor, setCursor]);
}, [unselect, data.id]);
const clearMultiSelection = useCallback(() => {
clearSelectedEvents();
setCursor(null);
}, [clearSelectedEvents, setCursor]);
}, [clearSelectedEvents]);
// Create / delete new events
type FieldValue = {
@@ -11,7 +11,6 @@ import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useAppMode } from '../../../common/stores/appModeStore';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import type { EventItemActions } from '../RundownEntry';
import { useEventIdSwapping } from '../useEventIdSwapping';
@@ -88,7 +87,6 @@ export default function EventBlock(props: EventBlockProps) {
} = props;
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const { selectedEvents, setSelectedEvents } = useEventSelection();
const setCursor = useAppMode((state) => state.setCursor);
const handleRef = useRef<null | HTMLSpanElement>(null);
const [isVisible, setIsVisible] = useState(false);
@@ -231,7 +229,6 @@ export default function EventBlock(props: EventBlockProps) {
const index = eventIndex - 1;
const editMode = getSelectionMode(event);
setSelectedEvents({ id: eventId, index, selectMode: editMode });
setCursor(eventId);
};
return (
@@ -40,7 +40,7 @@ function EventEditorEmpty() {
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>/</AuxKey>
<Kbd></Kbd>
<Kbd></Kbd>
</td>
</tr>
<tr>
@@ -56,7 +56,7 @@ function EventEditorEmpty() {
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>V</Kbd>
</td>
@@ -74,7 +74,7 @@ function EventEditorEmpty() {
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<Kbd>Backspace</Kbd>
</td>
</tr>
<tr className={style.spacer} />
@@ -91,7 +91,7 @@ function EventEditorEmpty() {
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>E</Kbd>
</td>
@@ -109,7 +109,7 @@ function EventEditorEmpty() {
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>B</Kbd>
</td>
@@ -127,7 +127,7 @@ function EventEditorEmpty() {
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
@@ -17,7 +17,6 @@ import { useEventSelection } from '../useEventSelection';
export default function RundownMenu() {
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const setCursor = useAppMode((state) => state.setCursor);
const appMode = useAppMode((state) => state.mode);
const { deleteAllEvents } = useEventAction();
@@ -27,9 +26,8 @@ export default function RundownMenu() {
const deleteAll = useCallback(() => {
deleteAllEvents();
clearSelectedEvents();
setCursor(null);
onClose();
}, [clearSelectedEvents, deleteAllEvents, onClose, setCursor]);
}, [clearSelectedEvents, deleteAllEvents, onClose]);
return (
<>
@@ -1,5 +1,5 @@
import { MouseEvent } from 'react';
import { isOntimeEvent, OntimeEvent, RundownCached } from 'ontime-types';
import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, RundownCached } from 'ontime-types';
import { create } from 'zustand';
import { RUNDOWN } from '../../common/api/constants';
@@ -10,7 +10,8 @@ export type SelectionMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore {
selectedEvents: Set<string>;
anchoredIndex: number | null;
anchoredIndex: MaybeNumber;
cursor: MaybeString;
setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
@@ -20,13 +21,14 @@ interface EventSelectionStore {
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
cursor: null,
setSelectedEvents: (selectionArgs) => {
const { id, index, selectMode } = selectionArgs;
const { selectedEvents, anchoredIndex } = get();
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index });
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id });
}
// on ctrl + click, we toggle the selection of that event
@@ -39,6 +41,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
return set({
selectedEvents: selectedEvents.add(id),
anchoredIndex: index,
cursor: id,
});
}
@@ -83,7 +86,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null }),
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null }),
clearMultiSelect: () => {
const { selectedEvents } = get();
const [firstSelected] = selectedEvents;
@@ -82,8 +82,6 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
...timer,
clock,
timerType: eventNow?.timerType ?? null,
timeWarning: eventNow?.timeWarning ?? null,
timeDanger: eventNow?.timeWarning ?? null,
};
// prevent render until we get all the data we need
@@ -53,10 +53,6 @@ export function getPropertyValue(event: OntimeEvent | null, property: MaybeStrin
return event[property as keyof OntimeEvent] as string;
}
export function capitaliseFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
type FormattingOptions = {
removeSeconds: boolean;
removeLeadingZero: boolean;
@@ -84,7 +80,9 @@ export function getFormattedTimer(
}
let display = millisToString(timeToParse);
display = removeLeadingZero(display);
if (options.removeLeadingZero) {
display = removeLeadingZero(display);
}
if (options.removeSeconds) {
display = formatDisplayWithMinutes(display, localisedMinutes);
@@ -20,11 +20,11 @@
}
.timer {
opacity: 1;
font-family: var(--font-family-bold-override, $timer-bold-font-family) ;
font-size: 20vw;
position: relative;
color: var(--timer-color-override, $timer-color);
opacity: 1;
color: var(--timer-color-override, var(--phase-color));
transition: $viewer-transition-time;
transition-property: opacity;
background-color: transparent;
@@ -42,42 +42,6 @@
/* =================== OVERLAY ===================*/
.message-overlay {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: $viewer-overlay-bg-color;
z-index: -1;
opacity: 0;
transition: $viewer-transition-time;
&--active {
opacity: 1;
transition: $viewer-transition-time;
transition-property: opacity;
z-index: 2;
}
}
.message {
width: inherit;
padding: 2vw;
position: absolute;
top: 50%;
left: 50%;
color: white;
transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
font-size: 15vw;
line-height: 30vh;
text-align: center;
font-weight: 600;
}
.end-message {
text-align: center;
font-size: 12vw;
@@ -1,5 +1,5 @@
import { useSearchParams } from 'react-router-dom';
import { Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants';
import { MINIMAL_TIMER_OPTIONS } from '../../../common/components/view-params-editor/constants';
@@ -15,13 +15,12 @@ import './MinimalTimer.scss';
interface MinimalTimerProps {
isMirrored: boolean;
pres: TimerMessage;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
}
export default function MinimalTimer(props: MinimalTimerProps) {
const { isMirrored, pres, time, viewSettings } = props;
const { isMirrored, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams();
@@ -117,9 +116,6 @@ export default function MinimalTimer(props: MinimalTimerProps) {
const hideOvertime = searchParams.get('hideovertime');
userOptions.hideOvertime = isStringBoolean(hideOvertime);
const hideMessagesOverlay = searchParams.get('hidemessages');
userOptions.hideMessagesOverlay = isStringBoolean(hideMessagesOverlay);
const hideEndMessage = searchParams.get('hideendmessage');
userOptions.hideEndMessage = isStringBoolean(hideEndMessage);
@@ -128,18 +124,16 @@ export default function MinimalTimer(props: MinimalTimerProps) {
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
const showOverlay = pres.text !== '' && pres.visible;
const isPlaying = time.playback !== Playback.Pause;
const showEndMessage = (time.current ?? 0) < 0 && viewSettings.endMessage && !hideEndMessage;
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
const showFinished = finished && !userOptions?.hideOvertime && (time.timerType !== TimerType.Clock || showEndMessage);
const shouldShowModifiers = time.timerType !== TimerType.Clock && time.timerType !== TimerType.CountUp;
const finished = time.phase === TimerPhase.Overtime;
const showEndMessage = finished && viewSettings.endMessage && !hideEndMessage;
const showFinished = finished && !userOptions?.hideOvertime && (shouldShowModifiers || showEndMessage);
const showProgress = time.playback !== Playback.Stop;
const showWarning = (time.current ?? 1) < (time.timeWarning ?? 0);
const showDanger = (time.current ?? 1) < (time.timeDanger ?? 0);
const showBlinking = pres.blink;
const showBlackout = pres.blackout;
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
let timerColor = viewSettings.normalColor;
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
@@ -155,11 +149,8 @@ export default function MinimalTimer(props: MinimalTimerProps) {
const timerFontSize = (89 / (stageTimerCharacters - 1)) * (userOptions.size || 1);
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''} ${
showBlinking ? (showOverlay ? '' : 'blink') : ''
}`;
const baseClasses = `minimal-timer ${isMirrored ? 'mirror' : ''} ${showBlackout ? 'blackout' : ''}`;
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
const baseClasses = `minimal-timer ${isMirrored ? 'mirror' : ''}`;
return (
<div
className={showFinished ? `${baseClasses} minimal-timer--finished` : baseClasses}
@@ -171,25 +162,18 @@ export default function MinimalTimer(props: MinimalTimerProps) {
data-testid='minimal-timer'
>
<ViewParamsEditor paramFields={MINIMAL_TIMER_OPTIONS} />
{!hideMessagesOverlay && (
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
</div>
)}
{showEndMessage ? (
<div className={`end-message ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`}>
{viewSettings.endMessage}
</div>
<div className='end-message'>{viewSettings.endMessage}</div>
) : (
<div
className={timerClasses}
style={{
color: timerColor,
fontSize: `${timerFontSize}vw`,
fontFamily: userOptions.font,
top: userOptions.top,
left: userOptions.left,
backgroundColor: userOptions.textBackground,
'--phase-color': timerColor,
}}
>
{display}
@@ -109,7 +109,7 @@
.timer {
opacity: 1;
font-family: var(--font-family-override, $viewer-font-family);
color: var(--timer-color-override, $timer-color);
color: var(--timer-color-override, var(--phase-color));
line-height: 0.9em;
text-align: center;
letter-spacing: 0.05em;
@@ -7,6 +7,7 @@ import {
Playback,
Settings,
TimerMessage,
TimerPhase,
TimerType,
ViewSettings,
} from 'ontime-types';
@@ -76,6 +77,7 @@ export default function Timer(props: TimerProps) {
hideMessage: false,
hideTimerSeconds: false,
hideClockSeconds: false,
removeLeadingZeros: true,
};
const hideClock = searchParams.get('hideClock');
@@ -97,23 +99,31 @@ export default function Timer(props: TimerProps) {
const hideTimerSeconds = searchParams.get('hideTimerSeconds');
userOptions.hideTimerSeconds = isStringBoolean(hideTimerSeconds);
const showLeadingZeros = searchParams.get('showLeadingZeros');
userOptions.removeLeadingZeros = !isStringBoolean(showLeadingZeros);
const secondarySource = searchParams.get('secondary-src');
const secondaryTextNow = getPropertyValue(eventNow, secondarySource);
const secondaryTextNext = getPropertyValue(eventNext, secondarySource);
const main = searchParams.get('main');
const mainFieldNow = (main ? getPropertyValue(eventNow, main) : eventNow?.title) ?? '';
const mainFieldNext = (main ? getPropertyValue(eventNext, main) : eventNext?.title) ?? '';
const showOverlay = pres.text !== '' && pres.visible;
const isPlaying = time.playback !== Playback.Pause;
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
const finished = time.phase === TimerPhase.Overtime;
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
const showEndMessage = (time.current ?? 1) < 0 && viewSettings.endMessage;
const shouldShowModifiers = time.timerType !== TimerType.Clock && time.timerType !== TimerType.CountUp;
const showEndMessage = finished && viewSettings.endMessage;
const showProgress = time.playback !== Playback.Stop;
const showFinished = finished && (time.timerType !== TimerType.Clock || showEndMessage);
const showWarning = (time.current ?? 1) < (eventNow?.timeWarning ?? 0);
const showDanger = (time.current ?? 1) < (eventNow?.timeDanger ?? 0);
const showFinished = finished && (shouldShowModifiers || showEndMessage);
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
const showBlinking = pres.blink;
const showBlackout = pres.blackout;
const showClock = time.timerType !== TimerType.Clock;
@@ -126,7 +136,7 @@ export default function Timer(props: TimerProps) {
const stageTimer = getTimerByType(viewSettings.freezeEnd, time);
const display = getFormattedTimer(stageTimer, time.timerType, getLocalizedString('common.minutes'), {
removeSeconds: userOptions.hideTimerSeconds,
removeLeadingZero: true,
removeLeadingZero: userOptions.removeLeadingZeros,
});
const stageTimerCharacters = display.replace('/:/g', '').length;
@@ -169,7 +179,7 @@ export default function Timer(props: TimerProps) {
className={timerClasses}
style={{
fontSize: `${timerFontSize}vw`,
color: timerColor,
'--phase-color': timerColor,
}}
>
{display}
@@ -209,7 +219,7 @@ export default function Timer(props: TimerProps) {
animate='visible'
exit='exit'
>
<TitleCard label='now' title={eventNow.title} secondary={secondaryTextNow} />
<TitleCard label='now' title={mainFieldNow} secondary={secondaryTextNow} />
</motion.div>
)}
</AnimatePresence>
@@ -224,7 +234,7 @@ export default function Timer(props: TimerProps) {
animate='visible'
exit='exit'
>
<TitleCard label='next' title={eventNext.title} secondary={secondaryTextNext} />
<TitleCard label='next' title={mainFieldNext} secondary={secondaryTextNext} />
</motion.div>
)}
</AnimatePresence>
-1
View File
@@ -37,7 +37,6 @@ $bg-container-onlight: $gray-100;
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
$box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
$box-shadow-right: rgba(0, 0, 0, 0.15) 3px 0 3px 0;
$large-bottom-drawer-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
$large-top-drawer-shadow: rgba(0, 0, 0, 0.35) 0 1px 6px 3px;
$modal-note-color: $gray-700;
+1 -1
View File
@@ -3,7 +3,7 @@ module.exports = {
shutdownCode: 99,
},
reactAppUrl: {
development: (port = 4001) => `http://localhost:${port}`,
development: (port = 3000) => `http://localhost:${port}`,
production: (port = 4001) => `http://localhost:${port}`,
},
server: {
+40 -9
View File
@@ -30,6 +30,10 @@ let win;
let splash;
let tray = null;
/**
* Coordinates the node process startup
* @returns {number} server port - the port at which the backend has been started at
*/
async function startBackend() {
// in dev mode, we expect both UI and server to be running
if (!isProduction) {
@@ -41,7 +45,7 @@ async function startBackend() {
await initAssets();
const result = await startServer();
const result = await startServer(escalateError);
loaded = result.message;
await startIntegrations();
@@ -62,6 +66,9 @@ function showNotification(title, text) {
}).show();
}
/**
* Terminate node service and close electron app
*/
function appShutdown() {
// terminate node service
(async () => {
@@ -76,16 +83,30 @@ function appShutdown() {
app.quit();
}
/**
* Sets Ontime window in focus
*/
function bringToFront() {
win.show();
win.focus();
}
/**
* Coordinates the shutdown process
*/
function askToQuit() {
bringToFront();
win.send('user-request-shutdown');
}
/**
* Allows processes to escalate errors to be shown in electron
* @param {string} error
*/
function escalateError(error) {
dialog.showErrorBox('An unrecoverable error occurred', error);
}
// Ensure there isn't another instance of the app running already
const lock = app.requestSingleInstanceLock();
if (!lock) {
@@ -103,6 +124,9 @@ if (!lock) {
});
}
/**
* Coordinates creation of electron windows (splash and main)
*/
function createWindow() {
splash = new BrowserWindow({
width: 333,
@@ -191,12 +215,16 @@ app.whenReady().then(() => {
console.log('ERROR: Ontime failed to start', error);
});
// recreate window if no others open
/**
* recreate window if no others open
*/
app.on('activate', () => {
win.show();
});
// Hide on close
/**
* Hide on close
*/
win.on('close', function (event) {
event.preventDefault();
if (!isQuitting) {
@@ -213,12 +241,11 @@ app.whenReady().then(() => {
const trayMenuTemplate = getTrayMenu(bringToFront, askToQuit);
const trayContextMenu = Menu.buildFromTemplate(trayMenuTemplate);
tray.setContextMenu(trayContextMenu);
});
// unregister shortcuts before quitting
/**
* Unregister shortcuts before quitting
*/
app.once('will-quit', () => {
globalShortcut.unregisterAll();
});
@@ -235,7 +262,9 @@ ipcMain.on('shutdown', () => {
appShutdown();
});
// Window manipulation
/**
* Handles requests to set window properties
*/
ipcMain.on('set-window', (event, arg) => {
switch (arg) {
case 'show-dev':
@@ -246,7 +275,9 @@ ipcMain.on('set-window', (event, arg) => {
}
});
// Open links external
/**
* Handles requests to open external links
*/
ipcMain.on('send-to-link', (event, arg) => {
shell.openExternal(arg);
});
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.0.4",
"version": "3.3.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -12,19 +12,20 @@
"license": "AGPL-3.0-only",
"main": "main.js",
"devDependencies": {
"electron": "^28.0.0",
"electron": "^28.3.3",
"electron-builder": "^24.13.3",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.0.3"
},
"scripts": {
"postinstall": "",
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"dev:electron": "cross-env NODE_ENV=development electron .",
"dev": "cross-env NODE_ENV=development electron .",
"dist-win": "electron-builder --publish=never --x64 --win",
"dist-mac": "electron-builder --publish=never --mac",
"dist-mac:local": "electron-builder --publish=never --mac -c.mac.identity=null",
"dist-linux": "electron-builder --publish=never --x64 --linux",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
},
+1 -1
View File
@@ -124,7 +124,7 @@ function getApplicationMenu(isMac, askToQuit, urlBase) {
{
label: 'Operator',
click: async () => {
await shell.openExternal(`${urlBase}/operator`);
await shell.openExternal(`${urlBase}/op`);
},
},
],
+7 -9
View File
@@ -2,11 +2,10 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.0.4",
"version": "3.3.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
"body-parser": "^1.20.0",
"cors": "^2.8.5",
"dotenv": "^16.0.1",
"express": "^4.18.2",
@@ -20,8 +19,7 @@
"node-osc": "^9.0.2",
"node-xlsx": "^0.23.0",
"ontime-utils": "workspace:*",
"passport": "^0.6.0",
"passport-local": "~1.0.0",
"sanitize-filename": "^1.6.3",
"steno": "^3.1.0",
"ts-essentials": "^9.4.1",
"ws": "^8.13.0"
@@ -34,14 +32,14 @@
"@types/node-osc": "^6.0.2",
"@types/websocket": "^1.0.5",
"@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@typescript-eslint/eslint-plugin": "^v7.12.0",
"@typescript-eslint/parser": "^7.12.0",
"esbuild": "^0.19.10",
"eslint": "^8.53.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint": "^8.56.0",
"eslint-plugin-prettier": "^5.1.3",
"nodemon": "^2.0.20",
"ontime-types": "workspace:*",
"prettier": "^3.0.3",
"prettier": "^3.3.1",
"server-timing": "^3.3.3",
"shx": "^0.3.4",
"ts-node": "^10.9.1",
+114 -37
View File
@@ -14,7 +14,7 @@
* Payload: adds necessary payload for the request to be completed
*/
import { LogOrigin } from 'ontime-types';
import { Client, LogOrigin } from 'ontime-types';
import { WebSocket, WebSocketServer } from 'ws';
import type { Server } from 'http';
@@ -24,6 +24,7 @@ import { IAdapter } from './IAdapter.js';
import { eventStore } from '../stores/EventStore.js';
import { logger } from '../classes/Logger.js';
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
import { generateId } from 'ontime-utils';
let instance: SocketServer | null = null;
@@ -31,7 +32,7 @@ export class SocketServer implements IAdapter {
private readonly MAX_PAYLOAD = 1024 * 256; // 256Kb
private wss: WebSocketServer | null;
private readonly clientIds: Set<string>;
private readonly clients: Map<string, Client>;
constructor() {
if (instance) {
@@ -40,7 +41,7 @@ export class SocketServer implements IAdapter {
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
instance = this;
this.clientIds = new Set<string>();
this.clients = new Map<string, Client>();
this.wss = null;
}
@@ -48,9 +49,32 @@ export class SocketServer implements IAdapter {
this.wss = new WebSocketServer({ path: '/ws', server, maxPayload: this.MAX_PAYLOAD });
this.wss.on('connection', (ws) => {
let clientId = getRandomName();
this.clientIds.add(clientId);
logger.info(LogOrigin.Client, `${this.clientIds.size} Connections with new: ${clientId}`);
const clientId = generateId();
this.clients.set(clientId, {
type: 'unknown',
identify: false,
name: getRandomName(),
path: '',
});
logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientId}`);
ws.send(
JSON.stringify({
type: 'client-id',
payload: clientId,
}),
);
ws.send(
JSON.stringify({
type: 'client-name',
payload: this.clients.get(clientId).name,
}),
);
this.sendClientList();
// send store payload on connect
ws.send(
@@ -60,18 +84,12 @@ export class SocketServer implements IAdapter {
}),
);
ws.send(
JSON.stringify({
type: 'client-name',
payload: clientId,
}),
);
ws.on('error', console.error);
ws.on('close', () => {
this.clientIds.delete(clientId);
logger.info(LogOrigin.Client, `${this.clientIds.size} Connections with disconnected: ${clientId}`);
this.clients.delete(clientId);
logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientId}`);
this.sendClientList();
});
ws.on('message', (data) => {
@@ -84,26 +102,44 @@ export class SocketServer implements IAdapter {
ws.send(
JSON.stringify({
type: 'client-name',
payload: clientId,
payload: this.clients.get(clientId).name,
}),
);
return;
}
if (type === 'set-client-type') {
if (payload && typeof payload == 'string') {
const previousData = this.clients.get(clientId);
this.clients.set(clientId, { ...previousData, type: payload });
}
this.sendClientList();
return;
}
if (type === 'set-client-path') {
if (payload && typeof payload == 'string') {
const previousData = this.clients.get(clientId);
previousData.path = payload;
this.clients.set(clientId, previousData);
}
this.sendClientList();
return;
}
if (type === 'set-client-name') {
if (payload) {
const previousName = clientId;
clientId = payload;
this.clientIds.delete(previousName);
this.clientIds.add(clientId);
logger.info(LogOrigin.Client, `Client ${previousName} renamed to ${clientId}`);
const previousData = this.clients.get(clientId);
logger.info(LogOrigin.Client, `Client ${previousData.name} renamed to ${payload}`);
this.clients.set(clientId, { ...previousData, name: payload });
ws.send(
JSON.stringify({
type: 'client-name',
payload: this.clients.get(clientId).name,
}),
);
}
ws.send(
JSON.stringify({
type: 'client-name',
payload: clientId,
}),
);
this.sendClientList();
return;
}
@@ -135,17 +171,58 @@ export class SocketServer implements IAdapter {
});
}
// message is any serializable value
sendAsJson(message: unknown) {
this.wss?.clients.forEach((client) => {
try {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
} catch (_) {
/** We do not handle this error */
}
private sendClientList(): void {
const payload = Object.fromEntries(this.clients.entries());
this.sendAsJson({ type: 'client-list', payload });
}
public getClientList(): string[] {
return Array.from(this.clients.keys());
}
public renameClient(target: string, name: string) {
const previousData = this.clients.get(target);
if (!previousData) {
throw new Error(`Client "${target}" not found`);
}
logger.info(LogOrigin.Client, `Client ${previousData.name} renamed to ${name}`);
this.clients.set(target, { ...previousData, name });
this.sendAsJson({
type: 'client-rename',
payload: { name, target },
});
this.sendClientList();
}
public redirectClient(target: string, path: string) {
const previousData = this.clients.get(target);
if (!previousData) {
throw new Error(`Client "${target}" not found`);
}
this.sendAsJson({ type: 'client-redirect', payload: { target, path } });
}
public identifyClient(target: string, identify: boolean) {
const previousData = this.clients.get(target);
if (!previousData) {
throw new Error(`Client "${target}" not found`);
}
this.clients.set(target, { ...previousData, identify });
this.sendClientList();
}
// message is any serializable value
public sendAsJson(message: unknown) {
try {
const stringifiedMessage = JSON.stringify(message);
this.wss?.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(stringifiedMessage);
}
});
} catch (_) {
/** We do not handle this error */
}
}
shutdown() {
+24 -21
View File
@@ -16,9 +16,11 @@ import { failEmptyObjects } from '../../utils/routerUtils.js';
import { resolveDbDirectory, resolveProjectsDirectory } from '../../setup/index.js';
import * as projectService from '../../services/project-service/ProjectService.js';
import { ensureJsonExtension } from '../../utils/fileManagement.js';
import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js';
import { appStateService } from '../../services/app-state-service/AppStateService.js';
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
// all fields are optional in validation
@@ -32,6 +34,7 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
const patchDb: DatabaseModel = { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http };
const newData = await projectService.applyDataModel(patchDb);
res.status(200).send(newData);
} catch (error) {
const message = getErrorMessage(error);
@@ -50,8 +53,7 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
*/
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
try {
const originalFilename = ensureJsonExtension(req.body.title || 'Untitled');
const filename = generateUniqueFileName(resolveProjectsDirectory, originalFilename);
const filename = generateUniqueFileName(resolveProjectsDirectory, req.body.filename);
const errors = projectService.validateProjectFiles({ newFilename: filename });
if (errors.length) {
@@ -67,7 +69,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
backstageInfo: req.body?.backstageInfo ?? '',
};
projectService.createProjectFile(filename, newProjectData);
await projectService.createProjectFile(filename, newProjectData);
res.status(200).send({
filename,
@@ -79,29 +81,18 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
}
/**
* Utility function finds the correct project file to download
*/
function selectProjectFile(fileName?: string) {
const projectsDirectory = resolveDbDirectory;
const fileToDownload = fileName ? ensureJsonExtension(fileName) : projectService.getProjectTitle();
const pathToFile = join(projectsDirectory, fileToDownload);
return { pathToFile, name: fileToDownload };
}
/**
* Allows downloading of a optionally given project files
* If no {filename} is provided, loaded file will be served
* Allows downloading of project files
*/
export async function projectDownload(req: Request, res: Response) {
const { pathToFile, name } = selectProjectFile(req.body?.fileName);
const { filename } = req.body;
const pathToFile = join(resolveDbDirectory, filename);
// Check if the file exists before attempting to download
if (!existsSync(pathToFile)) {
return res.status(404).send({ message: `Project ${name} not found.` });
return res.status(404).send({ message: `Project ${filename} not found.` });
}
res.download(pathToFile, name, (error) => {
res.download(pathToFile, filename, (error) => {
if (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
@@ -125,6 +116,12 @@ export async function postProjectFile(req: Request, res: Response<MessageRespons
await projectService.handleUploadedFile(path, filename);
await projectService.applyProjectFile(filename, options);
const oscSettings = await DataProvider.getOsc();
const httpSettings = await DataProvider.getHttp();
oscIntegration.init(oscSettings);
httpIntegration.init(httpSettings);
res.status(201).send({
message: `Loaded project ${filename}`,
});
@@ -159,6 +156,12 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
await projectService.applyProjectFile(name);
const oscSettings = await DataProvider.getOsc();
const httpSettings = await DataProvider.getHttp();
oscIntegration.init(oscSettings);
httpIntegration.init(httpSettings);
res.status(201).send({
message: `Loaded project ${name}`,
});
@@ -212,7 +215,7 @@ export async function duplicateProjectFile(req: Request, res: Response<MessageRe
*/
export async function renameProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const { newFilename } = req.body;
const { filename: newFilename } = req.body;
const { filename } = req.params;
const errors = projectService.validateProjectFiles({ filename, newFilename });
+11 -14
View File
@@ -14,28 +14,25 @@ import {
} from './db.controller.js';
import { uploadProjectFile } from './db.middleware.js';
import {
projectSanitiser,
sanitizeProjectFilename,
validateDownloadProject,
validateLoadProjectFile,
validatePatchProjectFile,
validateProjectDuplicate,
validateProjectRename,
validateNewProject,
validatePatchProject,
validateFilenameBody,
validateFilenameParam,
} from './db.validation.js';
export const router = express.Router();
router.post('/download', validateDownloadProject, projectDownload);
router.post('/download', validateFilenameBody, projectDownload);
router.post('/upload', uploadProjectFile, postProjectFile);
router.patch('/', validatePatchProjectFile, patchPartialProjectFile);
router.post('/new', projectSanitiser, createProjectFile);
router.patch('/', validatePatchProject, patchPartialProjectFile);
router.post('/new', validateFilenameBody, validateNewProject, createProjectFile);
router.get('/all', listProjects);
router.post('/load', validateLoadProjectFile, sanitizeProjectFilename, loadProject);
router.post('/:filename/duplicate', validateProjectDuplicate, sanitizeProjectFilename, duplicateProjectFile);
router.put('/:filename/rename', validateProjectRename, sanitizeProjectFilename, renameProjectFile);
router.delete('/:filename', sanitizeProjectFilename, deleteProjectFile);
router.post('/load', validateFilenameBody, loadProject);
router.post('/:filename/duplicate', validateFilenameParam, validateFilenameBody, duplicateProjectFile);
router.put('/:filename/rename', validateFilenameParam, validateFilenameBody, renameProjectFile);
router.delete('/:filename', validateFilenameParam, deleteProjectFile);
router.get('/info', getInfo);
+28 -60
View File
@@ -1,9 +1,12 @@
import { Request, Response, NextFunction } from 'express';
import { body, validationResult } from 'express-validator';
import { body, param, validationResult } from 'express-validator';
import { ensureJsonExtension } from '../../utils/fileManagement.js';
import sanitize from 'sanitize-filename';
export const projectSanitiser = [
/**
* @description Validates request for a new project.
*/
export const validateNewProject = [
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(),
@@ -19,18 +22,10 @@ export const projectSanitiser = [
},
];
export const sanitizeProjectFilename = (req: Request, _res: Response, next: NextFunction) => {
const { filename, newFilename } = req.body;
const { filename: projectName } = req.params;
req.body.filename = ensureJsonExtension(filename);
req.body.newFilename = ensureJsonExtension(newFilename);
req.params.filename = ensureJsonExtension(projectName);
next();
};
export const validatePatchProjectFile = [
/**
* @description Validates request for pathing data in the project.
*/
export const validatePatchProject = [
body('rundown').isArray().optional({ nullable: false }),
body('project').isObject().optional({ nullable: false }),
body('settings').isObject().optional({ nullable: false }),
@@ -47,31 +42,18 @@ export const validatePatchProjectFile = [
];
/**
* @description Validates the filename for loading a project file.
* @description Validates request with filename in the body.
*/
export const validateLoadProjectFile = [
body('filename').exists().withMessage('Filename is required').isString().withMessage('Filename must be a string'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
/**
* @description Validates the filenames for duplicating a project.
*/
export const validateProjectDuplicate = [
body('newFilename')
export const validateFilenameBody = [
body('filename')
.exists()
.withMessage('New project filename is required')
.isString()
.withMessage('New project filename must be a string')
.isLength({ min: 1, max: 255 })
.withMessage('New project filename must be between 1 and 255 characters'),
.trim()
.customSanitizer((input: string) => sanitize(input))
.withMessage('Failed to sanitize the filename')
.notEmpty()
.withMessage('Filename was empty or contained only invalid characters')
.customSanitizer((input: string) => ensureJsonExtension(input)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -84,32 +66,18 @@ export const validateProjectDuplicate = [
];
/**
* @description Validates the filenames for renaming a project.
* @description Validates request with filename in the params.
*/
export const validateProjectRename = [
body('newFilename')
export const validateFilenameParam = [
param('filename')
.exists()
.withMessage('Duplicate project filename is required')
.isString()
.withMessage('Duplicate project filename must be a string')
.isLength({ min: 1, max: 255 })
.withMessage('Duplicate project filename must be between 1 and 255 characters'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
/**
* @description Validates a download request which can include an optional project name.
*/
export const validateDownloadProject = [
body('fileName').isString().optional(),
.trim()
.customSanitizer((input: string) => sanitize(input))
.withMessage('Failed to sanitize the filename')
.notEmpty()
.withMessage('Filename was empty or contained only invalid characters')
.customSanitizer((input: string) => ensureJsonExtension(input)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -1,4 +1,5 @@
import { ErrorResponse, Settings } from 'ontime-types';
import { getErrorMessage, obfuscate } from 'ontime-utils';
import { Request, Response } from 'express';
@@ -6,8 +7,6 @@ import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { extractPin } from '../../services/project-service/ProjectService.js';
import { isDocker } from '../../setup/index.js';
import { getErrorMessage } from 'ontime-utils';
import { obfuscate } from 'ontime-utils';
export async function getSettings(_req: Request, res: Response<Settings>) {
const settings = DataProvider.getSettings();
@@ -10,6 +10,11 @@ import { eventStore } from '../stores/EventStore.js';
import * as assert from '../utils/assert.js';
import { isEmptyObject } from '../utils/parserUtils.js';
import { parseProperty, updateEvent } from './integration.utils.js';
import { socket } from '../adapters/WebsocketAdapter.js';
import { throttle } from '../utils/throttle.js';
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
const throttledUpdateEvent = throttle(updateEvent, 20);
export function dispatchFromAdapter(type: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') {
const action = type.toLowerCase();
@@ -43,6 +48,8 @@ const actionHandlers: Record<string, ActionHandler> = {
const data = payload[id as keyof typeof payload];
const patchEvent: Partial<OntimeEvent> & { id: string } = { id };
let shouldThrottle = false;
Object.entries(data).forEach(([property, value]) => {
if (typeof property !== 'string' || value === undefined) {
throw new Error('Invalid property or value');
@@ -50,6 +57,9 @@ const actionHandlers: Record<string, ActionHandler> = {
const newObjectProperty = parseProperty(property, value);
const key = Object.keys(newObjectProperty)[0] as keyof OntimeEvent;
shouldThrottle = willCauseRegeneration(key) || shouldThrottle;
if (patchEvent.custom && newObjectProperty.custom) {
Object.assign(patchEvent.custom, newObjectProperty.custom);
} else {
@@ -57,8 +67,13 @@ const actionHandlers: Record<string, ActionHandler> = {
}
});
updateEvent(patchEvent);
if (shouldThrottle) {
if (throttledUpdateEvent(patchEvent)) {
return { payload: 'throttled' };
}
} else {
updateEvent(patchEvent);
}
return { payload: 'success' };
},
/* Message Service */
@@ -226,6 +241,33 @@ const actionHandlers: Record<string, ActionHandler> = {
}
throw new Error('No matching method provided');
},
/* Client */
client: (payload) => {
assert.isObject(payload);
if (!('target' in payload) || typeof payload.target != 'string') {
throw new Error('No or invalid client target');
}
if ('rename' in payload && typeof payload.rename == 'string') {
const { target, rename } = payload;
socket.renameClient(target, rename);
return { payload: 'success' };
}
if ('redirect' in payload && typeof payload.redirect == 'string') {
const { target, redirect } = payload;
socket.redirectClient(target, redirect);
return { payload: 'success' };
}
if ('identify' in payload && typeof payload.identify == 'boolean') {
const { target, identify } = payload;
socket.identifyClient(target, identify);
return { payload: 'success' };
}
throw new Error('No matching method provided');
},
};
/**
@@ -1,24 +1,45 @@
import { OntimeEvent, isKeyOfType, isOntimeEvent } from 'ontime-types';
import { EndAction, OntimeEvent, TimerType, isKeyOfType, isOntimeEvent } from 'ontime-types';
import { MILLIS_PER_SECOND, maxDuration } from 'ontime-utils';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { editEvent } from '../services/rundown-service/RundownService.js';
import { getEventWithId } from '../services/rundown-service/rundownUtils.js';
import { coerceBoolean, coerceColour, coerceNumber, coerceString } from '../utils/coerceType.js';
import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js';
const whitelistedPayload = {
/**
*
* @param {number} value time amount in seconds
* @returns {number} time in milliseconds clamped to 0 and max duration
*/
function clampDuration(value: number) {
const valueInMillis = value * MILLIS_PER_SECOND;
if (valueInMillis > maxDuration || valueInMillis < 0) {
throw new Error('Times should be from 0 to 23:59:59');
}
return valueInMillis;
}
const propertyConversion = {
title: coerceString,
note: coerceString,
cue: coerceString,
duration: (value: unknown) => Math.max(coerceNumber(value) * MILLIS_PER_SECOND, maxDuration),
isPublic: coerceBoolean,
skip: coerceBoolean,
colour: coerceColour,
custom: coerceString,
timeWarning: (value: unknown) => clampDuration(coerceNumber(value)),
timeDanger: (value: unknown) => clampDuration(coerceNumber(value)),
endAction: (value: unknown) => coerceEnum<EndAction>(value, EndAction),
timerType: (value: unknown) => coerceEnum<TimerType>(value, TimerType),
duration: (value: unknown) => clampDuration(coerceNumber(value)),
timeStart: (value: unknown) => clampDuration(coerceNumber(value)),
timeEnd: (value: unknown) => clampDuration(coerceNumber(value)),
};
export function parseProperty(property: string, value: unknown) {
@@ -27,13 +48,13 @@ export function parseProperty(property: string, value: unknown) {
if (!(customKey in DataProvider.getCustomFields())) {
throw new Error(`Custom field ${customKey} not found`);
}
const parserFn = whitelistedPayload.custom;
const parserFn = propertyConversion.custom;
return { custom: { [customKey]: parserFn(value) } };
}
if (!isKeyOfType(property, whitelistedPayload)) {
if (!isKeyOfType(property, propertyConversion)) {
throw new Error(`Property ${property} not permitted`);
}
const parserFn = whitelistedPayload[property];
const parserFn = propertyConversion[property];
return { [property]: parserFn(value) };
}
@@ -50,6 +71,5 @@ export function updateEvent(patchEvent: Partial<OntimeEvent> & { id: string }) {
if (!isOntimeEvent(event)) {
throw new Error('Can only update events');
}
editEvent(patchEvent);
}
+27 -16
View File
@@ -20,6 +20,7 @@ import {
clearUploadfolder,
} from './setup/index.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight } from './utils/console.js';
// Import Routers
import { appRouter } from './api-data/index.js';
@@ -44,10 +45,13 @@ import { populateDemo } from './setup/loadDemo.js';
import { getState } from './stores/runtimeState.js';
import { initRundown } from './services/rundown-service/RundownService.js';
import { generateCrashReport } from './utils/generateCrashReport.js';
import { getNetworkInterfaces } from './utils/networkInterfaces.js';
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
console.log('\n');
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
if (!isProduction) {
const canLog = isProduction;
if (!canLog) {
console.log(`Ontime running in ${environment} environment`);
console.log(`Ontime directory at ${srcDirectory} `);
console.log(`Ontime database at ${resolveDbPath}`);
@@ -56,7 +60,7 @@ if (!isProduction) {
// Create express APP
const app = express();
if (process.env.NODE_ENV === 'development') {
// log more serever timings
// log server timings to requests
app.use(serverTiming());
}
app.disable('x-powered-by');
@@ -151,18 +155,16 @@ export const initAssets = async () => {
/**
* Starts servers
* @return {Promise<string>}
*/
export const startServer = async () => {
export const startServer = async (
escalateErrorFn?: (error: string) => void,
): Promise<{ message: string; serverPort: number }> => {
checkStart(OntimeStartOrder.InitServer);
const { serverPort } = DataProvider.getSettings();
const returnMessage = `Ontime is listening on port ${serverPort}`;
expressServer = http.createServer(app);
socket.init(expressServer);
logger.info(LogOrigin.Server, returnMessage);
/**
* Module initialises the services and provides initial payload for the store
@@ -186,6 +188,9 @@ export const startServer = async () => {
},
});
// initialise logging service, escalateErrorFn is only exists in electron
logger.init(escalateErrorFn);
// initialise rundown service
const persistedRundown = DataProvider.getRundown();
const persistedCustomFields = DataProvider.getCustomFields();
@@ -200,7 +205,16 @@ export const startServer = async () => {
// eventStore set is a dependency of the services that publish to it
messageService.init(eventStore.set.bind(eventStore));
expressServer.listen(serverPort, '0.0.0.0');
expressServer.listen(serverPort, '0.0.0.0', () => {
const nif = getNetworkInterfaces();
consoleSuccess(`Local: http://localhost:${serverPort}/editor`);
for (const key of Object.keys(nif)) {
consoleSuccess(`Network: http://${nif[key].address}:${serverPort}/editor`);
}
});
const returnMessage = `Ontime is listening on port ${serverPort}`;
logger.info(LogOrigin.Server, returnMessage);
return { message: returnMessage, serverPort };
};
@@ -241,7 +255,7 @@ export const startIntegrations = async (config?: { osc: OSCSettings; http: HttpS
* @return {Promise<void>}
*/
export const shutdown = async (exitCode = 0) => {
console.log(`Ontime shutting down with code ${exitCode}`);
consoleHighlight(`Ontime shutting down with code ${exitCode}`);
// clear the restore file if it was a normal exit
// 0 means it was a SIGNAL
@@ -251,7 +265,6 @@ export const shutdown = async (exitCode = 0) => {
await restoreService.clear();
}
// TODO: Clear token
expressServer?.close();
runtimeService.shutdown();
integrationService.shutdown();
@@ -260,19 +273,17 @@ export const shutdown = async (exitCode = 0) => {
process.exit(exitCode);
};
process.on('exit', (code) => console.log(`Ontime shutdown with code: ${code}`));
process.on('exit', (code) => consoleHighlight(`Ontime shutdown with code: ${code}`));
process.on('unhandledRejection', async (error) => {
console.error('Error: unhandled rejection', error);
generateCrashReport(error);
logger.error(LogOrigin.Server, `Error: unhandled rejection ${error}`);
logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`);
await shutdown(1);
});
process.on('uncaughtException', async (error) => {
console.error('Error: uncaught exception', error);
generateCrashReport(error);
logger.error(LogOrigin.Server, `Error: uncaught exception ${error}`);
logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`);
await shutdown(1);
});
+28 -4
View File
@@ -4,22 +4,33 @@ import { generateId, millisToString } from 'ontime-utils';
import { clock } from '../services/Clock.js';
import { isProduction } from '../setup/index.js';
import { socket } from '../adapters/WebsocketAdapter.js';
import { consoleSubdued, consoleRed } from '../utils/console.js';
class Logger {
private queue: Log[];
private escalateErrorFn: (error: string) => void | null;
private canLog = false;
constructor() {
this.queue = [];
this.escalateErrorFn = null;
this.canLog = !isProduction;
}
/**
* Enabling setup logger after init
*/
init() {
init(escalateErrorFn: (error: string) => void) {
// flush logs from queue
this.queue.forEach((log) => {
this._push(log);
});
this.queue = [];
// we only get this when running in electron
if (escalateErrorFn) {
this.escalateErrorFn = escalateErrorFn;
}
}
private addToQueue(log: Log) {
@@ -34,8 +45,12 @@ class Logger {
* @param log
*/
private _push(log: Log) {
if (!isProduction) {
console.log(`[${log.level}] \t ${log.origin} \t ${log.text}`);
if (this.canLog || log.level === LogLevel.Severe) {
if (log.level === LogLevel.Severe) {
consoleRed(`[${log.level}] \t ${log.origin} \t ${log.text}`);
} else {
consoleSubdued(`[${log.level}] \t ${log.origin} \t ${log.text}`);
}
}
try {
@@ -92,11 +107,20 @@ class Logger {
this.emit(LogLevel.Error, origin, text);
}
/**
* Utility to emit logging message of type SEVERE
* @param origin
* @param text
*/
crash(origin: string, text: string) {
this.emit(LogLevel.Severe, origin, text);
this.escalateErrorFn?.(text);
}
/**
* Shutdown logger
*/
shutdown() {
console.log('Shutting down logger');
this.queue = [];
}
}
+10 -4
View File
@@ -1,16 +1,22 @@
/* eslint-disable no-console */
import { consoleHighlight, consoleRed } from './utils/console.js';
import { initAssets, startIntegrations, startServer } from './app.js';
async function startOntime() {
try {
console.log('Request: Initialise assets...');
console.log('\n');
consoleHighlight('Request: Initialise assets...');
await initAssets();
console.log('Request: Start server...');
console.log('\n');
consoleHighlight('Request: Start server...');
await startServer();
console.log('Request: Start integrations...');
console.log('\n');
consoleHighlight('Request: Start integrations...');
await startIntegrations();
} catch (error) {
console.log(`Request failed: ${error}`);
consoleRed(`Request failed: ${error}`);
}
}

Some files were not shown because too many files have changed in this diff Show More