Compare commits

...

80 Commits

Author SHA1 Message Date
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
Carlos Valente 45cf1cf199 chore: bump version to 3.0.4 2024-05-28 22:49:29 +02:00
Carlos Valente 354f464263 chore: user feedback 2024-05-28 22:49:29 +02:00
Carlos Valente c46a00febc chore: expand download links 2024-05-28 22:49:29 +02:00
Carlos Valente e991e151db refactor: improve dnd activation strategy 2024-05-28 22:45:10 +02:00
Carlos Valente b5993b91e1 fix: column reordering
refactor: migrate local storage hook
2024-05-28 22:45:10 +02:00
arc-alex 4beac19c4a Clear upload folder on startup 2024-05-28 22:44:55 +02:00
Carlos Valente be852f4546 refactor: allow hiding sidebar 2024-05-27 15:52:02 +02:00
Carlos Valente 84e224dd71 refactor: unify time parsing logic 2024-05-27 15:51:45 +02:00
Carlos Valente 7cbb2112ed refactor: simplify parsing logic 2024-05-27 15:51:45 +02:00
Carlos Valente ce910c9cc6 refactor: extract parseUserTime 2024-05-27 15:51:45 +02:00
Alex Christoffer Rasmussen 9d04954d87 fix change api isPublic (#1008) 2024-05-27 13:01:10 +02:00
Alex Christoffer Rasmussen 345ea6722e add notes to secondaryOptions in timer view (#1004) 2024-05-27 12:22:01 +02:00
Alex Christoffer Rasmussen f43221cf7a don't notify when aux timer is paused (#1003) 2024-05-27 11:51:07 +02:00
Carlos Valente a09aa922bb refactor: delete several events 2024-05-26 21:36:49 +02:00
Carlos Valente 16f31a07b7 chore: upgrade dependencies 2024-05-26 21:36:49 +02:00
Carlos Valente 77d111650a refactor: reduce scope of context 2024-05-26 21:36:49 +02:00
jwetzell 72891a69f4 Merge pull request #998 from cpvalente/fix/smart-time-parsing
address some issues in am/pm time parsing
2024-05-26 11:07:43 -05:00
Carlos Valente b8d5a30caf style: input text alignment 2024-05-25 15:19:01 +02:00
jwetzell 9cbd4c085b slim back tests even further and list out 2024-05-24 06:51:51 -05:00
jwetzell afe0323384 use let not var 2024-05-24 06:12:46 -05:00
jwetzell 1907abc512 pare back am/pm tests 2024-05-24 05:53:07 -05:00
jwetzell 834aafe06d remove need for hardcoded 12am test 2024-05-23 20:29:05 -05:00
jwetzell 98418014d3 lowercase time string before beginning to parse 2024-05-23 20:16:41 -05:00
jwetzell 3d13e784ea fix parsing am/pm times 2024-05-23 19:45:12 -05:00
jwetzell e397500d07 dynamically build am/pm tests 2024-05-23 19:17:36 -05:00
jwetzell c7c0241859 add new am/pm test cases for dateConfig util 2024-05-23 17:39:42 -05:00
Carlos Valente 71a7fe96d1 style: fix input overflow 2024-05-23 22:13:42 +02:00
jwetzell d0f958c13d Merge pull request #990 from cpvalente/fix/docker-build
fix docker multi-architecture build
2024-05-23 08:15:07 -05:00
jwetzell 7ee98fb062 remove node setup and build from docker action 2024-05-21 16:06:28 -05:00
jwetzell c2b66a4215 rework dockerfile to remove need for turbo 2024-05-21 16:06:15 -05:00
jwetzell 1d0ee10c00 remove build:docker from turbo 2024-05-21 16:05:49 -05:00
129 changed files with 2799 additions and 1760 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

-18
View File
@@ -17,24 +17,6 @@ jobs:
- name: Setup env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
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 turbo build:docker
- name: Docker Login
uses: docker/login-action@v2.1.0
with:
+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.
+3 -5
View File
@@ -1,13 +1,11 @@
FROM node:18.18-alpine AS base
FROM node:18.18-alpine AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS builder
COPY . /app
WORKDIR /app
RUN pnpm install --frozen-lockfile \
&& pnpm turbo build:docker
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
FROM node:18.18-alpine
+25 -21
View File
@@ -4,19 +4,22 @@
[![](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)
## Download the latest releases here
<div style="display: flex; justify-content: space-around">
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe"><img alt="Download Windows" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/win-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage"><img alt="Download Linux" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/linux-download.png"/></a>
<a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/dockerhub.png"/></a>
</div>
<br />
## Download the latest release
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
## Using Ontime?
Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
We would also like to include a testimonials section in our ✨new website✨. It would be great to showcase the diversity of users running Ontime.
# Ontime
Ontime is a browser-based application that manages event rundowns, scheduling, and cueing.
@@ -24,6 +27,7 @@ Ontime is a browser-based application that manages event rundowns, scheduling, a
With Ontime, you can plan, track your schedule, manage automation and cross-department show information all in one place.
Ontime is made by entertainment and broadcast engineers and used by
- Conference organisers
- Touring shows and receiving venues
- Broadcasters and streamers
@@ -99,16 +103,16 @@ More documentation is available [in our docs](https://docs.getontime.no)
## Main features
* [x] Distribute data over network and render it in the browser
* [x] Collaborative
* [x] Extendable
* [x] Send messages to different screen types
* [x] Differentiate between backstage and public data
* [x] Workflow for managing delays
* [x] Rich protocol integrations for Control and Feedback
* [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
* [x] Multi-platform (available on Windows, MacOS and Linux)
* [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
- [x] Distribute data over network and render it in the browser
- [x] Collaborative
- [x] Extendable
- [x] Send messages to different screen types
- [x] Differentiate between backstage and public data
- [x] Workflow for managing delays
- [x] Rich protocol integrations for Control and Feedback
- [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
## Roadmap
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.0.3",
"version": "3.2.0",
"private": true,
"type": "module",
"dependencies": {
@@ -30,7 +30,7 @@
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0",
"web-vitals": "^3.1.1",
"zustand": "^4.5.0"
"zustand": "^4.5.2"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -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",
+1 -4
View File
@@ -3,7 +3,6 @@ import { ChakraProvider } from '@chakra-ui/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ContextMenu } from './common/components/context-menu/ContextMenu';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import { AppContextProvider } from './common/context/AppContext';
import { ontimeQueryClient } from './common/queryClient';
@@ -25,9 +24,7 @@ function App() {
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
<ContextMenu>
<AppRouter />
</ContextMenu>
<AppRouter />
</TranslationProvider>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
+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;
}
+2 -2
View File
@@ -74,8 +74,8 @@ export async function requestApplyDelay(eventId: string): Promise<AxiosResponse<
/**
* HTTP request to delete given event
*/
export async function requestDelete(eventId: string): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(`${rundownPath}/${eventId}`);
export async function requestDelete(eventIds: string[]): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(rundownPath, { data: { ids: eventIds } });
}
/**
@@ -75,6 +75,7 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
w={1}
h={1}
style={{
position: 'fixed',
left: coords.x,
top: coords.y,
}}
@@ -1,9 +1,8 @@
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
import { millisToString } from 'ontime-utils';
import { millisToString, parseUserTime } from 'ontime-utils';
import { useEventAction } from '../../../hooks/useEventAction';
import { forgivingStringToMillis } from '../../../utils/dateConfig';
import style from './DelayInput.module.scss';
@@ -40,7 +39,7 @@ export default function DelayInput(props: DelayInputProps) {
}
const isNegative = newValue.startsWith('-');
let newMillis = forgivingStringToMillis(newValue);
let newMillis = parseUserTime(newValue);
if (isNegative) {
newMillis = newMillis * -1;
@@ -1,23 +1,21 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import { millisToString } from 'ontime-utils';
import { millisToString, parseUserTime } from 'ontime-utils';
import { useEmitLog } from '../../../stores/logger';
import { forgivingStringToMillis } from '../../../utils/dateConfig';
import { cx } from '../../../utils/styleUtils';
import style from './TimeInput.module.scss';
interface TimeInputProps<T extends string> {
name: T;
submitHandler: (field: T, value: string) => void;
time?: number;
placeholder: string;
disabled?: boolean;
align?: 'left' | 'center';
className?: string;
}
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { name, submitHandler, time = 0, placeholder, disabled, className } = props;
const { name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -58,7 +56,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
submitHandler(name, newValue);
}
const valueInMillis = forgivingStringToMillis(newValue);
const valueInMillis = parseUserTime(newValue);
if (valueInMillis === time) {
return false;
}
@@ -117,15 +115,13 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
resetValue();
}, [resetValue, time]);
const timeInputClasses = cx([style.timeInput, className]);
return (
<Input
disabled={disabled}
size='sm'
ref={inputRef}
data-testid={`time-input-${name}`}
className={timeInputClasses}
className={className}
fontSize='1rem'
type='text'
placeholder={placeholder}
@@ -136,7 +132,10 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
maxWidth='7.5em'
letterSpacing='1px'
autoComplete='off'
textAlign={align}
/>
);
}
@@ -6,7 +6,3 @@ $input-delayed-border-color: $ontime-delay-text;
border: 1px solid $input-delayed-border-color;
}
}
.inputField {
max-width: 7.75em;
}
@@ -28,7 +28,7 @@ export default function TimeInputWithButton<T extends string>(props: PropsWithCh
submitHandler={submitHandler}
time={time}
placeholder={placeholder}
className={style.inputField}
align='left'
disabled={disabled}
/>
{children}
@@ -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>
@@ -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 secondaryOptions = makeOptionsFromCustomFields(customFields);
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);
+13 -11
View File
@@ -6,6 +6,7 @@ import {
getLinkedTimes,
getPreviousEventNormal,
MILLIS_PER_SECOND,
parseUserTime,
reorderArray,
swapEventData,
} from 'ontime-utils';
@@ -25,7 +26,6 @@ import {
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings';
import { forgivingStringToMillis } from '../utils/dateConfig';
/**
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
@@ -94,15 +94,15 @@ export const useEventAction = () => {
}
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
newEvent.duration = forgivingStringToMillis(defaultDuration);
newEvent.duration = parseUserTime(defaultDuration);
}
if (newEvent.timeDanger === undefined) {
newEvent.timeDanger = forgivingStringToMillis(defaultDangerTime);
newEvent.timeDanger = parseUserTime(defaultDangerTime);
}
if (newEvent.timeWarning === undefined) {
newEvent.timeWarning = forgivingStringToMillis(defaultWarnTime);
newEvent.timeWarning = parseUserTime(defaultWarnTime);
}
}
@@ -217,9 +217,9 @@ export const useEventAction = () => {
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
// TODO: is this logic solid?
const remainingString = value.substring(1);
newValMillis = getPreviousEnd() + forgivingStringToMillis(remainingString);
newValMillis = getPreviousEnd() + parseUserTime(remainingString);
} else {
newValMillis = forgivingStringToMillis(value);
newValMillis = parseUserTime(value);
}
// dont allow timer values over 23:59:59
@@ -337,7 +337,7 @@ export const useEventAction = () => {
const _deleteEventMutation = useMutation({
mutationFn: requestDelete,
// we optimistically update here
onMutate: async (eventId) => {
onMutate: async (eventIds: string[]) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -346,9 +346,11 @@ export const useEventAction = () => {
if (previousData) {
// optimistically update object
const newOrder = previousData.order.filter((id) => id !== eventId);
const newOrder = previousData.order.filter((id) => !eventIds.includes(id));
const newRundown = { ...previousData.rundown };
delete newRundown[eventId];
for (const eventId of eventIds) {
delete newRundown[eventId];
}
queryClient.setQueryData(RUNDOWN, {
order: newOrder,
@@ -377,9 +379,9 @@ export const useEventAction = () => {
* Deletes an event form the list
*/
const deleteEvent = useCallback(
async (eventId: string) => {
async (eventIds: string[]) => {
try {
await _deleteEventMutation.mutateAsync(eventId);
await _deleteEventMutation.mutateAsync(eventIds);
} catch (error) {
logAxiosError('Error deleting event', error);
}
@@ -1,46 +0,0 @@
import { useSyncExternalStore } from 'react';
const STORAGE_EVENT = 'ontime-storage';
function getSnapshot(key: string): string | null {
try {
return window.localStorage.getItem(`ontime-${key}`);
} catch {
return null;
}
}
function getParsedJson<T>(localStorageValue: string | null, initialValue: T): T {
try {
return localStorageValue ? JSON.parse(localStorageValue) : initialValue;
} catch {
return initialValue;
}
}
export const useLocalStorage = <T>(key: string, initialValue: T) => {
const localStorageValue = useSyncExternalStore(subscribe, () => getSnapshot(key));
const parsedLocalStorageValue = getParsedJson(localStorageValue, initialValue);
/**
* @description Set value to local storage
* @param value
*/
const setLocalStorageValue = (value: T | ((val: T) => T)) => {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(parsedLocalStorageValue) : value;
localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
window.dispatchEvent(new StorageEvent(STORAGE_EVENT));
};
return [parsedLocalStorageValue, setLocalStorageValue] as const;
};
function subscribe(callback: () => void) {
window.addEventListener(STORAGE_EVENT, callback);
return () => {
window.removeEventListener(STORAGE_EVENT, callback);
};
}
@@ -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 }),
}));
+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,
@@ -1,292 +1,4 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { forgivingStringToMillis, millisToDelayString } from '../dateConfig';
describe('test forgivingStringToMillis()', () => {
describe('function handles time with no separators', () => {
const testData = [
{ value: '', expect: 0 },
{ value: '0', expect: 0 },
{ value: '-0', expect: 0 },
{ value: '1', expect: MILLIS_PER_MINUTE },
{ value: '-1', expect: MILLIS_PER_MINUTE },
{ value: '0h0m0s', expect: 0 },
{ value: '0h0m1s', expect: MILLIS_PER_SECOND },
{ value: '0h1m0s', expect: MILLIS_PER_MINUTE },
{ value: '1h0m0s', expect: MILLIS_PER_HOUR },
{ value: '23h0m0s', expect: 23 * MILLIS_PER_HOUR },
{ value: '12h12m12s', expect: 12 * MILLIS_PER_SECOND + 12 * MILLIS_PER_MINUTE + 12 * MILLIS_PER_HOUR },
{ value: '12H12M12S', expect: 12 * MILLIS_PER_SECOND + 12 * MILLIS_PER_MINUTE + 12 * MILLIS_PER_HOUR },
{ value: '2m', expect: 2 * MILLIS_PER_MINUTE },
{ value: '1h5s', expect: MILLIS_PER_HOUR + 5 * MILLIS_PER_SECOND },
{ value: '1h2m', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE },
];
for (const s of testData) {
it(`handles ${s.value}`, () => {
expect(typeof forgivingStringToMillis(s.value)).toBe('number');
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('parses strings correctly', () => {
const ts = [
{ value: '1.1.1', expect: MILLIS_PER_HOUR + MILLIS_PER_MINUTE + MILLIS_PER_SECOND },
{ value: '12.1.1', expect: 12 * MILLIS_PER_HOUR + MILLIS_PER_MINUTE + MILLIS_PER_SECOND },
{ value: '12.55.1', expect: 12 * MILLIS_PER_HOUR + 55 * MILLIS_PER_MINUTE + MILLIS_PER_SECOND },
{ value: '12.55.40', expect: 12 * MILLIS_PER_HOUR + 55 * MILLIS_PER_MINUTE + 40 * MILLIS_PER_SECOND },
];
for (const s of ts) {
it(`handles ${s.value} to the left`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('#33 separators are parsed according to doc examples ', () => {
const ts = [
{ value: '0.1', expect: MILLIS_PER_MINUTE },
{ value: '0 1', expect: MILLIS_PER_MINUTE },
{ value: '0:1', expect: MILLIS_PER_MINUTE },
{ value: '0,1', expect: MILLIS_PER_MINUTE },
{ value: '2.2.2', expect: 2 * MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 2 * MILLIS_PER_SECOND },
{ value: '2 2 2', expect: 2 * MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 2 * MILLIS_PER_SECOND },
{ value: '2:2:2', expect: 2 * MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 2 * MILLIS_PER_SECOND },
{ value: '2,2,2', expect: 2 * MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 2 * MILLIS_PER_SECOND },
{ value: '2,2,2', expect: 2 * MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 2 * MILLIS_PER_SECOND },
{ value: '10:', expect: 10 * MILLIS_PER_HOUR },
{ value: ':10', expect: 10 * MILLIS_PER_MINUTE },
{ value: '10', expect: 10 * MILLIS_PER_MINUTE },
{ value: '120', expect: MILLIS_PER_HOUR + 20 * MILLIS_PER_MINUTE },
{ value: '90m', expect: 90 * MILLIS_PER_MINUTE },
{ value: '1.2', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE },
{ value: '1.2.3', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 3 * MILLIS_PER_SECOND },
{ value: '123456', expect: 12 * MILLIS_PER_HOUR + 34 * MILLIS_PER_MINUTE + 56 * MILLIS_PER_SECOND },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('parses time strings', () => {
const ts = [
{ value: '1h2m3s', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 3 * MILLIS_PER_SECOND },
{ value: '1h3s', expect: MILLIS_PER_HOUR + 3 * MILLIS_PER_SECOND },
{ value: '1h2m', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE },
{ value: '10h', expect: 10 * MILLIS_PER_HOUR },
{ value: '10m', expect: 10 * MILLIS_PER_MINUTE },
{ value: '10s', expect: 10 * MILLIS_PER_SECOND },
{ value: '120h', expect: 120 * MILLIS_PER_HOUR },
{ value: '120m', expect: 120 * MILLIS_PER_MINUTE },
{ value: '120s', expect: 120 * MILLIS_PER_SECOND },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('handles am/pm', () => {
const ampm = [
{ value: '9:10:11am', expect: 9 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND },
{ value: '9:10:11a', expect: 9 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND },
{ value: '9:10:11pm', expect: (12 + 9) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND },
{ value: '9:10:11p', expect: (12 + 9) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND },
{ value: '9:10am', expect: 9 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '9:10a', expect: 9 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '9:10pm', expect: (12 + 9) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '9:10p', expect: (12 + 9) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '9am', expect: 9 * MILLIS_PER_HOUR },
{ value: '9a', expect: 9 * MILLIS_PER_HOUR },
{ value: '9pm', expect: (12 + 9) * MILLIS_PER_HOUR },
{ value: '9p', expect: (12 + 9) * MILLIS_PER_HOUR },
{ value: '12am', expect: 0 },
{ value: '12pm', expect: 12 * MILLIS_PER_HOUR },
];
for (const s of ampm) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('it infers separators when non existent', () => {
const testCases = [
{ value: '1', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '12', expect: 12 * MILLIS_PER_MINUTE }, // 00:12:00
{ value: '123', expect: MILLIS_PER_HOUR + 23 * MILLIS_PER_MINUTE }, // 01:23:00
{ value: '1234', expect: 12 * MILLIS_PER_HOUR + 34 * MILLIS_PER_MINUTE }, // 12:34:00
{ value: '12345', expect: 12 * MILLIS_PER_HOUR + 34 * MILLIS_PER_MINUTE + 5 * MILLIS_PER_SECOND }, // 12:34:05
{ value: '123456', expect: 12 * MILLIS_PER_HOUR + 34 * MILLIS_PER_MINUTE + 56 * MILLIS_PER_SECOND }, // 12:34:56
];
for (const s of testCases) {
it(`handles basic strings digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const sixDigits = [
{ value: '000000', expect: 0 }, // 00:00:00
{ value: '000001', expect: MILLIS_PER_SECOND }, // 00:00:01
{ value: '000100', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '010000', expect: MILLIS_PER_HOUR }, // 01:00:00
{ value: '230000', expect: MILLIS_PER_HOUR * 23 }, // 23:00:00
{ value: '121212', expect: 12 * MILLIS_PER_HOUR + 12 * MILLIS_PER_MINUTE + 12 * MILLIS_PER_SECOND }, // 12:12:12
];
for (const s of sixDigits) {
it(`handles string with 6 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const fiveDigits = [
{ value: '00000', expect: 0 },
{ value: '00001', expect: MILLIS_PER_SECOND }, // 00:00:01
{ value: '00010', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '00100', expect: 10 * MILLIS_PER_MINUTE }, // 00:10:00
{ value: '01000', expect: MILLIS_PER_HOUR }, // 01:00:00
{ value: '10000', expect: 10 * MILLIS_PER_HOUR }, // 10:00:00
{ value: '23000', expect: 23 * MILLIS_PER_HOUR }, // 23:00:00
{ value: '12121', expect: 12 * MILLIS_PER_HOUR + 12 * MILLIS_PER_MINUTE + 1 * MILLIS_PER_SECOND }, // 12:12:01
];
for (const s of fiveDigits) {
it(`handles string with 5 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const fourDigits = [
{ value: '0000', expect: 0 },
{ value: '0001', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '0010', expect: 10 * MILLIS_PER_MINUTE }, // 00:10:00
{ value: '0100', expect: MILLIS_PER_HOUR }, // 01:00:00
{ value: '1000', expect: 10 * MILLIS_PER_HOUR }, // 10:00:00
{ value: '2300', expect: 23 * MILLIS_PER_HOUR }, // 23:00:00
{ value: '1212', expect: 12 * MILLIS_PER_HOUR + 12 * MILLIS_PER_MINUTE }, // 12:12:00
];
for (const s of fourDigits) {
it(`handles string with 4 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const threeDigits = [
{ value: '000', expect: 0 },
{ value: '001', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '010', expect: 10 * MILLIS_PER_MINUTE }, // 00:10:00
{ value: '100', expect: MILLIS_PER_HOUR }, // 01:00:00
{ value: '230', expect: 2 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE }, // 02:30:00
{ value: '121', expect: MILLIS_PER_HOUR + 21 * MILLIS_PER_MINUTE }, // 01:21:00
];
for (const s of threeDigits) {
it(`handles string with 3 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const twoDigits = [
{ value: '00', expect: 0 },
{ value: '01', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '10', expect: 10 * MILLIS_PER_MINUTE }, // 00:10:00
{ value: '23', expect: 23 * MILLIS_PER_MINUTE }, // 00:23:00
];
for (const s of twoDigits) {
it(`handles string with 2 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const singleDigit = [...Array(10).keys()];
for (const s of singleDigit) {
it(`handles string with a single digits ${s}`, () => {
expect(forgivingStringToMillis(`${s}`)).toBe(s * 1000 * 60);
});
}
});
describe('handles overflows', () => {
const ts = [
// minutes overflow
{ value: '99', expect: 99 * MILLIS_PER_MINUTE },
{ value: '1.39.0', expect: 99 * MILLIS_PER_MINUTE },
// seconds overflow
{ value: '0.0.120', expect: 120 * MILLIS_PER_SECOND },
{ value: '0.2.0', expect: 120 * MILLIS_PER_SECOND },
{ value: '0.0.99', expect: 99 * MILLIS_PER_SECOND },
{ value: '0.1.39', expect: 99 * MILLIS_PER_SECOND },
// hours overflow
{ value: '25.0.0', expect: 25 * MILLIS_PER_HOUR },
// hours overflow
{ value: '50.0.0', expect: 50 * MILLIS_PER_HOUR },
];
for (const s of ts) {
it(`handles ${s.value} to the left`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('test fillLeft', () => {
describe('function handles separators', () => {
const testData = [
{ value: '1:2:3:10', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 3 * MILLIS_PER_SECOND }, // 01:02:03
{ value: '2,10', expect: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '2.10', expect: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '2 10', expect: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
];
for (const s of testData) {
it(`handles ${s.value}`, () => {
expect(typeof forgivingStringToMillis(s.value)).toBe('number');
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('parses strings correctly', () => {
const ts = [
{ value: '1.2', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE },
{ value: '1.70', expect: MILLIS_PER_HOUR + 70 * MILLIS_PER_MINUTE },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('handles overflows', () => {
const ts = [
// minutes overflow
{ value: '0.120', expect: 120 * MILLIS_PER_MINUTE },
{ value: '0.99', expect: 99 * MILLIS_PER_MINUTE },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
});
});
import { millisToDelayString } from '../dateConfig';
describe('millisToDelayString()', () => {
it('returns null for null values', () => {
+4 -153
View File
@@ -1,160 +1,11 @@
import { MaybeNumber } from 'ontime-types';
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
/**
* @description safe parse string to int
* @param {string} valueAsString
* @return {number}
* Parses a value in millis to a string which encodes a delay
* @param millis
* @param format
*/
const parse = (valueAsString: string): number => {
const parsed = parseInt(valueAsString, 10);
if (isNaN(parsed)) {
return 0;
}
return Math.abs(parsed);
};
/**
* @description Utility function to check if a string contain am/pm indicators
* @param {string} value
*/
function checkAmPm(value: string) {
let isPM = false;
let isAM = false;
if (value.toLowerCase().includes('pm')) {
isPM = true;
value = value.replace(/pm/i, '');
} else if (value.toLowerCase().includes('p')) {
isPM = true;
value = value.replace(/p/i, '');
}
// we need to remove am, but it doesn't actually change anything
if (value.toLowerCase().includes('am')) {
isAM = true;
value = value.replace(/am/i, '');
} else if (value.toLowerCase().includes('a')) {
isAM = true;
value = value.replace(/a/i, '');
}
return { isAM, isPM, value };
}
/**
* @description Utility function to check if a string contain h / m / s indicators
* @param {string} value
*/
function checkMatchers(value: string) {
const hoursMatch = /(\d+)h/i.exec(value);
const hoursMatchValue = hoursMatch ? parse(hoursMatch[1]) : 0;
const minutesMatch = /(\d+)m/i.exec(value);
const minutesMatchValue = minutesMatch ? parse(minutesMatch[1]) : 0;
const secondsMatch = /(\d+)s/i.exec(value);
const secondsMatchValue = secondsMatch ? parse(secondsMatch[1]) : 0;
if (hoursMatchValue > 0 || minutesMatchValue > 0 || secondsMatchValue > 0) {
return (
hoursMatchValue * MILLIS_PER_HOUR + minutesMatchValue * MILLIS_PER_MINUTE + secondsMatchValue * MILLIS_PER_SECOND
);
}
return { hoursMatchValue };
}
/**
* @description Utility function to infer separators from a whole string
* @param {string} value
* @param {boolean} isAM
* @param {boolean} isPM
*/
function inferSeparators(value: string, isAM: boolean, isPM: boolean) {
const length = value.length;
let inferredMillis = 0;
let addAM = 0;
if (length === 1) {
if (isPM || isAM) {
inferredMillis = parse(value) * MILLIS_PER_HOUR;
if (isAM) {
// this ensures we dont add 12 hours in the end
addAM = inferredMillis;
}
} else {
inferredMillis = parse(value) * MILLIS_PER_MINUTE;
}
} else if (length === 2) {
if (isPM || isAM) {
inferredMillis = parse(value) * inferredMillis;
if (isAM) {
// this ensures we dont add 12 hours in the end
addAM = 12;
}
} else {
inferredMillis = parse(value) * MILLIS_PER_MINUTE;
}
} else if (length === 3) {
inferredMillis = parse(value[0]) * MILLIS_PER_HOUR + parse(value.substring(1)) * MILLIS_PER_MINUTE;
} else if (length === 4) {
inferredMillis = parse(value.substring(0, 2)) * MILLIS_PER_HOUR + parse(value.substring(2)) * MILLIS_PER_MINUTE;
} else if (length >= 5) {
const hours = parse(value.substring(0, 2));
const minutes = parse(value.substring(2, 4));
const seconds = parse(value.substring(4));
inferredMillis = hours * MILLIS_PER_HOUR + minutes * MILLIS_PER_MINUTE + seconds * MILLIS_PER_SECOND;
}
return { inferredMillis, addAM };
}
/**
* @description Parses a time string to millis, auto-filling to the left
* @param {string} value - time string
* @returns {number} - time string in millis
*/
export const forgivingStringToMillis = (value: string): number => {
if (value === '12am') {
return 0;
}
const { isAM, isPM, value: parsingValue } = checkAmPm(value);
const maybeMillisFromMatchers = checkMatchers(parsingValue);
if (typeof maybeMillisFromMatchers === 'number') {
return maybeMillisFromMatchers;
}
let { hoursMatchValue } = maybeMillisFromMatchers;
let millis = 0;
// split string at known separators : , .
const separatorRegex = /[\s,:.]+/;
const [first, second, third] = parsingValue.split(separatorRegex);
if (first != null && second != null && third != null) {
// if string has three sections, treat as [hours] [minutes] [seconds]
millis = parse(first) * MILLIS_PER_HOUR;
millis += parse(second) * MILLIS_PER_MINUTE;
millis += parse(third) * MILLIS_PER_SECOND;
} else if (first != null && second == null && third == null) {
// we only have one section, infer separators
const { inferredMillis, addAM } = inferSeparators(first, isAM, isPM);
millis = inferredMillis;
hoursMatchValue = addAM;
}
if (first != null && second != null && third == null) {
// if string has two sections, treat as [hours] [minutes]
millis = parse(first) * MILLIS_PER_HOUR;
millis += parse(second) * MILLIS_PER_MINUTE;
}
// Add 12 hours if it is PM
if (isPM && hoursMatchValue < 12) {
millis += 12 * MILLIS_PER_HOUR;
}
return millis;
};
export function millisToDelayString(millis: MaybeNumber, format: 'compact' | 'expanded' = 'compact'): string {
if (millis == null || millis === 0) {
return '';
+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/;
+6
View File
@@ -17,4 +17,10 @@ declare global {
}
}
declare module 'react' {
interface CSSProperties {
[key: `--${string}`]: string | number;
}
}
export default {};
@@ -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',
},
})}
/>
@@ -1,9 +1,9 @@
import { Select, Switch } from '@chakra-ui/react';
import { EndAction, TimerType } from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings';
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
import * as Panel from '../PanelUtils';
export default function EditorSettingsForm() {
@@ -15,9 +15,9 @@ export default function EditorSettingsForm() {
const setDangerTime = eventSettings.setDangerTime;
const setDefaultPublic = eventSettings.setDefaultPublic;
const durationInMs = forgivingStringToMillis(eventSettings.defaultDuration);
const warnTimeInMs = forgivingStringToMillis(eventSettings.defaultWarnTime);
const dangerTimeInMs = forgivingStringToMillis(eventSettings.defaultDangerTime);
const durationInMs = parseUserTime(eventSettings.defaultDuration);
const warnTimeInMs = parseUserTime(eventSettings.defaultWarnTime);
const dangerTimeInMs = parseUserTime(eventSettings.defaultDangerTime);
return (
<Panel.Section>
@@ -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'>
@@ -1,13 +1,12 @@
import { Tooltip } from '@chakra-ui/react';
import { useLocalStorage } from '@mantine/hooks';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { Playback } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils';
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useLocalStorage } from '../../../../common/hooks/useLocalStorage';
import { setPlayback } from '../../../../common/hooks/useSocket';
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
import { tooltipDelayMid } from '../../../../ontimeConfig';
import TapButton from '../tap-button/TapButton';
@@ -19,10 +18,10 @@ interface AddTimeProps {
export default function AddTime(props: AddTimeProps) {
const { playback } = props;
const [time, setTime] = useLocalStorage('add-time', 300_000); // 5 minutes
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
const handleTimeChange = (_field: string, value: string) => {
const newTime = forgivingStringToMillis(value);
const newTime = parseUserTime(value);
// cap add time to 1 hour
setTime(Math.min(newTime, MILLIS_PER_HOUR));
};
@@ -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;
}
@@ -4,10 +4,10 @@ import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { setAuxTimer, useAuxTimerControl, useAuxTimerTime } from '../../../../common/hooks/useSocket';
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
import TapButton from '../tap-button/TapButton';
import style from './AuxTimer.module.scss';
@@ -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>
);
}
@@ -64,7 +67,7 @@ function AuxTimerInput() {
const { setDuration } = setAuxTimer;
const handleTimeUpdate = (_field: string, value: string) => {
const newTime = forgivingStringToMillis(value);
const newTime = parseUserTime(value);
setDuration(newTime / 1000); //frontend api is seconds based;
};
@@ -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;
+37 -7
View File
@@ -1,10 +1,10 @@
import { useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import Color from 'color';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { getAccessibleColour } from '../../common/utils/styleUtils';
import BlockRow from './cuesheet-table-elements/BlockRow';
@@ -25,16 +25,25 @@ 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('table-hidden', {});
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
const [columnSizing, setColumnSizing] = useLocalStorage('table-sizes', {});
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} });
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
key: 'table-order',
defaultValue: initialColumnOrder,
});
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} });
const selectedRef = useRef<HTMLTableRowElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
// keep column order in sync with columns
useEffect(() => {
const order = columns.map((col) => col.id as string);
saveColumnOrder(order);
}, [columns, saveColumnOrder]);
const table = useReactTable({
data,
columns,
@@ -64,6 +73,26 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
setColumnSizing({});
};
const reorder = useCallback(
(fromId: string, toId: string) => {
// get index of from
const fromIndex = columnOrder.indexOf(fromId);
// get index of to
const toIndex = columnOrder.indexOf(toId);
if (toIndex === -1) {
return;
}
const reorderedCols = [...columnOrder];
const reorderedItem = reorderedCols.splice(fromIndex, 1);
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
saveColumnOrder(reorderedCols);
},
[columnOrder, saveColumnOrder],
);
const headerGroups = table.getHeaderGroups();
const rowModel = table.getRowModel();
const allLeafColumns = table.getAllLeafColumns();
@@ -83,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} />
<CuesheetHeader headerGroups={headerGroups} saveColumnOrder={reorder} showIndexColumn={showIndexColumn} />
<tbody>
{rowModel.rows.map((row) => {
const key = row.original.id;
@@ -136,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,23 +1,17 @@
import { memo, useEffect } from 'react';
import { Tooltip } from '@chakra-ui/react';
import {
closestCenter,
closestCorners,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { initialColumnOrder } from '../cuesheetCols';
import { SortableCell } from './SortableCell';
@@ -25,17 +19,12 @@ import style from '../Cuesheet.module.scss';
interface CuesheetHeaderProps {
headerGroups: HeaderGroup<OntimeRundownEntry>[];
saveColumnOrder: (fromId: string, toId: string) => void;
showIndexColumn: boolean;
}
function CuesheetHeader(props: CuesheetHeaderProps) {
const { headerGroups } = props;
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
useEffect(() => {
if (!localStorage.getItem('table-order')) {
saveColumnOrder(initialColumnOrder);
}
}, [saveColumnOrder]);
export default function CuesheetHeader(props: CuesheetHeaderProps) {
const { headerGroups, saveColumnOrder, showIndexColumn } = props;
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
@@ -45,21 +34,7 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
// cancel if we do not have an over id
if (over?.id == null) return;
// get index of from
const fromIndex = columnOrder.indexOf(active.id as string);
// get index of to
const toIndex = columnOrder.indexOf(over.id as string);
if (toIndex === -1) {
return;
}
const reorderedCols = [...columnOrder];
const reorderedItem = reorderedCols.splice(fromIndex, 1);
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
saveColumnOrder(reorderedCols);
saveColumnOrder(active.id as string, over.id as string);
};
const sensors = useSensors(
@@ -75,9 +50,6 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
tolerance: 50,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
return (
@@ -86,13 +58,9 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
const key = headerGroup.id;
return (
<DndContext key={key} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleOnDragEnd}>
<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();
@@ -119,5 +87,3 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
</thead>
);
}
export default memo(CuesheetHeader);
@@ -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
/>
);
}
+56 -41
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;
deleteEvent(cursor);
setCursor(previous);
const { entry, index } = getPreviousNormal(rundown, order, cursor);
deleteEvent([cursor]);
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,20 +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, clearSelectedEvents } = useEventSelection();
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
const removeOpenEvent = useCallback(() => {
if (selectedEvents.has(data.id)) {
clearSelectedEvents();
}
unselect(data.id);
}, [unselect, data.id]);
// clear cursor if we are deleting the event that is currently selected
if (cursor === data.id) {
setCursor(null);
}
}, [selectedEvents, data.id, cursor, clearSelectedEvents, setCursor]);
const clearMultiSelection = useCallback(() => {
clearSelectedEvents();
}, [clearSelectedEvents]);
// Create / delete new events
type FieldValue = {
@@ -111,10 +105,12 @@ export default function RundownEntry(props: RundownEntryProps) {
return swapEvents({ from: value as string, to: data.id });
}
case 'delete': {
if (selectedEvents.has(data.id)) {
removeOpenEvent();
if (selectedEvents.size > 1) {
clearMultiSelection();
return deleteEvent(Array.from(selectedEvents));
}
return deleteEvent(data.id);
removeOpenEvent();
return deleteEvent([data.id]);
}
case 'clone': {
const newEvent = cloneEvent(data as OntimeEvent, data.id);
@@ -134,7 +130,7 @@ export default function RundownEntry(props: RundownEntryProps) {
if (selectedEvents.size > 1) {
const changes: Partial<OntimeEvent> = { [field]: value };
batchUpdateEvents(changes, Array.from(selectedEvents));
return clearSelectedEvents();
return;
}
if (field in data) {
// @ts-expect-error -- not sure how to type this
@@ -1,7 +1,9 @@
import { memo } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { ContextMenu } from '../../common/components/context-menu/ContextMenu';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import { useAppMode } from '../../common/stores/appModeStore';
import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
@@ -12,6 +14,8 @@ import style from './RundownExport.module.scss';
const RundownExport = () => {
const isExtracted = window.location.pathname.includes('/rundown');
const appMode = useAppMode((state) => state.mode);
const hideSideBar = isExtracted && appMode === 'run';
const classes = cx([style.rundownExport, isExtracted && style.extracted]);
@@ -21,14 +25,18 @@ const RundownExport = () => {
<div className={style.list}>
<ErrorBoundary>
{!isExtracted && <IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'rundown')} />}
<RundownWrapper />
</ErrorBoundary>
</div>
<div className={style.side}>
<ErrorBoundary>
<EventEditor />
<ContextMenu>
<RundownWrapper />
</ContextMenu>
</ErrorBoundary>
</div>
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
<EventEditor />
</ErrorBoundary>
</div>
)}
</div>
</div>
);
@@ -50,7 +50,7 @@ export default function DelayBlock(props: DelayBlockProps) {
};
const cancelDelayHandler = () => {
deleteEvent(data.id);
deleteEvent([data.id]);
};
const blockClasses = cx([style.delay, hasCursor ? style.hasCursor : null]);
@@ -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);
@@ -113,6 +111,7 @@ export default function EventBlock(props: EventBlockProps) {
value: false,
}),
},
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
]
: [
{
@@ -230,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>
@@ -1,11 +1,11 @@
import { memo } from 'react';
import { Select, Switch } from '@chakra-ui/react';
import { EndAction, MaybeString, TimerType, TimeStrategy } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { millisToString, parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import { forgivingStringToMillis, millisToDelayString } from '../../../../common/utils/dateConfig';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EventEditor.module.scss';
@@ -51,7 +51,7 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
}
if (field === 'timeWarning' || field === 'timeDanger') {
const newTime = forgivingStringToMillis(value as string);
const newTime = parseUserTime(value as string);
updateEvent({ id: eventId, [field]: newTime });
return;
}
@@ -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,21 +10,25 @@ 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;
unselect: (id: string) => void;
}
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
@@ -37,6 +41,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
return set({
selectedEvents: selectedEvents.add(id),
anchoredIndex: index,
cursor: id,
});
}
@@ -81,7 +86,17 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set() }),
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null }),
clearMultiSelect: () => {
const { selectedEvents } = get();
const [firstSelected] = selectedEvents;
set({ selectedEvents: new Set(firstSelected || undefined), anchoredIndex: null });
},
unselect: (id: string) => {
const { selectedEvents } = get();
selectedEvents.delete(id);
set({ selectedEvents });
},
}));
export function getSelectionMode(event: MouseEvent): SelectionMode {
@@ -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 -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.3",
"version": "3.2.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.3",
"version": "3.2.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",
+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);
@@ -119,10 +119,10 @@ export async function rundownDelete(_req: Request, res: Response<MessageResponse
}
}
export async function deleteEventById(req: Request, res: Response<MessageResponse | ErrorResponse>) {
export async function deletesEventById(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
await deleteEvent(req.params.eventId);
res.status(204).send({ message: 'Event deleted' });
await deleteEvent(req.body.ids);
res.status(204).send({ message: 'Events deleted' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
@@ -1,7 +1,7 @@
import express from 'express';
import {
deleteEventById,
deletesEventById,
rundownApplyDelay,
rundownBatchPut,
rundownDelete,
@@ -14,6 +14,7 @@ import {
} from './rundown.controller.js';
import {
paramsMustHaveEventId,
rundownArrayOfIds,
rundownBatchPutValidator,
rundownPostValidator,
rundownPutValidator,
@@ -35,5 +36,5 @@ router.patch('/reorder/', rundownReorderValidator, rundownReorder);
router.patch('/swap', rundownSwapValidator, rundownSwap);
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
router.delete('/', rundownArrayOfIds, deletesEventById);
router.delete('/all', rundownDelete);
router.delete('/:eventId', paramsMustHaveEventId, deleteEventById);
@@ -64,3 +64,14 @@ export const paramsMustHaveEventId = [
next();
},
];
export const rundownArrayOfIds = [
body('ids').isArray().exists(),
body('ids.*').isString(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -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,10 @@ 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 { 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,12 +47,17 @@ 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');
}
// all custom fields keys are lowercase
const newObjectProperty = parseProperty(property.toLowerCase(), value);
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);
@@ -57,8 +66,13 @@ const actionHandlers: Record<string, ActionHandler> = {
}
});
updateEvent(patchEvent);
if (shouldThrottle) {
if (throttledUpdateEvent(patchEvent)) {
return { payload: 'throttled' };
}
} else {
updateEvent(patchEvent);
}
return { payload: 'success' };
},
/* Message Service */
@@ -1,40 +1,60 @@
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) {
if (property.startsWith('custom:')) {
const customKey = property.split(':')[1];
const customKey = property.split(':')[1].toLocaleLowerCase(); // all custom fields keys are lowercase
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) };
}
@@ -51,6 +71,5 @@ export function updateEvent(patchEvent: Partial<OntimeEvent> & { id: string }) {
if (!isOntimeEvent(event)) {
throw new Error('Can only update events');
}
editEvent(patchEvent);
}
+29 -16
View File
@@ -17,8 +17,10 @@ import {
resolveExternalsDirectory,
resolveStylesDirectory,
resolvedPath,
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';
@@ -43,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}`);
@@ -55,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');
@@ -143,24 +148,23 @@ const checkStart = (currentState: OntimeStartOrder) => {
export const initAssets = async () => {
checkStart(OntimeStartOrder.InitAssets);
await dbLoadingProcess;
await clearUploadfolder();
populateStyles();
populateDemo();
};
/**
* 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
@@ -184,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();
@@ -198,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 };
};
@@ -239,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
@@ -249,7 +265,6 @@ export const shutdown = async (exitCode = 0) => {
await restoreService.clear();
}
// TODO: Clear token
expressServer?.close();
runtimeService.shutdown();
integrationService.shutdown();
@@ -258,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}`);
}
}
@@ -1,11 +1,12 @@
import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils';
import { EndAction, OntimeEvent, Playback, TimeStrategy, TimerType } from 'ontime-types';
import { EndAction, OntimeEvent, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import {
getCurrent,
getExpectedFinish,
getRollTimers,
getRuntimeOffset,
getTimerPhase,
getTotalDuration,
normaliseEndTime,
skippedOutOfEvent,
@@ -977,6 +978,32 @@ describe('getRollTimers()', () => {
expect(state).toStrictEqual(expected);
});
it('loads upcoming event while waiting to roll', () => {
const singleEventList: Partial<OntimeEvent>[] = [
{
id: '1',
timeStart: 72000000, // 20:00
timeEnd: 72010000, // 20:10
isPublic: true,
},
];
const now = 6000; // 00:01
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 0,
timeToNext: 72000000 - now,
nextEvent: singleEventList[0],
nextPublicEvent: singleEventList[0],
currentEvent: null,
currentPublicEvent: null,
};
const state = getRollTimers(singleEventList as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('handles roll that goes over midnight', () => {
const singleEventList: Partial<OntimeEvent>[] = [
{
@@ -1756,3 +1783,177 @@ describe('getTotalDuration()', () => {
expect(millisToString(duration)).toBe('62:00:00');
});
});
describe('getTimerPhase()', () => {
it('should be None if the timer is not running', () => {
const state = {
timer: {
addedTime: 0,
current: null,
duration: null,
elapsed: null,
expectedFinish: null,
finishedAt: null,
playback: Playback.Stop,
phase: TimerPhase.None,
secondaryTimer: null,
startedAt: null,
},
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.None);
});
it('can be in overtime', () => {
const state = {
timer: {
addedTime: 0,
current: -50,
duration: 1000,
playback: Playback.Play,
},
eventNow: {
timeDanger: 100,
timeWarning: 200,
},
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Overtime);
});
it('can be danger', () => {
const state = {
timer: {
addedTime: 0,
current: 0,
duration: 1000,
playback: Playback.Play,
},
eventNow: {
timeDanger: 100,
timeWarning: 200,
},
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Danger);
});
it('can be warning', () => {
const state = {
timer: {
addedTime: 0,
current: 150,
duration: 1000,
playback: Playback.Play,
},
eventNow: {
timeDanger: 100,
timeWarning: 200,
},
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Warning);
});
it('it default if the timer is playing and there is none of the above', () => {
const state = {
timer: {
addedTime: 0,
current: 250,
duration: 1000,
playback: Playback.Play,
},
eventNow: {
timeDanger: 100,
timeWarning: 200,
},
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Default);
});
it('#1042 identifies waiting to roll', () => {
const state = {
clock: 55691050,
eventNow: null,
publicEventNow: null,
eventNext: null,
publicEventNext: null,
runtime: {
selectedEventIndex: null,
numEvents: 1,
offset: null,
plannedStart: 55860000,
plannedEnd: 55880000,
actualStart: null,
expectedEnd: null,
},
timer: {
addedTime: 0,
current: null,
duration: null,
elapsed: 0,
expectedFinish: null,
finishedAt: null,
phase: 'none',
playback: 'roll',
secondaryTimer: 168950,
startedAt: null,
},
_timer: {
forceFinish: null,
totalDelay: 0,
pausedAt: null,
secondaryTarget: 55860000,
},
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Pending);
});
it('#1042 identifies waiting to roll', () => {
const state = {
clock: 55691050,
eventNow: null,
publicEventNow: null,
eventNext: null,
publicEventNext: null,
runtime: {
selectedEventIndex: null,
numEvents: 1,
offset: null,
plannedStart: 55860000,
plannedEnd: 55880000,
actualStart: null,
expectedEnd: null,
},
timer: {
addedTime: 0,
current: null,
duration: null,
elapsed: 0,
expectedFinish: null,
finishedAt: null,
phase: 'none',
playback: 'roll',
secondaryTimer: 168950,
startedAt: null,
},
_timer: {
forceFinish: null,
totalDelay: 0,
pausedAt: null,
secondaryTarget: 55860000,
},
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Pending);
});
});
@@ -41,6 +41,7 @@ export class AuxTimerService {
@broadcastReturn
pause() {
this.stopInterval();
return this.timer.pause(this.getTime());
}
@@ -3,9 +3,9 @@ import { LogOrigin, MaybeNumber, MaybeString, OSCSettings, OscSubscription } fro
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { parseTemplateNested } from './integrationUtils.js';
import { isObject } from '../../utils/varUtils.js';
import { logger } from '../../classes/Logger.js';
import { OscServer } from '../../adapters/OscAdapter.js';
import { stringToOSCArgs } from '../../utils/oscArgParser.js';
/**
* @description Class contains logic towards outgoing OSC communications
@@ -60,27 +60,24 @@ export class OscIntegration implements IIntegration<OscSubscription, OSCSettings
}
const parsedAddress = parseTemplateNested(address, state || {});
const parsedPayload = payload ? parseTemplateNested(payload, state || {}) : undefined;
const parsedArguments = stringToOSCArgs(parsedPayload);
try {
this.emit(parsedAddress, parsedPayload);
this.emit(parsedAddress, parsedArguments);
} catch (error) {
logger.error(LogOrigin.Tx, `OSC Integration: ${error}`);
}
}
}
emit(address: string, payload?: ArgumentType) {
emit(address: string, args: ArgumentType[]) {
if (!this.oscClient) {
return;
}
//TODO: Look into using bundles
const message = new Message(address);
if (payload) {
if (isObject(payload)) {
message.append(JSON.stringify(payload));
} else {
message.append(payload);
}
}
message.append(args);
this.oscClient.send(message);
}
@@ -1,3 +1,4 @@
import { stringToOSCArgs } from '../../utils/oscArgParser.js';
import { parseTemplateNested } from './integrationUtils.js';
describe('parseTemplateNested()', () => {
@@ -96,3 +97,73 @@ describe('parseNestedTemplate() -> resolveAliasData()', () => {
expect(easyParse).toBe('5 to testing-3 {{human.not.found}}');
});
});
describe('parseNestedTemplate() -> stringToOSCArgs()', () => {
it('specific osc requirements', () => {
const data = {
not: {
so: {
easy: 'data with space',
empty: '',
number: 1234,
stringNumber: '1234',
},
},
};
const payloads = [
{
test: '"string with space and {{not.so.easy}}"',
expect: [{ type: 'string', value: 'string with space and data with space' }],
},
{
test: '',
expect: [],
},
{
test: ' ',
expect: [],
},
{
test: '""',
expect: [{ type: 'string', value: '' }],
},
{
test: '"string with space and {{not.so.empty}}"',
expect: [{ type: 'string', value: 'string with space and ' }],
},
{
test: '"string with space and {{not.so.number}}"',
expect: [{ type: 'string', value: 'string with space and 1234' }],
},
{
test: '"string with space and {{not.so.stringNumber}}"',
expect: [{ type: 'string', value: 'string with space and 1234' }],
},
{
test: '"{{not.so.easy}}" 1',
expect: [
{ type: 'string', value: 'data with space' },
{ type: 'integer', value: 1 },
],
},
{
test: '"{{not.so.empty}}" 1',
expect: [
{ type: 'string', value: '' },
{ type: 'integer', value: 1 },
],
},
{
test: '',
expect: [],
},
];
payloads.forEach((payload) => {
const parsedPayload = parseTemplateNested(payload.test, data);
const parsedArguments = stringToOSCArgs(parsedPayload);
expect(parsedArguments).toStrictEqual(payload.expect);
});
});
});
@@ -90,9 +90,9 @@ export async function addEvent(eventData: PatchWithId & { after?: string }): Pro
* deletes event by its ID
* @param eventId
*/
export async function deleteEvent(eventId: string) {
export async function deleteEvent(eventIds: string[]) {
const scopedMutation = cache.mutateCache(cache.remove);
const { didMutate } = await scopedMutation({ eventId });
const { didMutate } = await scopedMutation({ eventIds });
if (didMutate === false) {
return;
@@ -102,7 +102,7 @@ export async function deleteEvent(eventId: string) {
updateRuntimeOnChange();
// notify timer and external services of change
notifyChanges({ timer: [eventId], external: true });
notifyChanges({ timer: eventIds, external: true });
}
/**
@@ -347,9 +347,22 @@ describe('remove() mutation', () => {
test('deletes an event from the rundown', () => {
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
const testRundown: OntimeRundown = [mockEvent];
const { newRundown } = remove({ eventId: mockEvent.id, persistedRundown: testRundown });
const { newRundown } = remove({ eventIds: [mockEvent.id], persistedRundown: testRundown });
expect(newRundown.length).toBe(0);
});
test('deletes multiple events from the rundown', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1' } as OntimeEvent,
{ type: SupportedEvent.Block, id: '2' } as OntimeBlock,
{ type: SupportedEvent.Delay, id: '3' } as OntimeDelay,
{ type: SupportedEvent.Event, id: '4' } as OntimeEvent,
{ type: SupportedEvent.Event, id: '5' } as OntimeEvent,
{ type: SupportedEvent.Event, id: '6' } as OntimeEvent,
];
const { newRundown } = remove({ eventIds: ['1', '2', '3'], persistedRundown: testRundown });
expect(newRundown.length).toBe(3);
expect(newRundown.at(0).id).toBe('4');
});
});
describe('edit() mutation', () => {
@@ -9,7 +9,7 @@ import {
OntimeRundown,
OntimeRundownEntry,
} from 'ontime-types';
import { generateId, deleteAtIndex, insertAtIndex, reorderArray, swapEventData, checkIsNextDay } from 'ontime-utils';
import { generateId, insertAtIndex, reorderArray, swapEventData, checkIsNextDay } from 'ontime-utils';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { createPatch } from '../../utils/parser.js';
@@ -263,13 +263,12 @@ export function add({ persistedRundown, atIndex, event }: AddArgs): Required<Mut
return { newRundown, newEvent, didMutate: true };
}
type RemoveArgs = MutationParams<{ eventId: string }>;
type RemoveArgs = MutationParams<{ eventIds: string[] }>;
export function remove({ persistedRundown, eventId }: RemoveArgs): MutatingReturn {
const atIndex = persistedRundown.findIndex((event) => event.id === eventId);
const newRundown = deleteAtIndex(atIndex, persistedRundown);
export function remove({ persistedRundown, eventIds }: RemoveArgs): MutatingReturn {
const newRundown = persistedRundown.filter((event) => !eventIds.includes(event.id));
return { newRundown, didMutate: atIndex !== -1 };
return { newRundown, didMutate: persistedRundown.length !== newRundown.length };
}
export function removeAll(): MutatingReturn {
@@ -100,7 +100,7 @@ export function handleCustomField(
}
/** List of event properties which do not need the rundown to be regenerated */
enum regenerateWhitelist {
export enum regenerateWhitelist {
'id',
'cue',
'title',
@@ -122,6 +122,14 @@ export function isDataStale(patch: Partial<OntimeRundownEntry>): boolean {
return Object.keys(patch).some((key) => !(key in regenerateWhitelist));
}
/**
* given a key, returns whether it is whitelisted
* @param path
*/
export function willCauseRegeneration(key: keyof OntimeEvent): boolean {
return !(key in regenerateWhitelist);
}
/**
* Given an event and a patch to that event checks whether there are actual changes to the dataset
* @param existingEvent
@@ -26,8 +26,7 @@ export function getShouldTimerUpdate(previousValue: number, currentValue: MaybeN
return false;
}
// we avoid trigger ahead since it can cause duplicate triggers
// we force the timer value to be negative because we need a ceiling reduction
const shouldUpdateTimer = millisToSeconds(-currentValue) !== millisToSeconds(-previousValue);
const shouldUpdateTimer = millisToSeconds(currentValue) !== millisToSeconds(previousValue);
return shouldUpdateTimer;
}
+68 -20
View File
@@ -1,4 +1,4 @@
import { MaybeNumber, MaybeString, OntimeEvent, TimerType } from 'ontime-types';
import { MaybeNumber, MaybeString, OntimeEvent, Playback, TimerPhase, TimerType } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { RuntimeState } from '../stores/runtimeState.js';
import { timerConfig } from '../config/config.js';
@@ -118,18 +118,21 @@ type RollTimers = {
* Finds loading information given a current rundown and time
* @param {OntimeEvent[]} rundown - List of playable events
* @param {number} timeNow - time now in ms
* @returns {{}}
*/
export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTimers => {
let nowIndex: number | null = null; // index of event now
let nowId: string | null = null; // id of event now
let publicIndex: number | null = null; // index of public event now
let nextIndex: number | null = null; // index of next event
let publicNextIndex: number | null = null; // index of next public event
let timeToNext: number | null = null; // counter: time for next event
let publicTimeToNext: number | null = null; // counter: time for next public event
export const getRollTimers = (rundown: OntimeEvent[], timeNow: number, currentIndex?: number | null): RollTimers => {
let nowIndex: MaybeNumber = null; // index of event now
let nowId: MaybeString = null; // id of event now
let publicIndex: MaybeNumber = null; // index of public event now
let nextIndex: MaybeNumber = null; // index of next event
let publicNextIndex: MaybeNumber = null; // index of next public event
let timeToNext: MaybeNumber = null; // counter: time for next event
let publicTimeToNext: MaybeNumber = null; // counter: time for next public event
const lastEvent = rundown[rundown.length - 1];
const hasLoaded = currentIndex !== null;
const canFilter = hasLoaded && currentIndex === rundown.length - 1;
const filteredRundown = canFilter ? rundown.slice(currentIndex) : rundown;
const lastEvent = filteredRundown.at(-1);
const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd);
let nextEvent: OntimeEvent | null = null;
@@ -141,7 +144,7 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
// we are past last end
// preload first and find next
const firstEvent = rundown[0];
const firstEvent = filteredRundown.at(0);
nextIndex = 0;
nextEvent = firstEvent;
timeToNext = firstEvent.timeStart + dayInMs - timeNow;
@@ -153,11 +156,11 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
// look for next public
// dev note: we feel that this is more efficient than filtering
// since the next event will likely be close to the one playing
for (const event of rundown) {
for (const event of filteredRundown) {
if (event.isPublic) {
nextPublicEvent = event;
// we need the index before this was sorted
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
publicNextIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
break;
}
}
@@ -168,7 +171,7 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
// keep track of the end times when looking for public
let publicTime = -1;
for (const event of rundown) {
for (const event of filteredRundown) {
// When does the event end (handle midnight)
const normalEnd = normaliseEndTime(event.timeStart, event.timeEnd);
@@ -183,12 +186,12 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
// public event might not be the one running
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
publicIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (hasNotEnded && hasStarted && !nowFound) {
// event is running
currentEvent = event;
nowIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
nowIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
nowId = event.id;
nowFound = true;
@@ -196,7 +199,7 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
if (event.isPublic) {
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
publicIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (normalEnd > timeNow) {
// event will run
@@ -214,7 +217,7 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
if (nextIndex === null || timeToEventStart < timeToNext) {
timeToNext = timeToEventStart;
nextEvent = event;
nextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
nextIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
if (event.isPublic) {
@@ -222,7 +225,7 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
if (publicNextIndex === null || timeToEventStart < publicTimeToNext) {
publicTimeToNext = timeToEventStart;
nextPublicEvent = event;
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
publicNextIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
}
}
@@ -358,3 +361,48 @@ export function getExpectedEnd(state: RuntimeState): MaybeNumber {
}
return state.runtime.plannedEnd - state.runtime.offset + state._timer.totalDelay;
}
/**
* Utility checks whether the playback is considered to be active
* @param state
* @returns
*/
function isPlaybackActive(state: RuntimeState): boolean {
return (
state.timer.playback === Playback.Play ||
state.timer.playback === Playback.Pause ||
state.timer.playback === Playback.Roll
);
}
/**
* Checks running timer to see which phase it currently is in
* @param state
*/
export function getTimerPhase(state: RuntimeState): TimerPhase {
if (!isPlaybackActive(state)) {
return TimerPhase.None;
}
const current = state.timer.current;
if (current === null || state.eventNow === null) {
return TimerPhase.Pending;
}
if (current < 0) {
return TimerPhase.Overtime;
}
const danger = state.eventNow.timeDanger;
if (current <= danger) {
return TimerPhase.Danger;
}
const warning = state.eventNow.timeWarning;
if (current <= warning) {
return TimerPhase.Warning;
}
return TimerPhase.Default;
}
+11 -5
View File
@@ -2,6 +2,8 @@ import { fileURLToPath } from 'url';
import path, { dirname, join } from 'path';
import fs from 'fs';
import { rm } from 'fs/promises';
import { config } from './config.js';
import { ensureDirectory } from '../utils/fileManagement.js';
@@ -57,17 +59,13 @@ const currentDir = dirname(__dirname);
export const srcDirectory = isProduction ? currentDir : path.join(currentDir, '../');
// resolve path to external
const productionPath = path.join(srcDirectory, '../../resources/extraResources/client');
const productionPath = path.join(srcDirectory, 'client/');
const devPath = path.join(srcDirectory, '../../client/build/');
const dockerPath = path.join(srcDirectory, 'client/');
export const resolvedPath = (): string => {
if (isTest) {
return devPath;
}
if (isDocker) {
return dockerPath;
}
if (isProduction) {
return productionPath;
}
@@ -144,3 +142,11 @@ export const resolveCrashReportDirectory = getAppDataPath();
// path to projects
export const resolveProjectsDirectory = join(getAppDataPath(), config.projects);
export async function clearUploadfolder() {
try {
await rm(uploadsFolderPath, { recursive: true });
} catch (_) {
//we dont care that there was no folder
}
}

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