Compare commits

...

108 Commits

Author SHA1 Message Date
Carlos Valente 46e9fcb3cb refactor: populate readme on start 2024-10-22 21:18:33 +02:00
Carlos Valente f2d7f4ab82 bump version to 3.6.2 2024-10-22 20:24:22 +02:00
Alex Christoffer Rasmussen a5496434f8 todo note about sanitiseCustomFields (#1281) 2024-10-22 19:13:24 +02:00
Alex Christoffer Rasmussen 42984eed87 Improve xlsx import (#1271)
* use xlsx

* add existing custom fields to import map

* keep colours of existing custom fields

* use unknown

* fix

* revert

* allow space and upper case

* fix test

* define empty sheet
2024-10-22 19:13:07 +02:00
Carlos Valente 59c1c77d48 refactor: timeline has overrideable styles 2024-10-21 22:16:18 +02:00
Carlos Valente dd13eb31d8 refactor: expose public dir 2024-10-20 22:35:26 +02:00
Carlos Valente 82810f1cb1 feat: add sessions stats endpoint 2024-10-20 22:35:26 +02:00
Carlos Valente 71c468d069 refactor: session resource 2024-10-20 22:35:26 +02:00
Carlos Valente cf40001a2a improve style 2024-10-19 20:34:00 +02:00
arc-alex d458928090 fix external path and remove unneeded stuff 2024-10-19 20:34:00 +02:00
Carlos Valente 76e12af116 docs: add initial documentation to external assets 2024-10-19 20:34:00 +02:00
Carlos Valente b577057768 refactor: notes as data source 2024-10-19 20:33:36 +02:00
Carlos Valente dd4a165b08 refactor: add nif to navigation 2024-10-19 20:33:20 +02:00
Carlos Valente ab847a324b refactor: timer strategy is default 2024-10-19 14:31:58 +02:00
Carlos Valente cc5c097ddc refactor: tooltips for time locks 2024-10-19 14:31:58 +02:00
jwetzell ea2330e23b add invisible shortcuts for zooming in 2024-10-18 19:40:21 +02:00
jwetzell c5870452e3 make custom fields case sensitive (#1242)
* remove custom field lowercasing

* don't allow custom field form to submit duplicate field

* remove unused

* update custom field tests

* also keep upper case when editing

* show key in UI

* also kep upper case when creating a new field

* fix test

* allow old forced case keys to stay as is

* allow space

* add extension to import

* supply initial key to CustomFieldForm

* refactor: improve element contrast

* refactor: style and composition

* consistent use of `customFieldLabelToKey`

* fix CopyTag

* remove log

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-10-18 18:53:23 +02:00
Alex Christoffer Rasmussen b73b529c00 hide seconds toggle in cuesheet (#1272) 2024-10-18 13:08:34 +02:00
Carlos Valente 8ef807d6cb refactor: items active by default 2024-10-16 16:34:57 +02:00
Carlos Valente 1d4ddea596 chore: extract views directory 2024-10-15 22:28:50 +02:00
Carlos Valente 4cba970cda feat: add loader to views 2024-10-15 22:28:50 +02:00
Carlos Valente bad9dab0a0 refactor: compound timeline 2024-10-15 22:28:50 +02:00
Carlos Valente f11218a757 refactor: control container growth 2024-10-15 22:28:50 +02:00
Carlos Valente 77e87e1d0c refactor: make message service stateless 2024-10-15 16:11:53 +02:00
Carlos Valente a4aa513d1f refactor: share store initial state 2024-10-15 16:11:53 +02:00
Carlos Valente b73448f827 feat: implement colour picker 2024-10-15 16:01:42 +02:00
Carlos Valente 687ad68726 refactor: recover style override 2024-10-15 16:01:42 +02:00
Carlos Valente 089eba3877 refactor: param type colour 2024-10-15 16:01:42 +02:00
Carlos Valente 24b0cfd24e chore: consistent naming in externals 2024-10-15 15:51:01 +02:00
Carlos Valente 5950551da2 refactor: simplify setup of asset paths 2024-10-15 15:51:01 +02:00
Carlos Valente e46948772e refactor: network interfaces can be copied or followed 2024-10-13 20:39:58 +02:00
Carlos Valente 78fa1df99c refactor: blink style 2024-10-12 11:02:26 +02:00
Alex Christoffer Rasmussen 0500368982 Revert http test (#1257)
* Revert "refactor: allow messages not starting with http"

This reverts commit 887e5c448e.

* Revert "refactor: allow messages not starting with http"

This reverts commit 6ce275da7a.
2024-10-12 10:52:14 +02:00
Carlos Valente 2d53323f60 refactor: unify time to start logic 2024-10-11 16:01:44 +02:00
Carlos Valente 08ca3cd3a5 fix: calculate time to start 2024-10-11 16:01:44 +02:00
Carlos Valente 16fd44a441 refactor: allow aux change direction while playing 2024-10-11 16:01:22 +02:00
Carlos Valente 9a6ca7ac67 refactor: show electron dev tools 2024-10-11 13:26:15 +02:00
Carlos Valente 2c63bbff76 refactor: arrow used in new window navigation 2024-10-11 13:26:15 +02:00
Carlos Valente d2c34757f9 refactor: navigate in electron 2024-10-11 13:26:15 +02:00
Carlos Valente aeadc78578 refactor: remove unused db 2024-10-11 13:26:15 +02:00
Carlos Valente bf98f9792a feat: open asset directories 2024-10-11 13:26:15 +02:00
Carlos Valente 6d2b59ef73 refactor: collocate view navigation 2024-10-11 13:26:15 +02:00
Carlos Valente 0a53018930 refactor: improve native menu links 2024-10-11 13:26:15 +02:00
Carlos Valente f1cac8d1c4 feat: allow download of current project 2024-10-11 13:26:15 +02:00
Carlos Valente cfb76153a0 refactor: create project form state from params 2024-10-11 13:26:15 +02:00
Alex Christoffer Rasmussen fee2021ad7 Fix: time-to-end offset (#1252)
* flip the sign correctly for time-to-end offset
2024-10-10 16:20:59 +02:00
Carlos Valente db956f7955 refactor: await init rundown 2024-10-09 20:15:10 +02:00
Carlos Valente 887e5c448e refactor: allow messages not starting with http 2024-10-09 19:19:20 +02:00
Carlos Valente 6ce275da7a refactor: allow messages not starting with http 2024-10-09 19:19:20 +02:00
Carlos Valente 213f516f71 refactor: message does not have negative style 2024-10-09 19:18:47 +02:00
Alex Christoffer Rasmussen 86e6f8b58c bump version to 3.6.1 (#1237) 2024-10-03 14:50:05 +02:00
Carlos Valente 1de062a38e refactor: remove alt shortcut 2024-10-03 08:35:56 +02:00
Carlos Valente eb7a30fcf3 refactor: improve resizing of column 2024-10-02 22:34:19 +02:00
Carlos Valente e2cbe43a2b fix: form element alignment in form 2024-10-02 21:57:52 +02:00
Carlos Valente 7877af571f fix: apply delay 2024-10-02 21:44:03 +02:00
Carlos Valente 086bd5b644 docs: add discord link 2024-10-01 08:18:19 +02:00
Carlos Valente f045cf2292 bump version to 3.6.0 2024-09-28 20:41:21 +02:00
Alex Christoffer Rasmussen 78e2f217a4 handle timer type whitespace (#1225) 2024-09-28 20:34:04 +02:00
Carlos Valente 8a5cef79a8 refactor: simplify secondary styles 2024-09-27 20:14:58 +02:00
Carlos Valente 4d8bea2940 feat: timer preview 2024-09-27 20:14:58 +02:00
Carlos Valente fa709dc9be fix: issue where event next was stale 2024-09-24 22:25:45 +02:00
Carlos Valente 177c9a35ec feat: electron links open in browser 2024-09-24 13:46:09 +02:00
Carlos Valente 7f7691a452 fix: stop integration if output disabled 2024-09-23 19:39:19 +02:00
Carlos Valente 92a16ea33b refactor: migrate fit text logic 2024-09-23 16:56:15 +02:00
Carlos Valente bc6b7c5596 refactor: fit user message in screen 2024-09-23 16:56:15 +02:00
Carlos Valente 66083813f9 refactor: allow local shutdown 2024-09-23 16:55:22 +02:00
Carlos Valente d8cf1abb37 docs: warn of shutdown conditions 2024-09-23 16:55:22 +02:00
Carlos Valente 5b471980f5 refactor: enable timer type and end action default settings 2024-09-09 10:15:55 +02:00
Carlos Valente ad8f6dfcc7 bump version to 3.5.1 2024-09-07 08:49:39 +02:00
Carlos Valente 4706ed39f6 refactor: scope rundown to selected 2024-09-07 08:46:32 +02:00
Carlos Valente a8182cac8c refactor: simplify logic 2024-09-07 08:46:32 +02:00
Carlos Valente f9a5c55c07 refactor: reactive options 2024-09-07 08:46:32 +02:00
Carlos Valente 980ae7a7c5 refactor: show upcoming events 2024-09-07 08:46:32 +02:00
Carlos Valente db8ed93d41 fix: handle multiple days 2024-09-07 08:46:32 +02:00
Carlos Valente f8ca5b9cef fix: clear internal state on stop 2024-09-07 08:45:43 +02:00
Carlos Valente b9db08089f fix: ignore 0 duration events 2024-09-07 08:45:43 +02:00
Carlos Valente a3020ef3b2 fix: issue with roll evaluating multiple days 2024-09-07 08:45:43 +02:00
Carlos Valente 2492972097 fix: incorrect falsy check 2024-09-04 09:28:15 +02:00
Alex Christoffer Rasmussen aa4ee546ec Merge import (#1164)
---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-09-01 20:38:59 +02:00
Carlos Valente 0abaf7724d refactor: refetch on change 2024-09-01 17:18:48 +02:00
Carlos Valente 7b932ef0f7 refactor: add options to hide schedule 2024-09-01 10:41:54 +02:00
Carlos Valente 8bd7645c95 ui: rename mode to match UI 2024-08-31 10:55:32 +02:00
Carlos Valente 81f0e61953 refactor: add extra data to sentry logs 2024-08-29 15:10:42 +02:00
Carlos Valente 3877e207d0 chore: remove incorrect label 2024-08-27 14:21:35 +02:00
Carlos Valente d78e8838c3 bump version to 3.5.0 2024-08-27 13:16:01 +02:00
Carlos Valente 16c7107069 chore: mark timeline as beta 2024-08-27 13:16:01 +02:00
Alex Christoffer Rasmussen e20cd87673 fix: stop log incorrectly (#1185)
* fix: stop log incorrectly

* prevent start if already playing
2024-08-27 13:09:39 +02:00
Alex Christoffer Rasmussen db5fbd8019 add time in block to restore point (#1192) 2024-08-27 13:08:16 +02:00
Gyuris Gellért 25aefe322b Hungarian translation (#1187)
* Add Hungarian translation
2024-08-27 13:06:23 +02:00
Carlos Valente 73db14c383 refactor: show running gap in UI 2024-08-27 12:58:18 +02:00
Carlos Valente f5936e5254 refactor: unify logic 2024-08-27 12:58:18 +02:00
Carlos Valente 25c7915cf7 refactor: account for overlapping in rundown 2024-08-27 12:58:18 +02:00
Carlos Valente c43b6f37f9 refactor: accumulate times 2024-08-27 12:58:18 +02:00
Carlos Valente 6e5034ef72 refactor: show progress on top 2024-08-27 12:58:18 +02:00
Carlos Valente 66774321a5 refactor: calculate rundown duration 2024-08-27 12:58:18 +02:00
Carlos Valente d71df886f2 style: clarify function scope 2024-08-27 12:58:18 +02:00
Carlos Valente abcee741e2 refactor: prevent index collision 2024-08-27 12:58:18 +02:00
Alex Christoffer Rasmussen ee1b5b7fdd Keep offset when taking over playback with roll v2 (#1184)
* pass on offset

* account for offset in roll

* prevent roll from overtime

* add test
2024-08-26 18:09:03 +02:00
Alex Christoffer Rasmussen 1b1823e0fe Fix: change api endpoint (#1183)
* remove async from parseProperty

* swap shouldThrottle check

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-08-20 11:22:02 +02:00
Alex Christoffer Rasmussen 340f5478fb add shortcut to jump between blocks (#1181)
* add shortcut

* chore: rename getRelevantBlock to getPreviousBlock
2024-08-19 17:20:58 +02:00
Alex Christoffer Rasmussen f708dc0bc4 Fix current block (#1170)
* pass full rundown to loadNow

* remove prevBlock from state

* refactor: simplify load logic (#1174)

* send clock to client on block start to avoid it showing -1 in the client

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-08-15 16:25:29 +02:00
Carlos Valente 49467c66b7 fix: check worksheet errors before submit 2024-08-15 11:12:21 +02:00
Carlos Valente b539b2ecd5 refactor: review styles and implement BEM for user override 2024-08-13 21:54:43 +02:00
Alex Christoffer Rasmussen dc06a9afc3 fix TimerPahse difference from load and reload (#1179)
* use loadEvent function to reload

* rename reload to updateLoaded
2024-08-12 20:14:06 +02:00
Carlos Valente d3620b52f4 refactor: only broadcast public 2024-08-11 18:18:53 +02:00
Carlos Valente 66ed92e215 fix: stop playback when event is deleted 2024-08-10 12:50:30 +02:00
Carlos Valente 347f333b3e refactor: unify progress bar logic 2024-08-09 22:51:50 +02:00
Carlos Valente c3fb3061f6 fix: use unfiltered events in timeline 2024-08-09 20:01:30 +02:00
248 changed files with 5586 additions and 2928 deletions
-3
View File
@@ -40,9 +40,6 @@ dist/
ontime-db
ontime-external/
# working database
apps/server/src/preloaded-db/db.json
# versioning file
**/ONTIME_VERSION.js
+2 -1
View File
@@ -23,6 +23,7 @@ COPY --from=builder /app/apps/client/build ./client/
# Prepare Backend
COPY --from=builder /app/apps/server/dist/ ./server/
COPY --from=builder /app/apps/server/src/external/ ./external/
COPY --from=builder /app/apps/server/src/user/ ./user/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
@@ -32,4 +33,4 @@ CMD ["node", "server/docker.cjs"]
# Build and run commands
# !!! Note that this command needs pre-build versions of the UI and server apps
# docker buildx build . -t getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/external/db/ -v ./ontime-styles:/external/styles/ getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
+19 -18
View File
@@ -14,8 +14,10 @@
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
## Using Ontime?
## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
## Using Ontime?
Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
@@ -35,6 +37,16 @@ Ontime is made by entertainment and broadcast engineers and used by
- Theatres and opera houses
- Houses of worship
## Main features
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
... and a lot more ...
### For live environments
Ontime is designed for use in live environments. \
@@ -83,6 +95,7 @@ IP.ADDRESS:4001/clock > Simple clock view
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
IP.ADDRESS:4001/countdown > Countdown to anything
IP.ADDRESS:4001/studio > Studio Clock
IP.ADDRESS:4001/timeline > Timeline
```
```
@@ -100,26 +113,13 @@ IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/operator > automated views for operators
```
More documentation is available [in our docs](https://docs.getontime.no)
## Main features
- [x] Distribute data over network and render it in the browser
- [x] Collaborative
- [x] Extendable
- [x] Send messages to different screen types
- [x] Differentiate between backstage and public data
- [x] Workflow for managing delays
- [x] Rich protocol integrations for Control and Feedback
- [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
More information is available [in our docs](https://docs.getontime.no)
## Roadmap
### Continued development
Ontime is under active development. We continue adding and tweaking features in collaboration with users.
Ontime is under active development. We continue adding and improving features in collaboration with users.
Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
@@ -161,8 +161,9 @@ If you are a developer and would like to contribute with code, please open an is
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
## Links
See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no)
- [Ontime website](https://getontime.no)
- [Documentation](https://docs.getontime.no)
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
## License
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.5.0-beta.2",
"version": "3.6.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.5.0-beta.2",
"version": "3.6.2",
"private": true,
"type": "module",
"dependencies": {
+67 -10
View File
@@ -14,6 +14,7 @@ import { useClientPath } from './common/hooks/useClientPath';
import Log from './features/log/Log';
import withPreset from './features/PresetWrapper';
import withData from './features/viewers/ViewWrapper';
import ViewLoader from './views/ViewLoader';
import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
@@ -27,7 +28,7 @@ const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
const Timeline = React.lazy(() => import('./features/viewers/timeline/TimelinePage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
const Public = React.lazy(() => import('./features/viewers/public/Public'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
@@ -75,16 +76,72 @@ export default function AppRouter() {
<React.Suspense fallback={null}>
<SentryRoutes>
<Route path='/' element={<Navigate to='/timer' />} />
<Route path='/timer' element={<STimer />} />
<Route path='/public' element={<SPublic />} />
<Route path='/minimal' element={<SMinimalTimer />} />
<Route path='/clock' element={<SClock />} />
<Route path='/countdown' element={<SCountdown />} />
<Route path='/backstage' element={<SBackstage />} />
<Route path='/studio' element={<SStudio />} />
<Route
path='/timer'
element={
<ViewLoader>
<STimer />
</ViewLoader>
}
/>
<Route
path='/public'
element={
<ViewLoader>
<SPublic />
</ViewLoader>
}
/>
<Route
path='/minimal'
element={
<ViewLoader>
<SMinimalTimer />
</ViewLoader>
}
/>
<Route
path='/clock'
element={
<ViewLoader>
<SClock />
</ViewLoader>
}
/>
<Route
path='/countdown'
element={
<ViewLoader>
<SCountdown />
</ViewLoader>
}
/>
<Route
path='/backstage'
element={
<ViewLoader>
<SBackstage />
</ViewLoader>
}
/>
<Route
path='/studio'
element={
<ViewLoader>
<SStudio />
</ViewLoader>
}
/>
{/*/!* Lower third cannot have a loading screen *!/*/}
<Route path='/lower' element={<SLowerThird />} />
<Route path='/timeline' element={<STimeline />} />
<Route
path='/timeline'
element={
<ViewLoader>
<STimeline />
</ViewLoader>
}
/>
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
+4 -2
View File
@@ -18,6 +18,7 @@ const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
export const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
// resolve port
const STATIC_PORT = 4001;
@@ -31,5 +32,6 @@ export const projectDataURL = `${serverURL}/project`;
export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`;
export const stylesPath = 'external/styles/override.css';
export const overrideStylesURL = `${serverURL}/${stylesPath}`;
export const userAssetsPath = 'user';
export const cssOverridePath = 'styles/override.css';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
+2 -10
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
@@ -11,7 +11,7 @@ const dbPath = `${apiEntryUrl}/db`;
/**
* HTTP request to the current DB
*/
async function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download/`, { filename });
}
@@ -138,14 +138,6 @@ export async function deleteProject(filename: string): Promise<MessageResponse>
return res.data;
}
/**
* HTTP request to retrieve application info
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${dbPath}/info`);
return res.data;
}
/**
* Utility function gets project from db
* @param fileName
+14
View File
@@ -0,0 +1,14 @@
import axios from 'axios';
import { GetInfo } from 'ontime-types';
import { apiEntryUrl } from './constants';
const sessionPath = `${apiEntryUrl}/session`;
/**
* HTTP request to retrieve application info
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`);
return res.data;
}
@@ -8,5 +8,5 @@
.linkIcon {
margin-left: $element-inner-spacing;
display: inline-block;
transform: rotate(45deg);
@include rotate-fourty-five;
}
@@ -1,5 +1,6 @@
import { PropsWithChildren } from 'react';
import { PropsWithChildren, useState } from 'react';
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
import { tooltipDelayFast } from '../../../ontimeConfig';
@@ -7,26 +8,34 @@ import { Size } from '../../models/Util.type';
import copyToClipboard from '../../utils/copyToClipboard';
interface CopyTagProps {
copyValue: string;
label: string;
className?: string;
size?: Size;
disabled?: boolean;
onClick?: () => void;
}
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
const { label, className, size = 'xs', disabled, children } = props;
const { copyValue, label, size = 'xs', disabled, children, onClick } = props;
const [copied, setCopied] = useState(false);
const handleClick = () => copyToClipboard(children as string);
const handleClick = () => {
copyToClipboard(copyValue);
setCopied(true);
// reset copied state
setTimeout(() => setCopied(false), 1000);
};
return (
<Tooltip label={label} openDelay={tooltipDelayFast}>
<ButtonGroup size={size} isAttached className={className}>
<Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
<ButtonGroup size={size} isAttached>
<Button variant='ontime-subtle' tabIndex={-1} onClick={onClick} isDisabled={disabled}>
{children}
</Button>
<IconButton
aria-label={label}
icon={<IoCopy />}
icon={copied ? <IoCheckmark /> : <IoCopy />}
variant='ontime-filled'
tabIndex={-1}
onClick={handleClick}
@@ -28,9 +28,11 @@ class ErrorBoundary extends React.Component {
});
Sentry.withScope((scope) => {
scope.setExtras('error', error);
scope.setExtras('store', runtimeStore.getState());
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
scope.setExtras({
error,
store: runtimeStore.getState(),
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
});
const eventId = Sentry.captureException(error);
this.setState({ eventId, info });
});
@@ -0,0 +1,63 @@
/**
* Copied from
* https://github.com/namhong2001/react-textfit/blob/master/index.tsx
*/
import { HTMLAttributes, PropsWithChildren, useCallback, useEffect, useRef } from 'react';
import { bsearch } from './fitText.utils';
interface FitTextProps extends HTMLAttributes<HTMLDivElement> {
mode?: 'single' | 'multi';
min?: number; // inclusive
max?: number; // inclusive
}
export function FitText(props: PropsWithChildren<FitTextProps>) {
const { children, mode = 'multi', min = 16, max = 256, ...elementProps } = props;
const ref = useRef<HTMLDivElement>(null);
const isOverflown = useCallback(() => {
const el = ref.current;
if (!el) return false;
return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
}, []);
const setFontSize = useCallback(() => {
const el = ref.current;
if (!el) return;
const originVisibility = el.style.visibility;
el.style.visibility = 'hidden';
const fontSize = bsearch(min, max + 1, (mid) => {
el.style.fontSize = `${mid}px`;
return !isOverflown();
});
el.style.fontSize = `${fontSize}px`;
el.style.visibility = originVisibility;
}, [isOverflown, min, max]);
useEffect(() => {
const el = ref.current;
if (!el) return;
setFontSize();
const observer = new ResizeObserver(setFontSize);
observer.observe(el);
return () => observer.disconnect();
}, [children, mode, setFontSize]);
return (
<div
ref={ref}
style={{
whiteSpace: mode === 'single' ? 'nowrap' : 'normal',
}}
{...elementProps}
>
{children}
</div>
);
}
@@ -0,0 +1,18 @@
/**
* @param low inclusive, must be true on predicate function
* @param high exclusive,
* @param predicate predicate function
*/
export const bsearch = (low: number, high: number, predicate: (mid: number) => boolean): number => {
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (mid === low) break;
if (predicate(mid)) {
low = mid;
} else {
high = mid;
}
}
return low;
};
@@ -1,12 +1,12 @@
import { MaybeNumber } from 'ontime-types';
import { clamp } from '../../utils/math';
import { getProgress } from '../../utils/getProgress';
import './MultiPartProgressBar.scss';
interface MultiPartProgressBar {
now: MaybeNumber;
complete: number;
complete: MaybeNumber;
normalColor: string;
warning?: MaybeNumber;
warningColor: string;
@@ -31,10 +31,9 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
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 - dangerWidth, 0, 100) : 0;
const percentRemaining = 100 - getProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
return (
<div
@@ -47,6 +47,8 @@ $button-size: 3rem;
@include action-link;
padding: 0.75rem 1.5rem;
gap: 0.5rem;
width: 100%;
cursor: pointer;
&:hover {
background-color: $menu-hover-bg;
@@ -70,9 +72,33 @@ $button-size: 3rem;
.linkIcon {
margin-left: auto;
transform: rotate(45deg);
@include rotate-fourty-five;
}
.separator {
border-color: $border-color-ondark;
}
.sectionHeader {
font-size: calc(1rem - 2px);
margin-left: 1rem;
color: $gray-700;
}
.bottom {
margin-top: auto;
margin-bottom: 1rem;
}
.interfaces {
padding: 0.5rem 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.goIcon {
@include rotate-fourty-five;
margin-left: 0.25rem;
margin-bottom: 0.25rem;
}
@@ -1,6 +1,6 @@
import { memo, useRef } from 'react';
import { memo, PropsWithChildren, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import {
Drawer,
DrawerBody,
@@ -18,11 +18,17 @@ import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutl
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig';
import { isLocalhost, serverPort } from '../../api/constants';
import useClickOutside from '../../hooks/useClickOutside';
import useElectronEvent from '../../hooks/useElectronEvent';
import useInfo from '../../hooks-query/useInfo';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks, openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import { RenameClientModal } from '../client-modal/RenameClientModal';
import CopyTag from '../copy-tag/CopyTag';
import style from './NavigationMenu.module.scss';
@@ -40,6 +46,7 @@ function NavigationMenu(props: NavigationMenuProps) {
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore();
const location = useLocation();
const menuRef = useRef<HTMLDivElement | null>(null);
@@ -50,7 +57,7 @@ function NavigationMenu(props: NavigationMenuProps) {
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay />
<DrawerContent>
<DrawerContent maxWidth='21rem'>
<DrawerHeader>
<DrawerCloseButton size='lg' />
Ontime
@@ -96,39 +103,27 @@ function NavigationMenu(props: NavigationMenuProps) {
<hr className={style.separator} />
<Link
to='/editor'
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
tabIndex={0}
className={`${style.link} ${location.pathname === '/editor' && style.current}`}
>
<IoLockClosedOutline />
Editor
<IoArrowUp className={style.linkIcon} />
</Link>
<Link
to='/cuesheet'
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
tabIndex={0}
>
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
<IoLockClosedOutline />
Cuesheet
<IoArrowUp className={style.linkIcon} />
</Link>
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
</ClientLink>
<ClientLink to='op' current={location.pathname === '/op'}>
<IoLockClosedOutline />
Operator
<IoArrowUp className={style.linkIcon} />
</Link>
</ClientLink>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<Link
key={route.url}
to={route.url}
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
tabIndex={0}
>
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
{route.label}
<IoArrowUp className={style.linkIcon} />
</Link>
</ClientLink>
))}
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
</DrawerBody>
</DrawerContent>
</Drawer>
@@ -137,4 +132,70 @@ function NavigationMenu(props: NavigationMenuProps) {
);
}
interface OtherAddressesProps {
currentLocation: string;
}
function OtherAddresses(props: OtherAddressesProps) {
const { currentLocation } = props;
const { data } = useInfo();
// there is no point showing this if we only have one interface
if (data.networkInterfaces.length < 2) {
return null;
}
return (
<div className={style.bottom}>
<div className={style.sectionHeader}>Accessible on external networks</div>
<div className={style.interfaces}>
{data?.networkInterfaces?.map((nif) => {
if (nif.name === 'localhost') {
return null;
}
const address = `http://${nif.address}:${serverPort}${currentLocation}`;
return (
<CopyTag
key={nif.name}
copyValue={address}
onClick={() => openLink(address)}
label='Copy IP or navigate to address'
>
{nif.address} <IoArrowUp className={style.goIcon} />
</CopyTag>
);
})}
</div>
</div>
);
}
interface ClientLinkProps {
current: boolean;
to: string;
}
function ClientLink(props: PropsWithChildren<ClientLinkProps>) {
const { current, to, children } = props;
const { isElectron } = useElectronEvent();
const classes = cx([style.link, current && style.current]);
if (isElectron) {
return (
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
{children}
<IoArrowUp className={style.linkIcon} />
</button>
);
}
return (
<Link to={`/${to}`} className={classes} tabIndex={0}>
{children}
</Link>
);
}
export default memo(NavigationMenu);
@@ -1,22 +1,23 @@
import { clamp } from '../../utils/math';
import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress';
import './ProgressBar.scss';
interface ProgressBarProps {
now?: number;
complete?: number;
current: MaybeNumber;
duration: MaybeNumber;
hidden?: boolean;
className?: string;
}
export default function ProgressBar(props: ProgressBarProps) {
const { now = 0, complete = 100, hidden, className = '' } = props;
const percentComplete = clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
const { current, duration, hidden, className = '' } = props;
const progress = getProgress(current, duration);
return (
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
<div className='progress-bar__indicator' style={{ width: `${percentComplete}%` }} />
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} />
</div>
);
}
@@ -0,0 +1,27 @@
.inline {
display: flex;
align-items: center;
gap: 1rem;
}
// attempt to match with ontimeTextInputs
.input {
color: $gray-200;
border: 1px solid transparent;
border-radius: 3px;
background-color: $gray-1200;
padding: 0 1rem;
font-size: 1rem;
height: 2.5rem;
outline: none;
&:hover {
background-color: $gray-1100;
}
&:focus {
background-color: $gray-1000;
color: $gray-50;
border: 1px solid $blue-500;
}
}
@@ -0,0 +1,33 @@
import { useState } from 'react';
import PopoverPicker from '../input/popover-picker/PopoverPicker';
import style from './InlineColourPicker.module.scss';
interface InlineColourPickerProps {
name: string;
value: string;
}
const ensureHex = (value: string) => {
if (!value.startsWith('#')) {
return `#${value}`;
}
return value;
};
export default function InlineColourPicker(props: InlineColourPickerProps) {
const { name, value } = props;
const [colour, setColour] = useState(() => ensureHex(value));
const debouncedChange = (value: string) => {
setColour(value);
};
return (
<div className={style.inline}>
<PopoverPicker color={colour} onChange={debouncedChange} />
<input type='hidden' name={name} value={colour} />
</div>
);
}
@@ -16,6 +16,7 @@ import {
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import InlineColourPicker from './InlineColourPicker';
import { ParamField } from './types';
interface EditFormInputProps {
@@ -23,8 +24,8 @@ interface EditFormInputProps {
}
export default function ParamInput(props: EditFormInputProps) {
const [searchParams] = useSearchParams();
const { paramField } = props;
const [searchParams] = useSearchParams();
const { id, type, defaultValue } = paramField;
if (type === 'persist') {
@@ -81,6 +82,12 @@ export default function ParamInput(props: EditFormInputProps) {
);
}
if (type === 'colour') {
const currentvalue = `#${searchParams.get(id) ?? defaultValue}`;
return <InlineColourPicker name={id} value={currentvalue} />;
}
const defaultStringValue = searchParams.get(id) ?? defaultValue;
const { prefix, placeholder } = paramField;
@@ -19,6 +19,16 @@ import style from './ViewParamsEditor.module.scss';
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
/**
* Utility remove the # character from a hex string
*/
function sanitiseColour(colour: string) {
if (colour.startsWith('#')) {
return colour.substring(1);
}
return colour;
}
/**
* Makes a new URLSearchParams object from the given params object
*/
@@ -40,8 +50,13 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
// compare which values are different from the default values
Object.entries(paramsObj).forEach(([id, value]) => {
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
newSearchParams.set(id, value);
if (typeof value === 'string' && value.length) {
// we dont know which values contain colours
// unfortunately this means we run all the strings through the sanitation
const valueWithoutHash = sanitiseColour(value);
if (defaultValues[id] !== valueWithoutHash) {
newSearchParams.set(id, valueWithoutHash);
}
}
});
return newSearchParams;
@@ -23,10 +23,11 @@ type MultiOptionsField = {
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 };
type ColourField = { type: 'colour'; defaultValue: string; placeholder?: string };
type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
export type ParamField = BaseField &
(StringField | BooleanField | NumberField | OptionsField | MultiOptionsField | PersistedField);
(OptionsField | MultiOptionsField | StringField | NumberField | BooleanField | ColourField | PersistedField);
export type ViewOption = ParamSection | ParamField;
/**
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { CustomFields } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CUSTOM_FIELDS } from '../api/constants';
import { getCustomFields } from '../api/customFields';
@@ -14,7 +14,7 @@ export default function useCustomFields() {
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
@@ -3,7 +3,7 @@ import { GetInfo } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_INFO } from '../api/constants';
import { getInfo } from '../api/db';
import { getInfo } from '../api/session';
import { ontimePlaceholderInfo } from '../models/Info';
export default function useInfo() {
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants';
import { fetchNormalisedRundown } from '../api/rundown';
@@ -16,7 +16,7 @@ export default function useRundown() {
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
@@ -1,11 +1,31 @@
export default function useElectronEvent() {
const isElectron = window?.process?.type === 'renderer';
import { useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
const sendToElectron = (channel: string, args?: string | Record<string, unknown>) => {
if (isElectron) {
window?.ipcRenderer.send(channel, args);
const isElectron = window.process?.type === 'renderer';
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
export default function useElectronEvent() {
const navigate = useNavigate();
const sendToElectron = useCallback((channel: string, args?: string | Record<string, unknown>) => {
if (isElectron && ipcRenderer) {
ipcRenderer.send(channel, args);
}
};
}, []);
// listen to requests to change the editor location
useEffect(() => {
if (isElectron) {
ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => {
navigate(location, { relative: 'route' });
});
}
// Clean the listener after the component is dismounted
return () => {
ipcRenderer?.removeAllListeners();
};
}, [navigate]);
return { isElectron, sendToElectron };
}
+38 -6
View File
@@ -32,7 +32,16 @@ import { useEditorSettings } from '../stores/editorSettings';
*/
export const useEventAction = () => {
const queryClient = useQueryClient();
const { defaultPublic, linkPrevious, defaultDuration, defaultWarnTime, defaultDangerTime } = useEditorSettings();
const {
defaultPublic,
linkPrevious,
defaultTimeStrategy,
defaultDuration,
defaultWarnTime,
defaultDangerTime,
defaultTimerType,
defaultEndAction,
} = useEditorSettings();
/**
* Calls mutation to add new event
@@ -46,17 +55,17 @@ export const useEventAction = () => {
networkMode: 'always',
});
// options to any new block (event / delay / block)
type BaseOptions = {
after?: string;
};
// options to blocks of type OntimeEvent
type EventOptions = BaseOptions &
Partial<{
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: string;
defaultWarnTime: number;
defaultDangerTime: number;
}>;
/**
@@ -68,13 +77,12 @@ export const useEventAction = () => {
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEvent)) {
// merge creation time options with event settings
const applicationOptions = {
after: options?.after,
defaultPublic: options?.defaultPublic ?? defaultPublic,
lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious,
defaultWarnTime,
defaultDangerTime,
};
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
@@ -89,6 +97,7 @@ export const useEventAction = () => {
}
}
// Override event with options from editor settings
if (applicationOptions.defaultPublic) {
newEvent.isPublic = true;
}
@@ -104,6 +113,18 @@ export const useEventAction = () => {
if (newEvent.timeWarning === undefined) {
newEvent.timeWarning = parseUserTime(defaultWarnTime);
}
if (newEvent.timerType === undefined) {
newEvent.timerType = defaultTimerType;
}
if (newEvent.endAction === undefined) {
newEvent.endAction = defaultEndAction;
}
if (newEvent.timeStrategy === undefined) {
newEvent.timeStrategy = defaultTimeStrategy;
}
}
// handle adding options that concern all event type
@@ -117,7 +138,18 @@ export const useEventAction = () => {
logAxiosError('Failed adding event', error);
}
},
[_addEventMutation, defaultDangerTime, defaultDuration, defaultPublic, defaultWarnTime, linkPrevious, queryClient],
[
_addEventMutation,
defaultDangerTime,
defaultDuration,
defaultEndAction,
defaultPublic,
defaultTimerType,
defaultTimeStrategy,
defaultWarnTime,
linkPrevious,
queryClient,
],
);
/**
-159
View File
@@ -1,159 +0,0 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
export type TLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none';
export type TOptions = {
logLevel?: TLogLevel;
maxFontSize?: number;
minFontSize?: number;
onFinish?: (fontSize: number) => void;
onStart?: () => void;
resolution?: number;
};
const LOG_LEVEL: Record<TLogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
none: 100,
};
const useFitText = ({
logLevel: logLevelOption = 'info',
maxFontSize = 100,
minFontSize = 20,
onFinish,
onStart,
resolution = 5,
}: TOptions = {}) => {
const logLevel = LOG_LEVEL[logLevelOption];
const initState = useCallback(() => {
return {
calcKey: 0,
fontSize: maxFontSize,
fontSizePrev: minFontSize,
fontSizeMax: maxFontSize,
fontSizeMin: minFontSize,
};
}, [maxFontSize, minFontSize]);
const ref = useRef<HTMLDivElement>(null);
const innerHtmlPrevRef = useRef<string | null>();
const isCalculatingRef = useRef(false);
const [state, setState] = useState(initState);
const { calcKey, fontSize, fontSizeMax, fontSizeMin, fontSizePrev } = state;
// Monitor div size changes and recalculate on resize
let animationFrameId: number | null = null;
const [ro] = useState(
() =>
new ResizeObserver(() => {
animationFrameId = window.requestAnimationFrame(() => {
if (isCalculatingRef.current) {
return;
}
onStart && onStart();
isCalculatingRef.current = true;
// `calcKey` is used in the dependencies array of
// `useIsoLayoutEffect` below. It is incremented so that the font size
// will be recalculated even if the previous state didn't change (e.g.
// when the text fit initially).
setState({
...initState(),
calcKey: calcKey + 1,
});
});
}),
);
useEffect(() => {
if (ref.current) {
ro.observe(ref.current);
}
return () => {
animationFrameId && window.cancelAnimationFrame(animationFrameId);
ro.disconnect();
};
}, [animationFrameId, ro]);
// Recalculate when the div contents change
const innerHtml = ref.current && ref.current.innerHTML;
useEffect(() => {
if (calcKey === 0 || isCalculatingRef.current) {
return;
}
if (innerHtml !== innerHtmlPrevRef.current) {
onStart && onStart();
setState({
...initState(),
calcKey: calcKey + 1,
});
}
innerHtmlPrevRef.current = innerHtml;
}, [calcKey, initState, innerHtml, onStart]);
// Check overflow and resize font
useLayoutEffect(() => {
// Don't start calculating font size until the `resizeKey` is incremented
// above in the `ResizeObserver` callback. This avoids an extra resize
// on initialization.
if (calcKey === 0) {
return;
}
const isWithinResolution = Math.abs(fontSize - fontSizePrev) <= resolution;
const isOverflow =
!!ref.current &&
(ref.current.scrollHeight > ref.current.offsetHeight || ref.current.scrollWidth > ref.current.offsetWidth);
const isFailed = isOverflow && fontSize === fontSizePrev;
const isAsc = fontSize > fontSizePrev;
// Return if the font size has been adjusted "enough" (change within `resolution`)
// reduce font size by one increment if it's overflowing.
if (isWithinResolution) {
if (isFailed) {
isCalculatingRef.current = false;
if (logLevel <= LOG_LEVEL.info) {
console.info(`[use-fit-text] reached \`minFontSize = ${minFontSize}\` without fitting text`);
}
} else if (isOverflow) {
setState({
fontSize: isAsc ? fontSizePrev : fontSizeMin,
fontSizeMax,
fontSizeMin,
fontSizePrev,
calcKey,
});
} else {
isCalculatingRef.current = false;
onFinish && onFinish(fontSize);
}
return;
}
// Binary search to adjust font size
let delta: number;
let newMax = fontSizeMax;
let newMin = fontSizeMin;
if (isOverflow) {
delta = isAsc ? fontSizePrev - fontSize : fontSizeMin - fontSize;
newMax = Math.min(fontSizeMax, fontSize);
} else {
delta = isAsc ? fontSizeMax - fontSize : fontSizePrev - fontSize;
newMin = Math.max(fontSizeMin, fontSize);
}
setState({
calcKey,
fontSize: fontSize + delta / 2,
fontSizeMax: newMax,
fontSizeMin: newMin,
fontSizePrev: fontSize,
});
}, [calcKey, fontSize, fontSizeMax, fontSizeMin, fontSizePrev, onFinish, ref, resolution]);
return { fontSize: `${fontSize}%`, ref };
};
export default useFitText;
+41 -6
View File
@@ -1,4 +1,4 @@
import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket';
@@ -28,11 +28,43 @@ export const useOperator = () => {
return useRuntimeStore(featureSelector);
};
export const useMessageControl = () => {
export const useTimerViewControl = () => {
const featureSelector = (state: RuntimeStore) => ({
timer: state.message.timer,
external: state.message.external,
onAir: state.onAir,
blackout: state.message.timer.blackout,
blink: state.message.timer.blink,
secondarySource: state.message.timer.secondarySource,
});
return useRuntimeStore(featureSelector);
};
export const useTimerMessageInput = () => {
const featureSelector = (state: RuntimeStore) => ({
text: state.message.timer.text,
visible: state.message.timer.visible,
});
return useRuntimeStore(featureSelector);
};
export const useExternalMessageInput = () => {
const featureSelector = (state: RuntimeStore) => ({
text: state.message.external,
visible: state.message.timer.secondarySource === 'external',
});
return useRuntimeStore(featureSelector);
};
export const useMessagePreview = () => {
const featureSelector = (state: RuntimeStore) => ({
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
phase: state.timer.phase,
showAuxTimer: state.message.timer.secondarySource === 'aux',
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null,
});
return useRuntimeStore(featureSelector);
@@ -41,8 +73,11 @@ export const useMessageControl = () => {
export const setMessage = {
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
externalText: (payload: string) => socketSendJson('message', { external: payload }),
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
timerSecondary: (payload: TimerMessage['secondarySource']) =>
socketSendJson('message', { timer: { secondarySource: payload } }),
};
export const usePlaybackControl = () => {
@@ -50,6 +85,7 @@ export const usePlaybackControl = () => {
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
timerPhase: state.timer.phase,
});
return useRuntimeStore(featureSelector);
@@ -150,7 +186,6 @@ export const useClock = () => {
/** Used by the progress bar components */
export const useProgressData = () => {
const featureSelector = (state: RuntimeStore) => ({
addedTime: state.timer.addedTime,
current: state.timer.current,
duration: state.timer.duration,
timeWarning: state.eventNow?.timeWarning ?? null,
+1 -1
View File
@@ -7,5 +7,5 @@ export const ontimePlaceholderInfo: GetInfo = {
version: '2.0.0',
serverPort: 4001,
osc: oscPlaceholderSettings,
cssOverride: '',
publicDir: '',
};
@@ -1,3 +1,5 @@
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
import { validateEndAction, validateTimerType, validateTimeStrategy } from 'ontime-utils';
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
@@ -5,39 +7,63 @@ import { booleanFromLocalStorage } from '../utils/localStorage';
type EditorSettingsStore = {
defaultDuration: string;
linkPrevious: boolean;
defaultTimeStrategy: TimeStrategy;
defaultWarnTime: string;
defaultDangerTime: string;
defaultPublic: boolean;
defaultTimerType: TimerType;
defaultEndAction: EndAction;
setDefaultDuration: (defaultDuration: string) => void;
setLinkPrevious: (linkPrevious: boolean) => void;
setTimeStrategy: (timeStrategy: TimeStrategy) => void;
setWarnTime: (warnTime: string) => void;
setDangerTime: (dangerTime: string) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
setDefaultTimerType: (defaultTimerType: TimerType) => void;
setDefaultEndAction: (defaultEndAction: EndAction) => void;
};
export const editorSettingsDefaults = {
duration: '00:10:00',
linkPrevious: true,
timeStrategy: TimeStrategy.LockDuration,
warnTime: '00:02:00', // 120000 same as backend
dangerTime: '00:01:00', // 60000 same as backend
isPublic: true,
timerType: TimerType.CountDown,
endAction: EndAction.None,
};
enum EditorSettingsKeys {
DefaultDuration = 'ontime-default-duration',
LinkPrevious = 'ontime-link-previous',
DefaultTimeStrategy = 'ontime-time-strategy',
DefaultWarnTime = 'ontime-default-warn-time',
DefaultDangerTime = 'ontime-default-danger-time',
DefaultPublic = 'ontime-default-public',
DefaultTimerType = 'ontime-default-timer-type',
DefaultEndAction = 'ontime-default-end-action',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => {
return {
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? editorSettingsDefaults.duration,
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, editorSettingsDefaults.linkPrevious),
defaultTimeStrategy: validateTimeStrategy(
localStorage.getItem(EditorSettingsKeys.DefaultTimeStrategy),
editorSettingsDefaults.timeStrategy,
),
defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime,
defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime,
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
defaultTimerType: validateTimerType(
localStorage.getItem(EditorSettingsKeys.DefaultTimerType),
editorSettingsDefaults.timerType,
),
defaultEndAction: validateEndAction(
localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
editorSettingsDefaults.endAction,
),
setDefaultDuration: (defaultDuration) =>
set(() => {
@@ -50,6 +76,12 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
return { linkPrevious };
}),
setTimeStrategy: (defaultTimeStrategy) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultTimeStrategy, String(defaultTimeStrategy));
return { defaultTimeStrategy };
}),
setWarnTime: (defaultWarnTime) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultWarnTime, String(defaultWarnTime));
@@ -65,5 +97,15 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { defaultPublic };
}),
setDefaultTimerType: (defaultTimerType) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultTimerType, String(defaultTimerType));
return { defaultTimerType };
}),
setDefaultEndAction: (defaultEndAction) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction));
return { defaultEndAction };
}),
};
});
+1 -54
View File
@@ -1,60 +1,7 @@
import isEqual from 'react-fast-compare';
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types';
import { RuntimeStore, runtimeStorePlaceholder } from 'ontime-types';
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
export const runtimeStorePlaceholder: RuntimeStore = {
clock: 0,
timer: {
addedTime: 0,
current: null,
duration: null,
elapsed: null,
expectedFinish: null,
finishedAt: null,
phase: TimerPhase.None,
playback: Playback.Stop,
secondaryTimer: null,
startedAt: null,
},
onAir: false,
message: {
timer: {
text: '',
visible: false,
blink: false,
blackout: false,
},
external: {
text: '',
visible: false,
},
},
runtime: {
selectedEventIndex: null,
numEvents: 0,
offset: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
expectedEnd: null,
},
currentBlock: {
block: null,
startedAt: null,
},
eventNow: null,
eventNext: null,
publicEventNow: null,
publicEventNext: null,
auxtimer1: {
current: 0,
direction: SimpleDirection.CountUp,
duration: 0,
playback: SimplePlayback.Stop,
},
frozen: false,
};
const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
export const runtimeStore = createWithEqualityFn<RuntimeStore>(
@@ -0,0 +1,23 @@
import { MaybeNumber } from 'ontime-types';
import { clamp } from './math';
/**
* Returns completion percentage of a progress bar
* This code assumes the current time and duration have addedTime already applied
*/
export function getProgress(current: MaybeNumber, duration: MaybeNumber) {
if (current === null || duration === null) {
return 0;
}
if (current <= 0) {
return 100;
}
if (current >= duration) {
return 0;
}
return clamp(((duration - current) / duration) * 100, 0, 100);
}
+13 -2
View File
@@ -1,6 +1,6 @@
import { Log, RuntimeStore } from 'ontime-types';
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
import { CLIENT_LIST, isProduction, RUNTIME, websocketUrl } from '../api/constants';
import { CLIENT_LIST, CUSTOM_FIELDS, isProduction, RUNDOWN, RUNTIME, websocketUrl } from '../api/constants';
import { ontimeQueryClient } from '../queryClient';
import {
getClientId,
@@ -176,6 +176,17 @@ export const connectSocket = () => {
updateDevTools({ auxtimer1: payload });
break;
}
case 'ontime-refetch': {
// the refetch message signals that the rundown has changed in the server side
const { revision } = payload;
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
}
break;
}
}
} catch (_) {
// ignore unhandled
+10 -3
View File
@@ -6,7 +6,7 @@ import { APP_SETTINGS } from '../api/constants';
import { ontimeQueryClient } from '../queryClient';
/**
* Returns current time in milliseconds
* Returns current time in milliseconds from midnight
* @returns {number}
*/
export function nowInMillis(): number {
@@ -101,7 +101,7 @@ export const formatTime = (
* @param duration
* @returns
*/
export function formatDuration(duration: number): string {
export function formatDuration(duration: number, hideSeconds = true): string {
// durations should never be negative, we handle it here to flag if there is an issue in future
if (duration <= 0) {
return '0h 0m';
@@ -111,10 +111,17 @@ export function formatDuration(duration: number): string {
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
let result = '';
if (hours > 0) {
result += `${hours}h `;
result += `${hours}h`;
}
if (minutes > 0) {
result += `${minutes}m`;
}
if (!hideSeconds) {
const seconds = Math.floor((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
if (seconds > 0) {
result += `${seconds}s`;
}
}
return result;
}
+1
View File
@@ -3,3 +3,4 @@ export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/rele
export const websiteUrl = 'https://www.getontime.no';
export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
@@ -1,3 +1,7 @@
.halfWidth {
width: 50%;
}
.fullWidth {
width: 100%;
}
@@ -37,3 +41,9 @@
.flex {
display: flex;
}
.twoCols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@@ -78,7 +78,7 @@ export default function UrlPresetsForm() {
const addNew = () => {
prepend({
enabled: false,
enabled: true,
alias: '',
pathAndParams: '',
});
@@ -4,6 +4,7 @@ import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { CustomField, CustomFieldLabel } from 'ontime-types';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
import CustomFieldForm from './CustomFieldForm';
@@ -36,6 +37,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
onSubmit={handleEdit}
initialColour={colour}
initialLabel={label}
initialKey={field}
/>
</td>
</tr>
@@ -47,7 +49,12 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
<td>
<Swatch color={colour} />
</td>
<td className={style.fullWidth}>{label}</td>
<td className={style.halfWidth}>{label}</td>
<td className={style.fullWidth}>
<CopyTag label='Copy key to use in integrations' copyValue={field}>
{field}
</CopyTag>
</td>
<td className={style.actions}>
<IconButton
size='sm'
@@ -2,10 +2,11 @@ import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Input } from '@chakra-ui/react';
import { CustomField } from 'ontime-types';
import { isAlphanumeric } from 'ontime-utils';
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import * as Panel from '../../PanelUtils';
import style from '../FeatureSettings.module.scss';
@@ -15,10 +16,13 @@ interface CustomFieldsFormProps {
onCancel: () => void;
initialColour?: string;
initialLabel?: string;
initialKey?: string;
}
export default function CustomFieldForm(props: CustomFieldsFormProps) {
const { onSubmit, onCancel, initialColour, initialLabel } = props;
const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props;
const { data } = useCustomFields();
// we use this to force an update
const [_, setColour] = useState(initialColour || '');
@@ -31,7 +35,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
getValues,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm({
defaultValues: { label: initialLabel || '', colour: initialColour || '' },
defaultValues: { label: initialLabel || '', colour: initialColour || '', key: initialKey || '' },
resetOptions: {
keepDirtyValues: true,
},
@@ -66,28 +70,38 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
return (
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
<div className={style.column}>
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
<Input
{...register('label', {
required: { value: true, message: 'Required field' },
validate: (value) => {
if (value.trim().length === 0) return 'Required field';
if (!isAlphanumeric(value)) return 'Only alphanumeric characters are allowed';
return true;
},
})}
size='sm'
variant='ontime-filled'
autoComplete='off'
/>
<div className={style.twoCols}>
<div>
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
<Input
{...register('label', {
required: { value: true, message: 'Required field' },
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
validate: (value) => {
if (value.trim().length === 0) return 'Required field';
if (!isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
if (Object.keys(data).includes(value)) return 'Custom fields must be unique';
return true;
},
})}
size='sm'
variant='ontime-filled'
autoComplete='off'
/>
</div>
<div>
<Panel.Description>Key (auto-generated value for use in Integrations and API)</Panel.Description>
<Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' />
</div>
</div>
<div>
<Panel.Description>Colour</Panel.Description>
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
</div>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<div className={style.buttonRow}>
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
@@ -6,13 +6,12 @@ import { CustomField, CustomFieldLabel } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { customFieldsDocsUrl } from '../../../../../externals';
import * as Panel from '../../PanelUtils';
import CustomFieldEntry from './CustomFieldEntry';
import CustomFieldForm from './CustomFieldForm';
const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
export default function CustomFields() {
const { data, refetch } = useCustomFields();
const [isAdding, setIsAdding] = useState(false);
@@ -59,9 +58,14 @@ export default function CustomFields() {
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Custom fields allow for additional information to be added to an event (eg. light, sound, camera). <br />
Custom fields allow for additional information to be added to an event.
<br />
This data is not used by Ontime.
<br />
This data is not used by Ontime, but provides place for cueing or department specific information (eg.
light, sound, camera).
<br />
<br />
Custom fields can be used width the Integrations feature using the generated key.
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
@@ -73,6 +77,7 @@ export default function CustomFields() {
<tr>
<th>Colour</th>
<th>Name</th>
<th>Key (used in Integrations)</th>
<th />
</tr>
</thead>
@@ -148,6 +148,7 @@ export default function GeneralPanelForm() {
<option value='en'>English</option>
<option value='fr'>French</option>
<option value='de'>German</option>
<option value='hu'>Hungarian</option>
<option value='it'>Italian</option>
<option value='no'>Norwegian</option>
<option value='pt'>Portuguese</option>
@@ -84,8 +84,8 @@ export default function ViewSettingsForm() {
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
You can override the styles of the viewers with a custom CSS file. <br />
{info?.cssOverride && `In your installation the file is at ${info?.cssOverride}`}
You can the Ontime views or customise its styles by modifying the provided CSS file. <br />
The CSS file is in the user directory at {`${info.publicDir}/user/styles/override.css`}
<br />
<br />
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
@@ -147,7 +147,7 @@ export default function ViewSettingsForm() {
variant='ontime-filled'
maxLength={150}
width='275px'
placeholder='Message shown when timer reaches end'
placeholder='Shown when timer reaches end'
{...register('endMessage')}
/>
</Panel.ListItem>
@@ -60,7 +60,7 @@ export default function HttpIntegrations() {
id: generateId(),
cycle: 'onLoad',
message: '',
enabled: false,
enabled: true,
});
};
@@ -75,7 +75,7 @@ export default function OscIntegrations() {
cycle: 'onLoad',
address: '',
payload: '',
enabled: false,
enabled: true,
});
};
@@ -1,5 +1,5 @@
import { Select, Switch } from '@chakra-ui/react';
import { EndAction, TimerType } from 'ontime-types';
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
@@ -7,17 +7,28 @@ import { editorSettingsDefaults, useEditorSettings } from '../../../../common/st
import * as Panel from '../PanelUtils';
export default function EditorSettingsForm() {
const eventSettings = useEditorSettings((state) => state);
const {
defaultDuration,
linkPrevious,
defaultTimeStrategy,
defaultWarnTime,
defaultDangerTime,
defaultPublic,
defaultTimerType,
defaultEndAction,
setDefaultDuration,
setLinkPrevious,
setTimeStrategy,
setWarnTime,
setDangerTime,
setDefaultPublic,
setDefaultTimerType,
setDefaultEndAction,
} = useEditorSettings((state) => state);
const setDefaultDuration = eventSettings.setDefaultDuration;
const setLinkPrevious = eventSettings.setLinkPrevious;
const setWarnTime = eventSettings.setWarnTime;
const setDangerTime = eventSettings.setDangerTime;
const setDefaultPublic = eventSettings.setDefaultPublic;
const durationInMs = parseUserTime(eventSettings.defaultDuration);
const warnTimeInMs = parseUserTime(eventSettings.defaultWarnTime);
const dangerTimeInMs = parseUserTime(eventSettings.defaultDangerTime);
const durationInMs = parseUserTime(defaultDuration);
const warnTimeInMs = parseUserTime(defaultWarnTime);
const dangerTimeInMs = parseUserTime(defaultDangerTime);
return (
<Panel.Section>
@@ -26,6 +37,36 @@ export default function EditorSettingsForm() {
<Panel.Divider />
<Panel.Section>
<Panel.Title>Rundown defaults for new events</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Link previous'
description='Whether the start time of new events should be linked to the previous event end time'
/>
<Switch
variant='ontime'
size='lg'
defaultChecked={linkPrevious}
onChange={(event) => setLinkPrevious(event.target.checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Timer strategy'
description='Which time should be maintained when event schedule is recalculated'
/>
<Select
variant='ontime'
size='sm'
width='auto'
value={defaultTimeStrategy}
onChange={(event) => setTimeStrategy(event.target.value as TimeStrategy)}
>
<option value={TimeStrategy.LockDuration}>Duration</option>
<option value={TimeStrategy.LockEnd}>End Time</option>
</Select>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Default duration' description='Default duration for new events' />
@@ -36,21 +77,15 @@ export default function EditorSettingsForm() {
placeholder={editorSettingsDefaults.duration}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Link previous'
description='Whether the start time of new events should be linked to the previous event end time'
/>
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.linkPrevious}
onChange={(event) => setLinkPrevious(event.target.checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Timer type' description='Default type of timer for new events' />
<Select variant='ontime' size='sm' width='auto' isDisabled>
<Select
variant='ontime'
size='sm'
width='auto'
value={defaultTimerType}
onChange={(event) => setDefaultTimerType(event.target.value as TimerType)}
>
<option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.TimeToEnd}>Time to end</option>
@@ -59,7 +94,13 @@ export default function EditorSettingsForm() {
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='End Action' description='Default end action for new events' />
<Select variant='ontime' size='sm' width='auto' isDisabled>
<Select
variant='ontime'
size='sm'
width='auto'
value={defaultEndAction}
onChange={(event) => setDefaultEndAction(event.target.value as EndAction)}
>
<option value={EndAction.None}>None</option>
<option value={EndAction.Stop}>Stop</option>
<option value={EndAction.LoadNext}>Load next</option>
@@ -93,14 +134,14 @@ export default function EditorSettingsForm() {
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.defaultPublic}
defaultChecked={defaultPublic}
onChange={(event) => setDefaultPublic(event.target.checked)}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
<Panel.Section>
<Panel.Title>Play mode</Panel.Title>
<Panel.Title>Run mode</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
@@ -4,3 +4,9 @@
gap: $section-spacing;
row-gap: $element-inner-spacing;
}
.goIcon {
@include rotate-fourty-five;
margin-left: 0.25rem;
margin-bottom: 0.25rem;
}
@@ -1,19 +1,33 @@
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { serverPort } from '../../../../common/api/constants';
import AppLink from '../../../../common/components/app-link/AppLink';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import useInfo from '../../../../common/hooks-query/useInfo';
import { openLink } from '../../../../common/utils/linkUtils';
import style from './NetworkInterfaces.module.scss';
export default function InfoNif() {
const { data } = useInfo();
const handleClick = (address: string) => openLink(address);
return (
<div className={style.interfaces}>
{data?.networkInterfaces?.map((nif) => (
<AppLink key={nif.address} href={`http://${nif.address}:${serverPort}`}>
{`${nif.name} - ${nif.address}`}
</AppLink>
))}
{data?.networkInterfaces?.map((nif) => {
const address = `http://${nif.address}:${serverPort}`;
return (
<CopyTag
key={nif.name}
copyValue={address}
onClick={() => handleClick(address)}
label='Copy IP or navigate to address'
>
{`${nif.name} - ${nif.address}`} <IoArrowUp className={style.goIcon} />
</CopyTag>
);
})}
</div>
);
}
@@ -1,4 +1,5 @@
import { ChangeEvent, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Button, Input } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
@@ -13,14 +14,17 @@ import ProjectList from './ProjectList';
import style from './ProjectPanel.module.scss';
export default function ManageProjects() {
const [isCreatingProject, setIsCreatingProject] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const [error, setError] = useState('');
const [loading, setLoading] = useState<'import' | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const isCreatingProject = searchParams.get('create') === 'true';
const handleToggleCreate = () => {
setIsCreatingProject((prev) => !prev);
searchParams.set('create', isCreatingProject ? 'false' : 'true');
setSearchParams(searchParams);
};
const handleSelectFile = () => {
@@ -42,14 +46,15 @@ export default function ManageProjects() {
const errorMessage = maybeAxiosError(error);
setError(`Error uploading file: ${errorMessage}`);
} finally {
invalidateAllCaches();
await invalidateAllCaches();
}
setLoading(null);
};
const handleCloseForm = () => {
setIsCreatingProject(false);
searchParams.delete('create');
setSearchParams(searchParams);
};
return (
@@ -78,7 +78,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
</div>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<div className={style.innerColumn}>
<Panel.Section className={style.innerColumn}>
<label>
Project title
<Input
@@ -145,7 +145,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
{...register('backstageUrl')}
/>
</label>
</div>
</Panel.Section>
</Panel.Section>
);
}
@@ -9,7 +9,7 @@ export type ProjectFormValues = {
};
interface ProjectFormProps {
action: 'duplicate' | 'rename';
action: 'duplicate' | 'rename' | 'merge';
filename: string;
onCancel: () => void;
onSubmit: (values: ProjectFormValues) => Promise<void>;
@@ -11,13 +11,15 @@ import {
renameProject,
} from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../PanelUtils';
import ProjectForm, { ProjectFormValues } from './ProjectForm';
import ProjectMergeForm from './ProjectMergeForm';
import style from './ProjectPanel.module.scss';
export type EditMode = 'rename' | 'duplicate' | null;
export type EditMode = 'rename' | 'duplicate' | 'merge' | null;
interface ProjectListItemProps {
current?: boolean;
@@ -100,8 +102,10 @@ export default function ProjectListItem({
handleToggleEditMode(null, null);
};
const isCurrentlyBeingEdited = editingMode && filename === editingFilename;
const classes = current && !isCurrentlyBeingEdited ? style.current : undefined;
const isCurrentlyBeingEdited = filename === editingFilename;
const showProjectForm = (editingMode === 'rename' || editingMode === 'duplicate') && filename === editingFilename;
const showMergeForm = editingMode === 'merge' && isCurrentlyBeingEdited;
const classes = cx([current && !isCurrentlyBeingEdited && style.current, isCurrentlyBeingEdited && style.isEditing]);
return (
<>
@@ -113,7 +117,7 @@ export default function ProjectListItem({
</tr>
)}
<tr key={filename} className={classes}>
{isCurrentlyBeingEdited ? (
{showProjectForm ? (
<td colSpan={99}>
<ProjectForm
action={editingMode}
@@ -125,7 +129,7 @@ export default function ProjectListItem({
) : (
<>
<td className={style.containCell}>{filename}</td>
<td>{new Date(updatedAt).toLocaleString()}</td>
<td>{current ? 'Currently loaded' : new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}>
<ActionMenu
current={current}
@@ -133,12 +137,20 @@ export default function ProjectListItem({
onChangeEditMode={handleToggleEditMode}
onDelete={handleDelete}
onLoad={handleLoad}
isDisabled={loading}
isDisabled={loading || showMergeForm}
onMerge={(filename) => handleToggleEditMode('merge', filename)}
/>
</td>
</>
)}
</tr>
{showMergeForm && (
<tr>
<td colSpan={99}>
<ProjectMergeForm onClose={handleCancel} fileName={filename} />
</td>
</tr>
)}
</>
);
}
@@ -148,11 +160,12 @@ interface ActionMenuProps {
filename: string;
isDisabled: boolean;
onChangeEditMode: (editMode: EditMode, filename: string) => void;
onDelete: (filename: string) => void;
onLoad: (filename: string) => void;
onDelete: (filename: string) => Promise<void>;
onLoad: (filename: string) => Promise<void>;
onMerge: (filename: string) => void;
}
function ActionMenu(props: ActionMenuProps) {
const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad } = props;
const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad, onMerge } = props;
const handleRename = () => {
onChangeEditMode('rename', filename);
@@ -185,6 +198,9 @@ function ActionMenu(props: ActionMenuProps) {
<MenuItem onClick={() => onLoad(filename)} isDisabled={current}>
Load
</MenuItem>
<MenuItem onClick={() => onMerge(filename)} isDisabled={current}>
Partial Load
</MenuItem>
<MenuItem onClick={handleRename}>Rename</MenuItem>
<MenuItem onClick={handleDuplicate}>Duplicate</MenuItem>
<MenuItem onClick={handleDownload}>Download</MenuItem>
@@ -0,0 +1,128 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Switch } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_DATA } from '../../../../common/api/constants';
import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../PanelUtils';
import { makeProjectPatch } from './project.utils';
import style from './ProjectPanel.module.scss';
interface ProjectMergeFromProps {
onClose: () => void;
fileName: string;
}
type ProjectMergeFormValues = {
project: boolean;
rundown: boolean;
viewSettings: boolean;
urlPresets: boolean;
osc: boolean;
http: boolean;
};
export default function ProjectMergeForm(props: ProjectMergeFromProps) {
const { onClose, fileName } = props;
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
const {
handleSubmit,
register,
formState: { isSubmitting, isValid, isDirty },
} = useForm<ProjectMergeFormValues>({
defaultValues: {
project: false,
rundown: false,
viewSettings: false,
urlPresets: false,
osc: false,
http: false,
},
resetOptions: {
keepDirtyValues: true,
},
});
const handleSubmitCreate = async (values: ProjectMergeFormValues) => {
const allFalse = Object.values(values).every((value) => !value);
if (allFalse) {
setError('At least one option must be selected');
return;
}
try {
setError(null);
// make patch object
const { data } = await getDb(fileName);
const patch = await makeProjectPatch(data, values);
// request patch
await patchData(patch);
await queryClient.invalidateQueries({ queryKey: PROJECT_DATA });
onClose();
} catch (error) {
setError(maybeAxiosError(error));
}
};
return (
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
<Panel.Title>
Merge {`"${fileName}"`}
<div className={style.createActionButtons}>
<Button onClick={onClose} variant='ontime-ghosted' size='sm' isDisabled={isSubmitting}>
Cancel
</Button>
<Button
isDisabled={!isValid || !isDirty}
type='submit'
isLoading={isSubmitting}
variant='ontime-filled'
size='sm'
>
Merge
</Button>
</div>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
<Panel.Description>
Select partial data from {`"${fileName}"`} to merge into the current project.
<br /> This process is irreversible.
</Panel.Description>
<label>
<Switch variant='ontime' {...register('project')} />
Project data
</label>
<label>
<Switch variant='ontime' {...register('rundown')} />
Rundown + Custom Fields
</label>
<label>
<Switch variant='ontime' {...register('viewSettings')} />
View Settings
</label>
<label>
<Switch variant='ontime' {...register('urlPresets')} />
URL Presets
</label>
<label>
<Switch variant='ontime' {...register('osc')} />
OSC Integration
</label>
<label>
<Switch variant='ontime' {...register('http')} />
HTTP Integration
</label>
</Panel.Section>
</Panel.Section>
);
}
@@ -2,6 +2,10 @@
background-color: $blue-1100;
}
.isEditing {
color: $blue-500;
}
.actionButton {
flex: 1;
text-align: right;
@@ -50,3 +54,10 @@
flex-direction: column;
gap: 1em;
}
.inlineLabels {
label {
display: flex;
gap: 1rem;
}
}
@@ -0,0 +1,17 @@
import { DatabaseModel, isKeyOfType } from 'ontime-types';
export async function makeProjectPatch(data: DatabaseModel, mergeKeys: Record<string, boolean>) {
const patchObject: Partial<DatabaseModel> = {};
for (const key in mergeKeys) {
if (isKeyOfType(key, data) && mergeKeys[key]) {
// if the rundown is merged we also need the custom fields
if (key === 'rundown') {
patchObject.customFields = data['customFields'];
}
Object.assign(patchObject, { [key]: data[key] });
}
}
return patchObject;
}
@@ -10,6 +10,7 @@ import {
useDisclosure,
} from '@chakra-ui/react';
import { isLocalhost } from '../../../../common/api/constants';
import useElectronEvent from '../../../../common/hooks/useElectronEvent';
import * as Panel from '../PanelUtils';
@@ -31,9 +32,10 @@ export default function ShutdownPanel() {
This will shutdown the Ontime server. <br />
The runtime state will be lost, but your project is kept for next time.
</Panel.Paragraph>
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!isElectron}>
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!(isElectron || isLocalhost)}>
Shutdown ontime
</Button>
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
<AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
<AlertDialogOverlay>
<AlertDialogContent>
@@ -193,7 +193,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
<Panel.ListGroup>
<div className={style.buttonRow}>
{isAuthenticating && <Spinner />}
<CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
<CopyTag copyValue={authKey ?? ''} label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
{authKey ? authKey : 'Upload files to generate Auth Key'}
</CopyTag>
<Button
@@ -3,9 +3,8 @@ import { useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { ImportMap } from 'ontime-utils';
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils';
import { isAlphanumeric } from '../../../../../common/utils/regex';
import * as Panel from '../../PanelUtils';
import useGoogleSheet from '../useGoogleSheet';
import { useSheetStore } from '../useSheetStore';
@@ -78,7 +77,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
const isLoading = Boolean(loading);
const canSubmitSpreadsheet = isSpreadsheet && !isLoading;
const canSubmitGSheet = !isLoading;
const canSubmitGSheet = !isLoading && !stepData.worksheet.error;
const canSubmit = !hasErrors && isValid && (canSubmitSpreadsheet || canSubmitGSheet);
return (
@@ -190,9 +189,10 @@ export default function ImportMapForm(props: ImportMapFormProps) {
defaultValue={ontimeName}
placeholder='Name of the field as shown in Ontime'
{...register(`custom.${index}.ontimeName`, {
pattern: {
value: isAlphanumeric,
message: 'Custom field name must be alphanumeric',
validate: (value) => {
if (!isAlphanumericWithSpace(value))
return 'Only alphanumeric characters and space are allowed';
return true;
},
})}
/>
@@ -24,7 +24,8 @@ export default function PreviewRundown(props: PreviewRundownProps) {
// we only count Ontime Events which are 1 based in client
let eventIndex = 0;
const fieldHeaders = Object.keys(customFields);
const fieldKeys = Object.keys(customFields);
const fieldLabels = fieldKeys.map((key) => customFields[key].label);
return (
<Panel.Table>
@@ -44,8 +45,8 @@ export default function PreviewRundown(props: PreviewRundownProps) {
<th>Colour</th>
<th>Timer Type</th>
<th>End Action</th>
{fieldHeaders.map((field) => (
<th key={field}>{field}</th>
{fieldLabels.map((label) => (
<th key={label}>{label}</th>
))}
</tr>
</thead>
@@ -102,7 +103,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
<Tag>{event.endAction}</Tag>
</td>
{isOntimeEvent(event) &&
fieldHeaders.map((field) => {
fieldKeys.map((field) => {
let value = '';
if (field in event.custom) {
value = event.custom[field];
@@ -1,10 +1,9 @@
import { useEffect, useRef } from 'react';
import { IconButton, Input } from '@chakra-ui/react';
import { Input } from '@chakra-ui/react';
import { IoEye } from '@react-icons/all-files/io5/IoEye';
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './InputRow.module.scss';
@@ -13,15 +12,13 @@ interface InputRowProps {
label: string;
placeholder: string;
text: string;
visible?: boolean;
readonly?: boolean;
actionHandler: (action: string, payload: object) => void;
visible: boolean;
actionHandler: () => void;
changeHandler: (newValue: string) => void;
className?: string;
}
export default function InputRow(props: InputRowProps) {
const { label, placeholder, text, visible, actionHandler, changeHandler, className, readonly } = props;
const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
const inputRef = useRef<HTMLInputElement>(null);
const cursorPositionRef = useRef(0);
@@ -39,41 +36,27 @@ export default function InputRow(props: InputRowProps) {
changeHandler(event.target.value);
};
const classes = cx([style.inputRow, className]);
return (
<div className={classes}>
<div className={style.inputRow}>
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
<div className={style.inputItems}>
<Input
ref={inputRef}
size='sm'
variant='ontime-filled'
readOnly={readonly}
disabled={readonly}
value={text}
onChange={handleInputChange}
placeholder={placeholder}
/>
{readonly ? (
<IconButton
size='sm'
isDisabled
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
aria-label={`Toggle ${label}`}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
/>
) : (
<TooltipActionBtn
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label={`Toggle ${label}`}
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
)}
<TooltipActionBtn
clickHandler={actionHandler}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label={`Toggle ${label}`}
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
</div>
</div>
);
@@ -1,23 +1,3 @@
.messageContainer {
display: flex;
flex-direction: column;
gap: $section-spacing;
}
.buttonSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $element-spacing;
margin-top: -0.5rem;
}
.singleAction {
display: flex;
flex-direction: column;
gap: $element-spacing;
margin-top: $element-inner-spacing;
}
.label {
font-size: $inner-section-text-size;
color: $label-gray;
@@ -26,3 +6,89 @@
color: $action-text-color;
}
}
.previewContainer {
display: grid;
gap: $element-spacing;
grid-template-columns: 2fr 1fr;
}
.preview {
background-color: $ui-black;
display: grid;
place-content: center;
text-align: center;
position: relative;
}
.corner {
@include rotate-fourty-five;
position: absolute;
top: 0.5rem;
right: 0.5rem;
cursor: pointer;
color: $ui-white;
transition-property: color;
transition-duration: $transition-time-action;
&:hover {
color: $ontime-color;
}
}
.options {
display: flex;
flex-direction: column;
gap: $element-spacing;
}
.eventStatus {
position: absolute;
left: 0;
margin: 0.5rem 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.mainContent {
font-size: 1rem;
font-weight: 600;
color: var(--override-colour, $ui-white);
&[data-phase='pending'] {
color: $ontime-roll;
}
&[data-phase='overtime'] {
color: $playback-negative;
}
&[data-phase='none'] {
opacity: $opacity-disabled;
}
}
.secondaryContent {
border-top: 1px solid $white-7;
}
.blackout {
display: none;
}
.timerIndicators {
display: flex;
flex-direction: column;
}
.statusIcon {
color: $gray-1000;
&[data-active='true'] {
color: $active-indicator;
}
}
.divider {
border-top: 1px solid $gray-1000;
}
@@ -1,64 +1,52 @@
import { Button } from '@chakra-ui/react';
import { IoEye } from '@react-icons/all-files/io5/IoEye';
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
import { IoSunny } from '@react-icons/all-files/io5/IoSunny';
import { IoSunnyOutline } from '@react-icons/all-files/io5/IoSunnyOutline';
import { setMessage, useMessageControl } from '../../../common/hooks/useSocket';
import { enDash } from '../../../common/utils/styleUtils';
import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
import InputRow from './InputRow';
import style from './MessageControl.module.scss';
const noop = () => undefined;
import TimerControlsPreview from './TimerViewControl';
export default function MessageControl() {
const message = useMessageControl();
const blink = message.timer.blink;
const blackout = message.timer.blackout;
return (
<div className={style.messageContainer}>
<InputRow
label='Timer'
placeholder='Message shown in stage timer'
text={message.timer.text}
visible={message.timer.visible}
changeHandler={(newValue) => setMessage.timerText(newValue)}
actionHandler={() => setMessage.timerVisible(!message.timer.visible)}
/>
<div className={style.buttonSection}>
<Button
size='sm'
className={`${blink ? style.blink : ''}`}
variant={blink ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={blink ? <IoSunny size='1rem' /> : <IoSunnyOutline size='1rem' />}
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink
</Button>
<Button
size='sm'
className={style.blackoutButton}
variant={blackout ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={blackout ? <IoEye size='1rem' /> : <IoEyeOffOutline size='1rem' />}
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout'
>
Blackout screen
</Button>
</div>
<InputRow
label='External Message (read only)'
placeholder={enDash}
readonly
text={message.external.text}
visible={message.external.visible}
changeHandler={noop}
actionHandler={noop}
/>
</div>
<>
<TimerControlsPreview />
<TimerMessageInput />
<ExternalInput />
</>
);
}
function TimerMessageInput() {
const { text, visible } = useTimerMessageInput();
return (
<InputRow
label='Timer Message'
placeholder='Message shown fullscreen in stage timer'
text={text}
visible={visible}
changeHandler={(newValue) => setMessage.timerText(newValue)}
actionHandler={() => setMessage.timerVisible(!visible)}
/>
);
}
function ExternalInput() {
const { text, visible } = useExternalMessageInput();
const toggleExternal = () => {
if (visible) {
setMessage.timerSecondary(null);
} else {
setMessage.timerSecondary('external');
}
};
return (
<InputRow
label='External Message'
placeholder='Message shown as secondary text in stage timer'
text={text}
visible={visible}
changeHandler={(newValue) => setMessage.externalText(newValue)}
actionHandler={toggleExternal}
/>
);
}
@@ -3,16 +3,19 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils';
import MessageControl from './MessageControl';
import style from '../../editors/Editor.module.scss';
const MessageControlExport = () => {
const classes = cx([style.content, style.contentColumnLayout]);
return (
<div className={style.messages} data-testid='panel-messages-control'>
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'messagecontrol')} />
<div className={style.content}>
<div className={classes}>
<ErrorBoundary>
<MessageControl />
</ErrorBoundary>
@@ -0,0 +1,78 @@
import { Tooltip } from '@chakra-ui/react';
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoFlag } from '@react-icons/all-files/io5/IoFlag';
import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { TimerPhase, TimerType } from 'ontime-types';
import { useMessagePreview } from '../../../common/hooks/useSocket';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './MessageControl.module.scss';
export default function TimerPreview() {
const { blink, blackout, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } =
useMessagePreview();
const { data } = useViewSettings();
const contentClasses = cx([style.previewContent, blink && style.blink, blackout && style.blackout]);
const main = (() => {
if (showTimerMessage) return 'Message';
if (phase === TimerPhase.Pending) return 'Standby to start';
if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message';
return 'Timer';
})();
const secondary = (() => {
// message is a fullscreen overlay
if (showTimerMessage) return null;
// we need to check aux first since it takes priority
if (showAuxTimer) return 'Aux Timer';
if (showExternalMessage) return 'External message';
return null;
})();
const overrideColour = (() => {
// override fallback colours from starter project
if (phase === TimerPhase.Warning) return data.warningColor ?? '#FFAB33';
if (phase === TimerPhase.Danger) return data.dangerColor ?? '#ED3333';
return data.normalColor ?? '#FFFC';
})();
const showColourOverride = main == 'Timer';
return (
<div className={style.preview}>
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'timer')} />
<div className={contentClasses}>
<div
className={style.mainContent}
data-phase={showColourOverride && phase}
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
>
{main}
</div>
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
</div>
<div className={style.eventStatus}>
<Tooltip label='Time type: Count down' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoArrowDown className={style.statusIcon} data-active={timerType === TimerType.CountDown} />
</Tooltip>
<Tooltip label='Time type: Count up' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoArrowUp className={style.statusIcon} data-active={timerType === TimerType.CountUp} />
</Tooltip>
<Tooltip label='Time type: Clock' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoTime className={style.statusIcon} data-active={timerType === TimerType.Clock} />
</Tooltip>
<Tooltip label='Time type: Time to end' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoFlag className={style.statusIcon} data-active={timerType === TimerType.TimeToEnd} />
</Tooltip>
</div>
</div>
);
}
@@ -0,0 +1,62 @@
import { Button } from '@chakra-ui/react';
import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket';
import TimerPreview from './TimerPreview';
import style from './MessageControl.module.scss';
export default function TimerControlsPreview() {
const { blackout, blink, secondarySource } = useTimerViewControl();
const toggleSecondary = (newValue: 'aux' | 'external' | null) => {
if (secondarySource === newValue) {
setMessage.timerSecondary(null);
} else {
setMessage.timerSecondary(newValue);
}
};
return (
<div className={style.previewContainer}>
<TimerPreview />
<div className={style.options}>
<Button
size='sm'
variant={secondarySource === 'aux' ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => toggleSecondary('aux')}
>
Show Aux timer
</Button>
<Button
size='sm'
variant={secondarySource === 'external' ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => toggleSecondary('external')}
>
Show external
</Button>
<hr className={style.divider} />
<Button
size='sm'
variant={blink ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink
</Button>
<Button
size='sm'
className={style.blackoutButton}
variant={blackout ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout'
>
Blackout screen
</Button>
</div>
</div>
);
}
@@ -21,6 +21,7 @@ export default function PlaybackControl() {
playback={data.playback}
numEvents={data.numEvents}
selectedEventIndex={data.selectedEventIndex}
timerPhase={data.timerPhase}
/>
<AuxTimer />
</div>
@@ -68,7 +68,7 @@ function AuxTimerInput() {
const handleTimeUpdate = (_field: string, value: string) => {
const newTime = parseUserTime(value);
setDuration(newTime / 1000); //frontend api is seconds based;
setDuration(newTime / 1000); // frontend api is seconds based
};
return (
@@ -6,7 +6,7 @@ import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward'
import { IoReload } from '@react-icons/all-files/io5/IoReload';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { Playback } from 'ontime-types';
import { Playback, TimerPhase } from 'ontime-types';
import { validatePlayback } from 'ontime-utils';
import { setPlayback } from '../../../../common/hooks/useSocket';
@@ -19,10 +19,11 @@ interface PlaybackButtonsProps {
playback: Playback;
numEvents: number;
selectedEventIndex: number | null;
timerPhase: TimerPhase;
}
export default function PlaybackButtons(props: PlaybackButtonsProps) {
const { playback, numEvents, selectedEventIndex } = props;
const { playback, numEvents, selectedEventIndex, timerPhase } = props;
const isRolling = playback === Playback.Roll;
const isPlaying = playback === Playback.Play;
@@ -37,7 +38,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
const disableNext = isRolling || noEvents || isLast;
const disablePrev = isRolling || noEvents || isFirst;
const playbackCan = validatePlayback(playback);
const playbackCan = validatePlayback(playback, timerPhase);
const disableStart = !playbackCan.start;
const disablePause = !playbackCan.pause;
const disableRoll = !playbackCan.roll || noEvents;
@@ -50,9 +50,15 @@ $table-header-font-size: calc(1rem - 3px);
font-size: $table-header-font-size;
color: $gray-700;
}
th {
background-color: $gray-1300;
th {
background-color: $gray-1300;
&:hover {
.resizer {
width: 0.5rem;
}
}
}
@@ -126,30 +132,19 @@ $table-header-font-size: calc(1rem - 3px);
.resizer {
cursor: col-resize;
opacity: 0;
opacity: $opacity-disabled;
display: inline-block;
width: 3px;
width: 0;
height: 100%;
position: absolute;
right: 0;
top: 0;
transform: translateX(50%);
background-color: $action-blue;
user-select: none;
touch-action: none;
transition-duration: $transition-time-action;
transition-property: width, background-color;
&:hover {
opacity: $opacity-disabled;
width: 6px;
}
&.isResizing {
opacity: 1;
width: 6px;
background-color: $action-blue;
}
}
@@ -6,18 +6,17 @@ import styles from './CuesheetProgress.module.scss';
export default function CuesheetProgress() {
const { data } = useViewSettings();
const { addedTime, current, duration, timeWarning, timeDanger } = useProgressData();
const totalTime = (duration ?? 0) + (addedTime ?? 0);
const { current, duration, timeWarning, timeDanger } = useProgressData();
return (
<MultiPartProgressBar
now={current}
complete={totalTime}
normalColor={data!.normalColor}
complete={duration}
normalColor={data.normalColor}
warning={timeWarning}
warningColor={data!.warningColor}
warningColor={data.warningColor}
danger={timeDanger}
dangerColor={data!.dangerColor}
dangerColor={data.dangerColor}
className={styles.progressOverride}
ignoreCssOverride
/>
@@ -4,8 +4,6 @@ import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils';
import styles from '../Cuesheet.module.scss';
interface SortableCellProps {
@@ -29,8 +27,6 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
transition,
};
const resizerClasses = cx([styles.resizer, header.column.getIsResizing() ? styles.isResizing : null]);
return (
<th ref={setNodeRef} style={dragStyle} colSpan={colSpan}>
<div {...attributes} {...listeners}>
@@ -41,7 +37,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(),
}}
className={resizerClasses}
className={styles.resizer}
/>
</th>
);
@@ -29,10 +29,12 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
showPrevious,
togglePreviousVisibility,
showDelayBlock,
hideSeconds,
showDelayedTimes,
toggleIndexColumn,
toggleDelayedTimes,
toggleDelayVisibility,
toggleSecondsVisibility,
} = useCuesheetSettings();
return (
@@ -80,6 +82,10 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
<Switch variant='ontime' size='sm' isChecked={showDelayBlock} onChange={() => toggleDelayVisibility()} />
Show delay blocks
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={hideSeconds} onChange={() => toggleSecondsVisibility()} />
Hide seconds
</label>
</div>
</div>
<div className={style.rightPanel}>
@@ -2,7 +2,6 @@ import { useCallback } from 'react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
import RunningTime from '../viewers/common/running-time/RunningTime';
@@ -19,20 +18,28 @@ function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
const cellValue = (getValue() as number | null) ?? 0;
const delayValue = (original as OntimeEvent)?.delay ?? 0;
return (
<span className={style.time}>
<DelayIndicator delayValue={delayValue} />
<RunningTime value={cellValue} />
<RunningTime value={cellValue} hideSeconds={hideSeconds} />
{delayValue !== 0 && showDelayedTimes && (
<RunningTime className={style.delayedTime} value={cellValue + delayValue} />
<RunningTime className={style.delayedTime} value={cellValue + delayValue} hideSeconds={hideSeconds} />
)}
</span>
);
}
function MakeDuration({ getValue }: CellContext<OntimeRundownEntry, unknown>) {
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
const cellValue = (getValue() as number | null) ?? 0;
return <RunningTime value={cellValue} hideSeconds={hideSeconds} />;
}
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
const update = useCallback(
(newValue: string) => {
@@ -97,7 +104,7 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
accessorKey: 'duration',
id: 'duration',
header: 'Duration',
cell: (row) => millisToString(row.getValue() as number | null),
cell: MakeDuration,
size: 75,
},
{
@@ -9,6 +9,7 @@ interface CuesheetSettings {
showPrevious: boolean;
showDelayBlock: boolean;
showDelayedTimes: boolean;
hideSeconds: boolean;
toggleSettings: (newValue?: boolean) => void;
toggleFollow: (newValue?: boolean) => void;
@@ -16,6 +17,7 @@ interface CuesheetSettings {
toggleIndexColumn: (newValue?: boolean) => void;
toggleDelayVisibility: (newValue?: boolean) => void;
toggleDelayedTimes: (newValue?: boolean) => void;
toggleSecondsVisibility: (newValue?: boolean) => void;
}
function toggle(oldValue: boolean, value?: boolean) {
@@ -31,6 +33,7 @@ enum CuesheetKeys {
PreviousVisibility = 'ontime-cuesheet-show-previous',
ColumnIndex = 'ontime-cuesheet-show-index-column',
DelayedTimes = 'ontime-cuesheet-show-delayed',
Seconds = 'ontime-cuesheet-hide-sceconds',
}
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
@@ -40,6 +43,7 @@ export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
showDelayedTimes: booleanFromLocalStorage(CuesheetKeys.DelayedTimes, false),
hideSeconds: booleanFromLocalStorage(CuesheetKeys.Seconds, false),
toggleSettings: (newValue?: boolean) => set((state) => ({ showSettings: toggle(state.showSettings, newValue) })),
toggleFollow: (newValue?: boolean) =>
@@ -72,4 +76,10 @@ export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
localStorage.setItem(CuesheetKeys.DelayedTimes, String(showDelayedTimes));
return { showDelayedTimes };
}),
toggleSecondsVisibility: (newValue?: boolean) =>
set((state) => {
const hideSeconds = toggle(state.hideSeconds, newValue);
localStorage.setItem(CuesheetKeys.Seconds, String(hideSeconds));
return { hideSeconds };
}),
}));
@@ -40,7 +40,7 @@ $panel-gap: 0.5rem;
.playback,
.messages {
position: relative;
border-radius: 8px;
border-radius: var(--editor--panel__br);
background-color: $bg-container-l2;
padding: 1rem;
}
@@ -62,3 +62,10 @@ $panel-gap: 0.5rem;
.content {
padding-top: 1.5rem;
}
.contentColumnLayout {
display: flex;
flex-direction: column;
gap: $section-spacing;
color: $ui-white;
}
@@ -1,10 +1,18 @@
@use '../../theme/ontimeColours' as *;
@use '../../theme/ontimeStyles' as *;
@mixin absolute-top-right($distance) {
// declare editor specific styling constants
:root {
--editor--panel__br: 8px;
}
@mixin corner() {
display: none;
transform: rotate(45deg);
position: absolute;
top: $distance;
right: $distance;
top: 0.5rem;
right: 0.5rem;
cursor: pointer;
color: $ui-white;
transition-property: color;
@@ -15,17 +23,11 @@
}
}
@mixin corner() {
display: none;
@include absolute-top-right(0.5rem);
transform: rotate(45deg);
}
@mixin panel() {
display: flex;
position: relative;
border-radius: 8px;
border-radius: var(--editor--panel__br);
height: 100%;
background-color: $bg-container-l2;
padding: 1rem;
@@ -11,13 +11,12 @@ interface StatusBarProgressProps {
export default function StatusBarProgress(props: StatusBarProgressProps) {
const { viewSettings } = props;
const { addedTime, current, duration, timeWarning, timeDanger } = useProgressData();
const totalTime = (duration ?? 0) + (addedTime ?? 0);
const { current, duration, timeWarning, timeDanger } = useProgressData();
return (
<MultiPartProgressBar
now={current}
complete={totalTime}
complete={duration}
normalColor={viewSettings.normalColor}
warning={timeWarning}
warningColor={viewSettings.warningColor}
+78 -26
View File
@@ -2,8 +2,24 @@ import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react'
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useHotkeys } from '@mantine/hooks';
import { isOntimeEvent, MaybeNumber, Playback, RundownCached, SupportedEvent } from 'ontime-types';
import { getFirstNormal, getLastNormal, getNextNormal, getPreviousNormal } from 'ontime-utils';
import {
isOntimeBlock,
isOntimeEvent,
isPlayableEvent,
PlayableEvent,
Playback,
RundownCached,
SupportedEvent,
} from 'ontime-types';
import {
getFirstNormal,
getLastNormal,
getNextBlockNormal,
getNextNormal,
getPreviousBlockNormal,
getPreviousNormal,
isNewLatest,
} from 'ontime-utils';
import { useEventAction } from '../../common/hooks/useEventAction';
import useFollowComponent from '../../common/hooks/useFollowComponent';
@@ -98,28 +114,61 @@ export default function Rundown({ data }: RundownProps) {
[rundown, order, addEvent],
);
const selectBlock = useCallback(
(cursor: string | null, direction: 'up' | 'down') => {
if (order.length < 1) {
return;
}
let newCursor = cursor;
if (cursor === null) {
// there is no cursor, we select the first or last depending on direction
const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order);
if (isOntimeBlock(selected)) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
return;
}
newCursor = selected?.id ?? null;
}
if (newCursor === null) {
return;
}
// otherwise we select the next or previous
const selected =
direction === 'up'
? getPreviousBlockNormal(rundown, order, newCursor)
: getNextBlockNormal(rundown, order, newCursor);
if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
}
},
[order, rundown, setSelectedEvents],
);
const selectEntry = useCallback(
(cursor: string | null, direction: 'up' | 'down') => {
if (order.length < 1) {
return;
}
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) ?? null;
newIndex = direction === 'up' ? order.length : 0;
} else {
// otherwise we select the next or previous
const selected =
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
newCursor = selected.entry?.id ?? null;
newIndex = selected.index;
const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order);
if (selected !== null) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
}
return;
}
if (newCursor && newIndex !== null) {
setSelectedEvents({ id: newCursor, selectMode: 'click', index: newIndex });
// otherwise we select the next or previous
const selected =
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
}
},
[order, rundown, setSelectedEvents],
@@ -145,6 +194,10 @@ export default function Rundown({ data }: RundownProps) {
useHotkeys([
['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true }],
['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true }],
['alt + shift + ArrowDown', () => selectBlock(cursor, 'down'), { preventDefault: true }],
['alt + shift + ArrowUp', () => selectBlock(cursor, 'up'), { preventDefault: true }],
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }],
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }],
@@ -203,11 +256,9 @@ export default function Rundown({ data }: RundownProps) {
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
}
let previousStart: MaybeNumber = null;
let previousEnd: MaybeNumber = null;
let lastEntry: PlayableEvent | undefined; // used by indicators
let thisEntry: PlayableEvent | undefined;
let previousEventId: string | undefined;
let thisStart: MaybeNumber = null;
let thisEnd: MaybeNumber = null;
let thisId = previousEventId;
let eventIndex = 0;
@@ -235,13 +286,14 @@ export default function Rundown({ data }: RundownProps) {
if (isOntimeEvent(event)) {
// event indexes are 1 based in frontend
eventIndex++;
previousStart = thisStart;
previousEnd = thisEnd;
previousEventId = thisId;
lastEntry = thisEntry;
if (!event.skip) {
thisStart = event.timeStart;
thisEnd = event.timeEnd;
if (isPlayableEvent(event)) {
// populate previous entry
if (isNewLatest(event.timeStart, event.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
thisEntry = event;
}
thisId = eventId;
}
}
@@ -268,8 +320,8 @@ export default function Rundown({ data }: RundownProps) {
loaded={isLoaded}
hasCursor={hasCursor}
isNext={isNext}
previousStart={previousStart}
previousEnd={previousEnd}
previousStart={lastEntry?.timeStart}
previousEnd={lastEntry?.timeEnd}
previousEventId={previousEventId}
playback={isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { MaybeNumber, OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../common/hooks/useEventAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
@@ -32,8 +32,8 @@ interface RundownEntryProps {
eventIndex: number;
hasCursor: boolean;
isNext: boolean;
previousStart: MaybeNumber;
previousEnd: MaybeNumber;
previousStart?: number;
previousEnd?: number;
previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing
isRolling: boolean; // we need to know even if not related to this event
@@ -8,7 +8,7 @@ import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { EndAction, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
@@ -36,8 +36,8 @@ interface EventBlockProps {
title: string;
note: string;
delay: number;
previousStart: MaybeNumber;
previousEnd: MaybeNumber;
previousStart?: number;
previousEnd?: number;
colour: string;
isPast: boolean;
isNext: boolean;
@@ -1,5 +1,13 @@
import { MaybeNumber } from 'ontime-types';
import { checkIsNextDay, dayInMs, millisToString, removeLeadingZero, removeTrailingZero } from 'ontime-utils';
import {
calculateDuration,
checkIsNextDay,
dayInMs,
getTimeFromPrevious,
millisToString,
removeTrailingZero,
} from 'ontime-utils';
import { formatDuration } from '../../../common/utils/time';
export function formatDelay(timeStart: number, delay: number): string | undefined {
if (!delay) return;
@@ -10,31 +18,24 @@ export function formatDelay(timeStart: number, delay: number): string | undefine
return `New start ${timeTag}`;
}
export function formatOverlap(
previousStart: MaybeNumber,
previousEnd: MaybeNumber,
timeStart: number,
): string | undefined {
const noPreviousElement = previousEnd === null || previousStart === null;
export function formatOverlap(timeStart: number, previousStart?: number, previousEnd?: number): string | undefined {
const noPreviousElement = previousEnd === undefined || previousStart === undefined;
if (noPreviousElement) return;
const overlap = previousEnd - timeStart;
if (overlap === 0) return;
const normalisedDuration = calculateDuration(previousStart, previousEnd);
const timeFromPrevious = getTimeFromPrevious(timeStart, previousStart, previousEnd, normalisedDuration);
if (timeFromPrevious === 0) return;
const previousCrossMidnight = previousStart > previousEnd;
const isNextDay = previousCrossMidnight
? checkIsNextDay(previousEnd, timeStart) || previousEnd == 0 // exception for when previousEnd is precisely midnight
: checkIsNextDay(previousStart, timeStart);
if (checkIsNextDay(previousStart, timeStart, normalisedDuration)) {
const previousCrossMidnight = previousStart > previousEnd;
const normalisedPreviousEnd = previousCrossMidnight ? previousEnd + dayInMs : previousEnd;
const correctedPreviousEnd = previousCrossMidnight ? previousEnd + dayInMs : previousEnd;
if (isNextDay) {
const gap = dayInMs - correctedPreviousEnd + timeStart;
const gap = dayInMs - normalisedPreviousEnd + timeStart;
if (gap === 0) return;
const gapString = removeLeadingZero(millisToString(Math.abs(gap)));
const gapString = formatDuration(Math.abs(gap), false);
return `Gap ${gapString} (next day)`;
}
const overlapString = removeLeadingZero(millisToString(Math.abs(overlap)));
return `${overlap > 0 ? 'Overlap' : 'Gap'} ${overlapString}`;
const overlapString = formatDuration(Math.abs(timeFromPrevious), false);
return `${timeFromPrevious < 0 ? 'Overlap' : 'Gap'} ${overlapString}`;
}
@@ -21,10 +21,6 @@ import EventBlockProgressBar from './composite/EventBlockProgressBar';
import style from './EventBlock.module.scss';
const tooltipProps = {
openDelay: tooltipDelayMid,
};
interface EventBlockInnerProps {
timeStart: number;
timeEnd: number;
@@ -98,11 +94,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
</div>
<div className={style.titleSection}>
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
{isNext && (
<Tooltip label='Next event' {...tooltipProps}>
<span className={style.nextTag}>UP NEXT</span>
</Tooltip>
)}
{isNext && <span className={style.nextTag}>UP NEXT</span>}
</div>
<EventBlockPlayback
eventId={eventId}
@@ -118,17 +110,17 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
{loaded && <EventBlockProgressBar />}
</div>
<div className={style.eventStatus} tabIndex={-1}>
<Tooltip label={`Time type: ${timerType}`} {...tooltipProps}>
<Tooltip label={`Time type: ${timerType}`} openDelay={tooltipDelayMid}>
<span>
<TimerIcon type={timerType} className={style.statusIcon} />
</span>
</Tooltip>
<Tooltip label={`End action: ${endAction}`} {...tooltipProps}>
<Tooltip label={`End action: ${endAction}`} openDelay={tooltipDelayMid}>
<span>
<EndActionIcon action={endAction} className={style.statusIcon} />
</span>
</Tooltip>
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} {...tooltipProps}>
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} openDelay={tooltipDelayMid}>
<span>
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
</span>
@@ -1,20 +1,18 @@
import { MaybeNumber } from 'ontime-types';
import { formatDelay, formatOverlap } from './EventBlock.utils';
import style from './RundownIndicators.module.scss';
interface RundownIndicatorProps {
timeStart: number;
previousStart: MaybeNumber;
previousEnd: MaybeNumber;
previousStart?: number;
previousEnd?: number;
delay: number;
}
export default function RundownIndicators(props: RundownIndicatorProps) {
const { timeStart, previousStart, previousEnd, delay } = props;
const hasOverlap = formatOverlap(previousStart, previousEnd, timeStart);
const hasOverlap = formatOverlap(timeStart, previousStart, previousEnd);
const hasDelay = formatDelay(timeStart, delay);
return (
@@ -16,47 +16,63 @@ describe('formatOverlap()', () => {
const previousStart = 0;
const previousEnd = 60000; // 1 min
const timeStart = 30000; // 30 sec
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toEqual('Overlap 0:30');
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toEqual('Overlap 30s');
});
it('bug #949 recognises an overlap between two times', () => {
const previousStart = 46800000; // 13:00:00
const previousEnd = 48600000; // 13:30:00
const timeStart = 48300000; // 13:25:00
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toEqual('Overlap 5:00');
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toEqual('Overlap 5m');
});
it('handles events the day after, without overlap', () => {
const previousStart = 11 * MILLIS_PER_HOUR;
const previousEnd = 12 * MILLIS_PER_HOUR;
const timeStart = 6 * MILLIS_PER_HOUR;
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toBe('Gap 18:00:00 (next day)');
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 18h (next day)');
});
it('handles events the day after, with gap', () => {
const previousStart = 17 * MILLIS_PER_HOUR;
const previousEnd = 23 * MILLIS_PER_HOUR;
const timeStart = 9 * MILLIS_PER_HOUR;
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toBe('Gap 10:00:00 (next day)');
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 10h (next day)');
});
it('handles events the day after, with previous ending at midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR; // 23:00:00
const previousEnd = 0; // 00:00:00
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00:00
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toBe('Gap 01:00:00 (next day)');
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 1h (next day)');
});
it('handles events the day after, with previous ending over midnight', () => {
it('handles sequential events the day after, with previous ending over midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 1 * MILLIS_PER_HOUR;
const timeStart = 1 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBeUndefined();
});
it('handles events the day after, with previous ending over midnight with overlap', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 2 * MILLIS_PER_HOUR;
const timeStart = 1 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Overlap 1h');
});
it('handles events the day after, with previous ending over midnight with gap', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 1 * MILLIS_PER_HOUR;
const timeStart = 2 * MILLIS_PER_HOUR;
const result = formatOverlap(previousStart, previousEnd, timeStart);
expect(result).toBe('Gap 01:00:00');
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 1h');
});
});
@@ -1,29 +1,12 @@
import { MaybeNumber } from 'ontime-types';
import { useTimer } from '../../../../common/hooks/useSocket';
import { clamp } from '../../../../common/utils/math';
import { getProgress } from '../../../../common/utils/getProgress';
import style from './EventBlockProgressBar.module.scss';
export function getPercentComplete(remaining: MaybeNumber, total: MaybeNumber): number {
if (remaining === null || total === null) {
return 0;
}
if (remaining <= 0) {
return 100;
}
if (remaining === total) {
return 0;
}
return clamp(100 - (remaining * 100) / total, 0, 100);
}
export default function EventBlockProgressBar() {
const timer = useTimer();
const progress = `${getPercentComplete(timer.current, timer.duration)}%`;
return <div className={style.progressBar} style={{ width: progress }} />;
const progress = getProgress(timer.current, timer.duration);
return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
}
@@ -1,27 +0,0 @@
import { dayInMs } from 'ontime-utils';
import { getPercentComplete } from '../EventBlockProgressBar';
describe('getPercentComplete()', () => {
describe('calculates progress in normal cases', () => {
const testScenarios = [
{ current: 0, duration: 0, expect: 100 },
{ current: 0, duration: 100, expect: 100 },
{ current: 0, duration: dayInMs, expect: 100 },
{ current: 10, duration: 100, expect: 90 },
{ current: 50, duration: 100, expect: 50 },
{ current: 100, duration: 100, expect: 0 },
];
testScenarios.forEach((testCase) => {
it(`handles ${testCase.current} / ${testCase.duration}`, () => {
const progress = getPercentComplete(testCase.current, testCase.duration);
expect(progress).toBe(testCase.expect);
});
});
});
it('is 0 if we dont have a current or duration', () => {
const progress = getPercentComplete(null, null);
expect(progress).toBe(0);
});
});
@@ -1,4 +1,4 @@
import { CSSProperties, useCallback, useEffect, useState } from 'react';
import { CSSProperties, memo, useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Button } from '@chakra-ui/react';
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types';
@@ -126,10 +126,32 @@ export default function EventEditor() {
})}
</div>
</div>
<div className={style.footer}>
<CopyTag label='OSC trigger by id'>{`/ontime/load/id "${event.id}"`}</CopyTag>
<CopyTag label='OSC trigger by cue'>{`/ontime/load/cue "${event.cue}"`}</CopyTag>
</div>
<EventEditorFooter id={event.id} cue={event.cue} />
</div>
);
}
interface EventEditorFooterProps {
id: string;
cue: string;
}
const EventEditorFooter = memo(_EventEditorFooter);
function _EventEditorFooter(props: EventEditorFooterProps) {
const { id, cue } = props;
const loadById = `/ontime/load/id "${id}"`;
const loadByCue = `/ontime/load/cue "${cue}"`;
return (
<div className={style.footer}>
<CopyTag copyValue={loadById} label='OSC trigger by ID'>
{loadById}
</CopyTag>
<CopyTag copyValue={loadByCue} label='OSC trigger by cue'>
{loadByCue}
</CopyTag>
</div>
);
}
@@ -28,6 +28,7 @@
}
td:nth-child(even) {
text-align: right;
white-space: nowrap;
}
}
}
@@ -24,6 +24,18 @@ function EventEditorEmpty() {
<Kbd></Kbd>
</td>
</tr>
<tr>
<td>Select block</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>/</AuxKey>
<Kbd></Kbd>
</td>
</tr>
<tr>
<td>Deselect entry</td>
<td>
@@ -10,7 +10,7 @@ import { MaybeString, OntimeEvent, TimeStrategy } from 'ontime-types';
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { tooltipDelayFast, tooltipDelayMid } from '../../../ontimeConfig';
import style from './TimeInputFlow.module.scss';
@@ -66,10 +66,12 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
placeholder='Start'
disabled={Boolean(linkStart)}
>
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
<span className={style.timeLabel}>S</span>
<span className={style.fourtyfive}>{linkStart ? <IoLink /> : <IoUnlink />}</span>
</InputRightElement>
<Tooltip label='Link start to previous end' openDelay={tooltipDelayMid}>
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
<span className={style.timeLabel}>S</span>
<span className={style.fourtyfive}>{linkStart ? <IoLink /> : <IoUnlink />}</span>
</InputRightElement>
</Tooltip>
</TimeInputWithButton>
<TimeInputWithButton<TimeActions>
@@ -80,14 +82,16 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
disabled={isLockedDuration}
placeholder='End'
>
<InputRightElement
className={activeEnd}
onClick={() => handleChangeStrategy(TimeStrategy.LockEnd)}
data-testid='lock__end'
>
<span className={style.timeLabel}>E</span>
{isLockedEnd ? <IoLockClosed /> : <IoLockOpenOutline />}
</InputRightElement>
<Tooltip label='Lock end' openDelay={tooltipDelayMid}>
<InputRightElement
className={activeEnd}
onClick={() => handleChangeStrategy(TimeStrategy.LockEnd)}
data-testid='lock__end'
>
<span className={style.timeLabel}>E</span>
{isLockedEnd ? <IoLockClosed /> : <IoLockOpenOutline />}
</InputRightElement>
</Tooltip>
</TimeInputWithButton>
<TimeInputWithButton<TimeActions>
@@ -97,14 +101,16 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
disabled={isLockedEnd}
placeholder='Duration'
>
<InputRightElement
className={activeDuration}
onClick={() => handleChangeStrategy(TimeStrategy.LockDuration)}
data-testid='lock__duration'
>
<span className={style.timeLabel}>D</span>
{isLockedDuration ? <IoLockClosed /> : <IoLockOpenOutline />}
</InputRightElement>
<Tooltip label='Lock duration' openDelay={tooltipDelayMid}>
<InputRightElement
className={activeDuration}
onClick={() => handleChangeStrategy(TimeStrategy.LockDuration)}
data-testid='lock__duration'
>
<span className={style.timeLabel}>D</span>
{isLockedDuration ? <IoLockClosed /> : <IoLockOpenOutline />}
</InputRightElement>
</Tooltip>
</TimeInputWithButton>
{overMidnight && (
@@ -2,13 +2,13 @@ import { ComponentType, useMemo } from 'react';
import { ViewExtendedTimer } from 'common/models/TimeManager.type';
import {
CustomFields,
Message,
MessageState,
OntimeEvent,
ProjectData,
Runtime,
Settings,
SimpleTimerState,
SupportedEvent,
TimerMessage,
ViewSettings,
} from 'ontime-types';
import { useStore } from 'zustand';
@@ -23,23 +23,23 @@ import { runtimeStore } from '../../common/stores/runtime';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
type WithDataProps = {
auxTimer: SimpleTimerState;
backstageEvents: OntimeEvent[];
customFields: CustomFields;
eventNext: OntimeEvent | null;
eventNow: OntimeEvent | null;
events: OntimeEvent[];
external: Message;
general: ProjectData;
isMirrored: boolean;
message: MessageState;
nextId: string | null;
onAir: boolean;
pres: TimerMessage;
publicEventNext: OntimeEvent | null;
publicEventNow: OntimeEvent | null;
publicSelectedId: string | null;
runtime: Runtime;
selectedId: string | null;
settings: Settings | undefined;
settings: Settings | undefined; // TODO: what is the case for this being undefined?
time: ViewExtendedTimer;
viewSettings: ViewSettings;
};
@@ -68,7 +68,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
}, [rundownData]);
// websocket data
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime } =
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime, auxtimer1 } =
useStore(runtimeStore);
const publicSelectedId = publicEventNow?.id ?? null;
const selectedId = eventNow?.id ?? null;
@@ -86,27 +86,22 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
timerType: eventNow?.timerType ?? null,
};
// prevent render until we get all the data we need
if (!viewSettings) {
return null;
}
return (
<>
<ViewNavigationMenu />
<Component
{...props}
auxTimer={auxtimer1}
backstageEvents={rundownData}
customFields={customFields}
eventNext={eventNext}
eventNow={eventNow}
events={publicEvents}
external={message.external}
general={project}
isMirrored={isMirrored}
message={message}
nextId={nextId}
onAir={onAir}
pres={message.timer}
publicEventNext={publicEventNext}
publicEventNow={publicEventNow}
publicSelectedId={publicSelectedId}
@@ -2,17 +2,15 @@ import { useEffect, useState } from 'react';
import QRCode from 'react-qr-code';
import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { CustomFields, OntimeEvent, ProjectData, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
import { CustomFields, OntimeEvent, ProjectData, Settings, SupportedEvent } from 'ontime-types';
import { millisToString, removeLeadingZero } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/constants';
import ProgressBar from '../../../common/components/progress-bar/ProgressBar';
import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
import TitleCard from '../../../common/components/title-card/TitleCard';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { timerPlaceholderMin } from '../../../common/utils/styleUtils';
@@ -37,25 +35,12 @@ interface BackstageProps {
backstageEvents: OntimeEvent[];
selectedId: string | null;
general: ProjectData;
viewSettings: ViewSettings;
settings: Settings | undefined;
}
export default function Backstage(props: BackstageProps) {
const {
customFields,
isMirrored,
eventNow,
eventNext,
time,
backstageEvents,
selectedId,
general,
viewSettings,
settings,
} = props;
const { customFields, isMirrored, eventNow, eventNext, time, backstageEvents, selectedId, general, settings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
const [blinkClass, setBlinkClass] = useState(false);
const [searchParams] = useSearchParams();
@@ -73,11 +58,6 @@ export default function Backstage(props: BackstageProps) {
return () => clearTimeout(timer);
}, [selectedId]);
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
const clock = formatTime(time.clock);
const startedAt = formatTime(time.startedAt);
const isNegative = (time.current ?? 0) < 0;
@@ -94,7 +74,6 @@ export default function Backstage(props: BackstageProps) {
let stageTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
stageTimer = removeLeadingZero(stageTimer);
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const backstageOptions = getBackstageOptions(defaultFormat, customFields);
@@ -111,8 +90,8 @@ export default function Backstage(props: BackstageProps) {
<ProgressBar
className='progress-container'
now={time.current ?? undefined}
complete={totalTime}
current={time.current}
duration={time.duration}
hidden={!showProgress}
/>
@@ -1,9 +1,7 @@
import { useSearchParams } from 'react-router-dom';
import { Settings, ViewSettings } from 'ontime-types';
import { Settings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { OverridableOptions } from '../../../common/models/View.types';
@@ -17,22 +15,15 @@ import './Clock.scss';
interface ClockProps {
isMirrored: boolean;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
settings: Settings | undefined;
}
export default function Clock(props: ClockProps) {
const { isMirrored, time, viewSettings, settings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { isMirrored, time, settings } = props;
const [searchParams] = useSearchParams();
useWindowTitle('Clock');
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
// get config from url: key, text, font, size, hidenav
// eg. http://localhost:3000/clock?key=f00&text=fff
// Check for user options
@@ -8,26 +8,23 @@ export const getClockOptions = (timeFormat: string): ViewOption[] => [
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
description: 'Background or key colour for entire view. Default: #000000',
type: 'colour',
defaultValue: '000000',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
description: 'Text colour. Default: #FFFFFF',
type: 'colour',
defaultValue: 'FFFFFF',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
description: 'Background colour for timer text. Default: #FFF0 (transparent)',
type: 'colour',
defaultValue: 'FFF0',
},
{
id: 'font',
@@ -1,19 +1,8 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
OntimeEvent,
OntimeRundownEntry,
Playback,
Runtime,
Settings,
SupportedEvent,
TimerPhase,
ViewSettings,
} from 'ontime-types';
import { OntimeEvent, OntimeRundownEntry, Playback, Runtime, Settings, SupportedEvent, TimerPhase } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
@@ -34,12 +23,10 @@ interface CountdownProps {
selectedId: string | null;
settings: Settings | undefined;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
}
export default function Countdown(props: CountdownProps) {
const { isMirrored, backstageEvents, runtime, selectedId, settings, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { isMirrored, backstageEvents, runtime, selectedId, settings, time } = props;
const [searchParams] = useSearchParams();
const { getLocalizedString } = useTranslation();
@@ -80,11 +67,6 @@ export default function Countdown(props: CountdownProps) {
}
}, [backstageEvents, searchParams]);
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
const { message: runningMessage, timer: runningTimer } = fetchTimerData(time, follow, selectedId, runtime.offset);
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
@@ -39,16 +39,16 @@ const defaultOptions: Readonly<LowerOptions> = {
width: 45,
topSrc: 'title',
bottomSrc: 'lowerMsg',
topColour: '000000ff',
bottomColour: '000000ff',
topBg: '00000000',
bottomBg: '00000000',
topColour: '000000',
bottomColour: '000000',
topBg: 'FFF0',
bottomBg: 'FFF0',
topSize: '65px',
bottomSize: '40px',
transition: 3,
delay: 3,
key: 'ffffffff',
lineColour: 'ff0000ff',
key: 'FFF0',
lineColour: 'FF0000',
lineHeight: '0.4em',
};
@@ -6,10 +6,12 @@ import { ViewOption } from '../../../common/components/view-params-editor/types'
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
note: 'Note',
});
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
note: 'Note',
none: 'None',
});
@@ -31,39 +33,22 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
values: bottomSourceOptions,
defaultValue: 'none',
},
{ section: 'View animation' },
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{ section: 'View style override' },
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'top-size',
title: 'Top Text Size',
@@ -86,35 +71,47 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Colour of the background',
prefix: '#',
type: 'string',
placeholder: 'ffffffff (default)',
description: 'Colour of the background. Default: #FFF0 (transparent)',
type: 'colour',
defaultValue: 'FFF0',
},
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour. Default: #000000',
type: 'colour',
defaultValue: '000000',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour. Default: #000000',
type: 'colour',
defaultValue: '000000',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour. Default: #FFF0 (transparent)',
type: 'colour',
defaultValue: 'FFF0',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour. Default: #FFF0 (transparent)',
type: 'colour',
defaultValue: 'FFF0',
},
{
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line',
prefix: '#',
type: 'string',
placeholder: 'ff0000ff (default)',
description: 'Colour of the line. Default: #FF0000',
type: 'colour',
defaultValue: 'FF0000',
},
];
};
@@ -1,9 +1,7 @@
import { useSearchParams } from 'react-router-dom';
import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { OverridableOptions } from '../../../common/models/View.types';
@@ -22,17 +20,11 @@ interface MinimalTimerProps {
export default function MinimalTimer(props: MinimalTimerProps) {
const { isMirrored, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams();
useWindowTitle('Minimal Timer');
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
// TODO: this should be tied to the params
// USER OPTIONS
const userOptions: OverridableOptions = {
@@ -169,6 +161,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
<div
className={timerClasses}
style={{
color: userOptions.textColour,
fontSize: `${timerFontSize}vw`,
fontFamily: userOptions.font,
top: userOptions.top,
@@ -23,26 +23,23 @@ export const MINIMAL_TIMER_OPTIONS: ViewOption[] = [
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
description: 'Background or key colour for entire view. Default: #000000',
type: 'colour',
defaultValue: '000000',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
description: 'Text colour. Default: #FFFFFF',
type: 'colour',
defaultValue: 'FFFFFF',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
description: 'Background colour for timer text. Default: #FFF0 (transparent)',
type: 'colour',
defaultValue: 'FFF0',
},
{
id: 'font',

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