Compare commits

...

84 Commits

Author SHA1 Message Date
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
Carlos Valente a26aa9e01c bump version to 3.5.0-beta.2 2024-08-04 21:37:01 +02:00
Carlos Valente da11ef64c2 refactor: roll mode
* chore: escalate stack trace to console

* refactor: event finding in roll

* docs: initial roll specification

* refactor: call to roll

* refactor: normalise index

* refactor: roll into next event

* refactor: hot reload on waiting to roll

* refactor: wait to roll next
2024-08-04 21:34:01 +02:00
Carlos Valente 3012d73285 feat: timeline view 2024-08-04 21:32:25 +02:00
Alex Christoffer Rasmussen bad491063d fix: getShouldClockUpdate test (#1165)
* useFakeTimers

* now can never be negative
2024-07-31 16:58:02 +02:00
Alex Christoffer Rasmussen a103f8b347 show time in block (#1158) 2024-07-31 14:00:40 +02:00
Alex Christoffer Rasmussen dffccd258f prevent rare case of old customFields (#1163)
as part of subscriptions in operator
2024-07-30 15:42:37 +02:00
Alex Christoffer Rasmussen ce8b0d9850 FIX: remove '/' from host in client-control(#1156) 2024-07-30 12:36:31 +02:00
Alex Christoffer Rasmussen e500fa6b5e about in electron help menu (#1155) 2024-07-29 18:06:10 +02:00
Alex Christoffer Rasmussen 67be4a5631 restore rundownGetAll (#1162) 2024-07-29 13:29:56 +02:00
Alex Christoffer Rasmussen c7893938eb remove old l3 options (#1160) 2024-07-27 09:44:41 +02:00
Alex Christoffer Rasmussen 158ef05ff0 add relevantBlock function (#1100)
* feat: current block

* pass full rundown to functions and use filter utils
2024-07-23 12:22:47 +02:00
Carlos Valente 20838c038a chore: evaluate react 19 migration 2024-07-23 09:02:44 +02:00
Carlos Valente 1ddcec993a chore: upgrade sentry to react version 2024-07-22 22:27:10 +02:00
Alex Christoffer Rasmussen 91d6adf8e0 useFakeTimers for initRundown in tests (#1150) 2024-07-22 14:15:09 +02:00
Carlos Valente a7fe5eceef chore: upgrade sentry 2024-07-20 16:25:46 +02:00
jwetzell e6e00c0d4a add freeze feature to rundown (#1106)
* add frozen flag to runtime store

* add middleware for when frozen

* prevent rundown editing when frozen

* add endpoint to router to set frozen state

* add frozen property to rundown placeholder in client
2024-07-19 14:14:59 +02:00
Carlos Valente edc7f20d7a refactor: timer update 2024-07-19 14:11:12 +02:00
Carlos Valente cb773ded9f refactor: improve pending detection 2024-07-19 14:11:12 +02:00
Carlos Valente 2f65711078 refactor: dev label 2024-07-19 14:11:12 +02:00
jwetzell 3056b75960 clean out unused demo and test db.json files (#1148)
* remove demo and test db JSON

* remove demo-db reference in Dockerfile

* remove preloaded-db folder in server

* cleanup package.json scripts

* remove db.json references from server
2024-07-18 22:27:24 +02:00
jwetzell dd9423abf4 remove unused dependency 2024-07-18 22:26:58 +02:00
jwetzell dee6a67018 Merge pull request #1146 from cpvalente/chore/pnpm-catalogs
convert shared dependencies to pnpm catalog
2024-07-17 22:23:47 -05:00
jwetzell a79fb4ecbb convert shared dependencies to pnpm catalog 2024-07-17 07:53:43 -05:00
jwetzell a53a1f87be Merge pull request #1145 from cpvalente/chore/remove-husky
remove husky and pre-commit scripts
2024-07-17 07:41:17 -05:00
jwetzell ef9afcbf3e remove husky and pre-commit scripts 2024-07-16 17:57:20 -05:00
Carlos Valente 33b04e01c9 chore: cleanup unused 2024-07-16 21:34:21 +02:00
194 changed files with 6141 additions and 4079 deletions
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged
-1
View File
@@ -22,7 +22,6 @@ COPY --from=builder /app/apps/client/build ./client/
# Prepare Backend
COPY --from=builder /app/apps/server/dist/ ./server/
COPY ./demo-db/ ./preloaded-db/
COPY --from=builder /app/apps/server/src/external/ ./external/
# Export default ports
+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
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.4.0",
"version": "3.6.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -20,8 +20,8 @@
"server"
],
"devDependencies": {
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"prettier": "^3.0.3"
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"prettier": "catalog:"
}
}
+11 -12
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.4.0",
"version": "3.6.1",
"private": true,
"type": "module",
"dependencies": {
@@ -13,7 +13,7 @@
"@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.6.2",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.92.0",
"@sentry/react": "^8.19.0",
"@tanstack/react-query": "^5.17.9",
"@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-table": "^8.11.3",
@@ -22,9 +22,9 @@
"color": "^4.2.3",
"csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0",
"react": "^18.2.0",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-dom": "^18.3.1",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.12",
@@ -42,7 +42,6 @@
"build:docker": "vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"test": "vitest",
"test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
@@ -70,13 +69,13 @@
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^v7.16.1",
"@typescript-eslint/parser": "^7.16.1",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-prettier": "catalog:",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
@@ -84,9 +83,9 @@
"jsdom": "^21.1.0",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "^3.3.1",
"prettier": "catalog:",
"sass": "^1.57.1",
"typescript": "^5.5.3",
"typescript": "catalog:",
"vite": "^5.2.11",
"vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0",
+57 -30
View File
@@ -1,24 +1,36 @@
import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import React from 'react';
import {
createRoutesFromChildren,
matchRoutes,
Navigate,
Route,
Routes,
useLocation,
useNavigationType,
} from 'react-router-dom';
import * as Sentry from '@sentry/react';
import { useClientPath } from './common/hooks/useClientPath';
import Log from './features/log/Log';
import withPreset from './features/PresetWrapper';
import withData from './features/viewers/ViewWrapper';
import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/OperatorExport'));
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
const Public = lazy(() => import('./features/viewers/public/Public'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
const Timeline = React.lazy(() => import('./features/viewers/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'));
const STimer = withPreset(withData(TimerView));
const SMinimalTimer = withPreset(withData(MinimalTimerView));
@@ -28,41 +40,56 @@ const SBackstage = withPreset(withData(Backstage));
const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline));
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
Sentry.init({
dsn: sentryDsn,
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect: React.useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
tracesSampleRate: 0.3,
release: ONTIME_VERSION,
enabled: import.meta.env.PROD,
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
});
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
export default function AppRouter() {
// handle client path changes
useClientPath();
return (
<Suspense fallback={null}>
<Routes>
<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='/public' element={<SPublic />} />
<Route path='/studio' element={<SStudio />} />
<Route path='/lower' element={<SLowerThird />} />
<Route path='/op' element={<Operator />} />
<Route path='/timeline' element={<STimeline />} />
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/op' element={<Operator />} />
{/*/!* Protected Routes - Elements *!/*/}
<Route
@@ -99,7 +126,7 @@ export default function AppRouter() {
/>
{/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} />
</Routes>
</Suspense>
</SentryRoutes>
</React.Suspense>
);
}
+1
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;
+1 -1
View File
@@ -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 });
}
@@ -38,7 +38,7 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
onClose();
};
const host = `${window.location.origin}/`;
const host = window.location.origin;
const canSubmit = path !== currentPath && path !== '';
return (
@@ -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;
};
@@ -54,7 +54,6 @@ function Overlay() {
};
}, [showOverlay, id, setIdentify, handleClose]);
console.log('here2');
return (
<div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}>
<div className={style.name}>{name}</div>
@@ -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;
@@ -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,
@@ -19,9 +19,12 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import useElectronEvent from '../../hooks/useElectronEvent';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import { RenameClientModal } from '../client-modal/RenameClientModal';
import style from './NavigationMenu.module.scss';
@@ -40,6 +43,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);
@@ -96,38 +100,29 @@ 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>
))}
</DrawerBody>
</DrawerContent>
@@ -137,4 +132,30 @@ function NavigationMenu(props: NavigationMenuProps) {
);
}
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}
</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>
);
}
@@ -51,6 +51,7 @@ interface EditFormDrawerProps {
viewOptions: ViewOption[];
}
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onClose, onOpen } = useDisclosure();
@@ -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',
});
@@ -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 };
@@ -12,9 +12,7 @@ export const useClientPath = () => {
// notify of client path changes
useEffect(() => {
//remove leading '/' from path
const fullPath = (pathname.startsWith('/') ? pathname.slice(1) : pathname) + search;
socketSendJson('set-client-path', fullPath);
socketSendJson('set-client-path', pathname + search);
}, [pathname, search]);
// navigate to new path when received from server
+34 -6
View File
@@ -9,6 +9,8 @@ import {
parseUserTime,
reorderArray,
swapEventData,
validateEndAction,
validateTimerType,
} from 'ontime-utils';
import { RUNDOWN } from '../api/constants';
@@ -32,7 +34,15 @@ import { useEditorSettings } from '../stores/editorSettings';
*/
export const useEventAction = () => {
const queryClient = useQueryClient();
const { defaultPublic, linkPrevious, defaultDuration, defaultWarnTime, defaultDangerTime } = useEditorSettings();
const {
defaultPublic,
linkPrevious,
defaultDuration,
defaultWarnTime,
defaultDangerTime,
defaultTimerType,
defaultEndAction,
} = useEditorSettings();
/**
* Calls mutation to add new event
@@ -46,17 +56,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 +78,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 +98,7 @@ export const useEventAction = () => {
}
}
// Override event with options from editor settings
if (applicationOptions.defaultPublic) {
newEvent.isPublic = true;
}
@@ -104,6 +114,14 @@ export const useEventAction = () => {
if (newEvent.timeWarning === undefined) {
newEvent.timeWarning = parseUserTime(defaultWarnTime);
}
if (newEvent.timerType === undefined) {
newEvent.timerType = validateTimerType(defaultTimerType);
}
if (newEvent.endAction === undefined) {
newEvent.endAction = validateEndAction(defaultEndAction);
}
}
// handle adding options that concern all event type
@@ -117,7 +135,17 @@ export const useEventAction = () => {
logAxiosError('Failed adding event', error);
}
},
[_addEventMutation, defaultDangerTime, defaultDuration, defaultPublic, defaultWarnTime, linkPrevious, queryClient],
[
_addEventMutation,
defaultDangerTime,
defaultDuration,
defaultEndAction,
defaultPublic,
defaultTimerType,
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;
+53 -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);
@@ -115,6 +151,7 @@ export const setAuxTimer = {
export const useCuesheet = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
currentBlockId: state.currentBlock.block?.id ?? null,
selectedEventId: state.eventNow?.id ?? null,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
@@ -149,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,
@@ -179,6 +215,17 @@ export const useRuntimePlaybackOverview = () => {
numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset,
currentBlock: state.currentBlock,
});
return useRuntimeStore(featureSelector);
};
export const useTimelineStatus = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offset,
});
return useRuntimeStore(featureSelector);
@@ -1,3 +1,5 @@
import { EndAction, TimerType } from 'ontime-types';
import { validateEndAction, validateTimerType } from 'ontime-utils';
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
@@ -8,11 +10,15 @@ type EditorSettingsStore = {
defaultWarnTime: string;
defaultDangerTime: string;
defaultPublic: boolean;
defaultTimerType: TimerType;
defaultEndAction: EndAction;
setDefaultDuration: (defaultDuration: string) => void;
setLinkPrevious: (linkPrevious: boolean) => void;
setWarnTime: (warnTime: string) => void;
setDangerTime: (dangerTime: string) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
setDefaultTimerType: (defaultTimerType: TimerType) => void;
setDefaultEndAction: (defaultEndAction: EndAction) => void;
};
export const editorSettingsDefaults = {
@@ -21,6 +27,8 @@ export const editorSettingsDefaults = {
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 {
@@ -29,6 +37,8 @@ enum EditorSettingsKeys {
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) => {
@@ -38,6 +48,14 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
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(() => {
@@ -65,5 +83,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 };
}),
};
});
+7 -4
View File
@@ -23,11 +23,9 @@ export const runtimeStorePlaceholder: RuntimeStore = {
visible: false,
blink: false,
blackout: false,
secondarySource: null,
},
external: {
text: '',
visible: false,
},
external: '',
},
runtime: {
selectedEventIndex: null,
@@ -38,6 +36,10 @@ export const runtimeStorePlaceholder: RuntimeStore = {
actualStart: null,
expectedEnd: null,
},
currentBlock: {
block: null,
startedAt: null,
},
eventNow: null,
eventNext: null,
publicEventNow: null,
@@ -48,6 +50,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
duration: 0,
playback: SimplePlayback.Stop,
},
frozen: false,
};
const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
@@ -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);
}
+20 -3
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,
@@ -36,7 +36,8 @@ export const connectSocket = () => {
}
socketSendJson('set-client-type', 'ontime');
socketSendJson('set-client-path', location.pathname);
socketSendJson('set-client-path', location.pathname + location.search);
};
websocket.onclose = () => {
@@ -150,6 +151,11 @@ export const connectSocket = () => {
updateDevTools({ eventNow: payload });
break;
}
case 'ontime-currentBlock': {
patchRuntime('currentBlock', payload);
updateDevTools({ currentBlock: payload });
break;
}
case 'ontime-publicEventNow': {
patchRuntime('publicEventNow', payload);
updateDevTools({ publicEventNow: payload });
@@ -170,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
@@ -34,3 +34,16 @@ export const enDash = '';
export const timerPlaceholder = '––:––:––';
export const timerPlaceholderMin = '––:––';
/**
* Adds opacity to a given colour if possible
*/
export function alpha(colour: string, amount: number): string {
try {
const withAlpha = Color(colour).alpha(amount).hexa();
return withAlpha;
} catch (_error) {
/* we do not handle errors here */
}
return colour;
}
+37 -7
View File
@@ -1,25 +1,25 @@
import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
import { formatFromMillis } from 'ontime-utils';
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
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 const nowInMillis = () => {
export function nowInMillis(): number {
const now = new Date();
// extract milliseconds since midnight
let elapsed = now.getHours() * 3600000;
elapsed += now.getMinutes() * 60000;
elapsed += now.getSeconds() * 1000;
let elapsed = now.getHours() * MILLIS_PER_HOUR;
elapsed += now.getMinutes() * MILLIS_PER_MINUTE;
elapsed += now.getSeconds() * MILLIS_PER_SECOND;
elapsed += now.getMilliseconds();
return elapsed;
};
}
/**
* @description Resolves format from url and store
@@ -95,3 +95,33 @@ export const formatTime = (
const isNegative = milliseconds < 0;
return `${isNegative ? '-' : ''}${display}`;
};
/**
* Handles case for formatting a duration time
* @param duration
* @returns
*/
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';
}
const hours = Math.floor(duration / MILLIS_PER_HOUR);
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
let result = '';
if (hours > 0) {
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;
}
@@ -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>
@@ -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>
@@ -7,17 +7,26 @@ import { editorSettingsDefaults, useEditorSettings } from '../../../../common/st
import * as Panel from '../PanelUtils';
export default function EditorSettingsForm() {
const eventSettings = useEditorSettings((state) => state);
const {
defaultDuration,
linkPrevious,
defaultWarnTime,
defaultDangerTime,
defaultPublic,
defaultTimerType,
defaultEndAction,
setDefaultDuration,
setLinkPrevious,
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>
@@ -44,13 +53,19 @@ export default function EditorSettingsForm() {
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.linkPrevious}
defaultChecked={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 +74,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 +114,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
@@ -42,7 +42,7 @@ export default function ManageProjects() {
const errorMessage = maybeAxiosError(error);
setError(`Error uploading file: ${errorMessage}`);
} finally {
invalidateAllCaches();
await invalidateAllCaches();
}
setLoading(null);
@@ -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>
@@ -78,7 +78,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 (
@@ -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 {
transform: rotate(45deg);
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={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;
}
}
@@ -21,11 +21,11 @@ interface CuesheetProps {
columns: ColumnDef<OntimeRundownEntry>[];
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => void;
selectedId: string | null;
currentBlockId: string | null;
}
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
export default function Cuesheet({ data, columns, handleUpdate, selectedId, currentBlockId }: CuesheetProps) {
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
const {
columnVisibility,
columnOrder,
@@ -114,11 +114,16 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
}
if (isOntimeBlock(row.original)) {
if (isPast && !showPrevious && key !== currentBlockId) {
return null;
}
return <BlockRow key={key} title={row.original.title} />;
}
if (isOntimeDelay(row.original)) {
if (isPast && !showPrevious) {
return null;
}
const delayVal = row.original.duration;
if (!showDelayBlock || delayVal === 0) {
return null;
}
@@ -128,9 +133,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
if (isOntimeEvent(row.original)) {
eventIndex++;
const isSelected = key === selectedId;
if (isSelected) {
isPast = false;
}
if (isPast && !showPrevious) {
return null;
@@ -107,6 +107,7 @@ export default function CuesheetWrapper() {
columns={columns}
handleUpdate={handleUpdate}
selectedId={featureData.selectedEventId}
currentBlockId={featureData.currentBlockId}
/>
</div>
);
@@ -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>
);
@@ -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;
@@ -170,9 +170,12 @@ export default function Operator() {
const mainField = main ? getPropertyValue(entry, main) ?? '' : entry.title;
const secondaryField = getPropertyValue(entry, secondary) ?? '';
const subscribedData = subscriptions
? subscriptions.map((id) => {
? subscriptions.flatMap((id) => {
if (!customFields[id]) {
return [];
}
const { label, colour } = customFields[id];
return { id, label, colour, value: entry.custom[id] };
return [{ id, label, colour, value: entry.custom[id] }];
})
: null;
@@ -30,7 +30,7 @@
&.running {
border-top: 1px solid $gray-1300;
background-color: var(--operator-running-bg-override, $red-700);
background-color: var(--operator-running-bg-override, $active-red);
}
&.past {
@@ -99,7 +99,6 @@
display: flex;
flex-wrap: wrap;
.field {
font-weight: 600;
padding-inline: 0.25rem;
@@ -45,7 +45,7 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
@@ -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}
@@ -33,6 +33,7 @@ function _EditorOverview({ children }: { children: React.ReactNode }) {
<TimeRow label='Actual start' value={formatedTime(actualStart)} className={style.start} />
</div>
<ProgressOverview />
<CurrentBlockOverview />
<RuntimeOverview />
<div>
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} />
@@ -94,6 +95,14 @@ function TitlesOverview() {
);
}
function CurrentBlockOverview() {
const { currentBlock, clock } = useRuntimePlaybackOverview();
const timeInBlock = formatedTime(currentBlock.startedAt === null ? null : clock - currentBlock.startedAt);
return <TimeColumn label='Time in block' value={timeInBlock} className={style.clock} />;
}
function TimerOverview() {
const { current } = useTimer();
+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);
});
});
@@ -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>
@@ -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;
@@ -96,17 +96,17 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
<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}
@@ -94,7 +94,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 +110,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}
/>
@@ -14,18 +14,6 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
});
return [
{ section: 'View behaviour' },
{
id: 'trigger',
title: 'Animation Trigger',
description: '',
type: 'option',
values: {
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'manual',
},
{ section: 'Data sources' },
{
id: 'top-src',
@@ -42,6 +42,11 @@ $orange-active: #f60;
grid-template-areas: 'clock schedule';
text-transform: uppercase;
&.hide-right {
grid-template-columns: 1fr;
grid-template-areas: 'clock';
}
.clock-container {
grid-area: clock;
display: grid;
@@ -115,6 +120,9 @@ $orange-active: #f60;
}
.next-title {
height: 12.5vh;
width: 70%;
font-family: monospace;
font-weight: 400;
color: var(--studio-active-label, $cyan-active);
@@ -1,20 +1,19 @@
import { useSearchParams } from 'react-router-dom';
import type { OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types';
import { isOntimeEvent, Playback } from 'ontime-types';
import { millisToString, removeSeconds } from 'ontime-utils';
import type { MaybeString, OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types';
import { Playback } from 'ontime-types';
import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/constants';
import { FitText } from '../../../common/components/fit-text/FitText';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import useFitText from '../../../common/hooks/useFitText';
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';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { isStringBoolean } from '../common/viewUtils';
import { getStudioClockOptions } from './studioClock.options';
import { secondsInMillis, trimRundown } from './studioClock.utils';
import StudioClockSchedule from './StudioClockSchedule';
import './StudioClock.scss';
@@ -23,8 +22,8 @@ interface StudioClockProps {
eventNext: OntimeEvent | null;
time: ViewExtendedTimer;
backstageEvents: OntimeRundown;
selectedId: string | null;
nextId: string | null;
selectedId: MaybeString;
nextId: MaybeString;
onAir: boolean;
viewSettings: ViewSettings;
settings: Settings | undefined;
@@ -33,21 +32,20 @@ interface StudioClockProps {
export default function StudioClock(props: StudioClockProps) {
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, viewSettings, settings } = props;
// TODO: can we prevent the Flash of Unstyled Content on the 7segment fonts?
// deferring rendering seems to affect styling (font and useFitText)
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ minFontSize: 150, maxFontSize: 500 });
const activeIndicators = [...Array(12).keys()];
const secondsIndicators = [...Array(60).keys()];
// TODO: fit titles on screen
const MAX_TITLES = 11;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
useWindowTitle('Studio Clock');
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
const activeIndicators = [...Array(12).keys()];
const secondsIndicators = [...Array(60).keys()];
let clock = formatTime(time.clock);
let hasAmPm = '';
if (clock.includes('AM')) {
@@ -65,8 +63,7 @@ export default function StudioClock(props: StudioClockProps) {
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const studioClockOptions = getStudioClockOptions(defaultFormat);
const delayed = backstageEvents.filter((event) => isOntimeEvent(event)) as OntimeEvent[];
const trimmedRundown = trimRundown(delayed, selectedId, MAX_TITLES);
const hideRight = isStringBoolean(searchParams.get('hideRight'));
let timer = millisToString(time.current, { fallback: '---' });
const hideSeconds = isStringBoolean(searchParams.get('hideTimerSeconds'));
if (time.current != null && hideSeconds) {
@@ -74,18 +71,15 @@ export default function StudioClock(props: StudioClockProps) {
}
return (
<div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'>
<div
className={`studio-clock ${isMirrored ? 'mirror' : ''} ${hideRight ? 'hide-right' : ''}`}
data-testid='studio-view'
>
<ViewParamsEditor viewOptions={studioClockOptions} />
<div className='clock-container'>
{hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>}
<div className={`studio-timer ${!hideSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
<div
ref={titleRef}
className='next-title'
style={{ fontSize: titleFontSize, height: '12.5vh', width: '100%', maxWidth: '80%' }}
>
{eventNext?.title}
</div>
<FitText className='next-title'>{eventNext?.title}</FitText>
<div
className={`
next-countdown ${isNegative ? ' next-countdown--overtime' : ''} ${isPaused ? ' next-countdown--paused' : ''}
@@ -114,31 +108,9 @@ export default function StudioClock(props: StudioClockProps) {
))}
</div>
</div>
<div className='schedule-container'>
<div
className={onAir ? 'onAir' : 'onAir onAir--idle'}
data-testid={onAir ? 'on-air-enabled' : 'on-air-disabled'}
>
ON AIR
</div>
<ul className='schedule'>
{trimmedRundown.map((event) => {
const start = formatTime(event.timeStart + (event?.delay ?? 0), { format12: 'h:mm a', format24: 'HH:mm' });
const isSelected = event.id === selectedId;
const isNext = event.id === nextId;
const classes = `schedule__item schedule__item${isSelected ? '--now' : isNext ? '--next' : '--future'}`;
return (
<li key={event.id} className={classes}>
<span className='event'>
<span className='event__colour' style={{ backgroundColor: `${event.colour}` }} />
<SuperscriptTime time={start} />
</span>
<span>{event.title}</span>
</li>
);
})}
</ul>
</div>
{!hideRight && (
<StudioClockSchedule rundown={backstageEvents} selectedId={selectedId} nextId={nextId} onAir={onAir} />
)}
</div>
);
}
@@ -0,0 +1,50 @@
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundown } from 'ontime-types';
import { formatTime } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { trimRundown } from './studioClock.utils';
import './StudioClock.scss';
interface StudioClockScheduleProps {
rundown: OntimeRundown;
selectedId: MaybeString;
nextId: MaybeString;
onAir: boolean;
}
// TODO: fit titles on screen
const MAX_TITLES = 11;
export default function StudioClockSchedule(props: StudioClockScheduleProps) {
const { rundown, selectedId, nextId, onAir } = props;
const delayed = rundown.filter((event) => isOntimeEvent(event)) as OntimeEvent[];
const trimmedRundown = trimRundown(delayed, selectedId, MAX_TITLES);
return (
<div className='schedule-container'>
<div className={onAir ? 'onAir' : 'onAir onAir--idle'} data-testid={onAir ? 'on-air-enabled' : 'on-air-disabled'}>
ON AIR
</div>
<ul className='schedule'>
{trimmedRundown.map((event) => {
const start = formatTime(event.timeStart + (event?.delay ?? 0), { format12: 'h:mm a', format24: 'HH:mm' });
const isSelected = event.id === selectedId;
const isNext = event.id === nextId;
const classes = `schedule__item schedule__item${isSelected ? '--now' : isNext ? '--next' : '--future'}`;
return (
<li key={event.id} className={classes}>
<span className='event'>
<span className='event__colour' style={{ backgroundColor: `${event.colour}` }} />
<SuperscriptTime time={start} />
</span>
<span>{event.title}</span>
</li>
);
})}
</ul>
</div>
);
}
@@ -1,6 +1,6 @@
import { OntimeEvent } from 'ontime-types';
import { secondsInMillis, trimRundown } from '../studioClock.utils';
import { trimRundown } from '../studioClock.utils';
describe('test trimEventlist function', () => {
const limit = 8;
@@ -118,14 +118,3 @@ describe('test trimEventlist function', () => {
expect(l).toStrictEqual(expected);
});
});
describe('secondsInMillis()', () => {
it('return 0 if value is null', () => {
expect(secondsInMillis(null)).toBe(0);
});
it('returns the seconds value of a millis date', () => {
const date = 1686255053619; // Thu Jun 08 2023 20:10:53
const seconds = secondsInMillis(date);
expect(seconds).toBe(53);
});
});
@@ -6,4 +6,12 @@ export const getStudioClockOptions = (timeFormat: string): ViewOption[] => [
getTimeOption(timeFormat),
{ section: 'Timer Options' },
hideTimerSeconds,
{ section: 'Element visibility' },
{
id: 'hideRight',
title: 'Hide right section',
description: 'Hides the right section with On Air indicator and the schedule',
type: 'boolean',
defaultValue: false,
},
];
@@ -1,5 +1,4 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { OntimeEvent } from 'ontime-types';
/**
* @description Returns trimmed event list array
@@ -20,15 +19,3 @@ export function trimRundown(rundown: OntimeEvent[], selectedId: string | null, l
const trimmedRundown = rundown.slice(startIndex, endIndex);
return trimmedRundown;
}
/**
* @description Returns amount of seconds in a date given in milliseconds. For studio clock second indicator
* @param {MaybeNumber} millis time to format
* @returns amount of elapsed seconds
*/
export function secondsInMillis(millis: MaybeNumber): number {
if (!millis) {
return 0;
}
return Math.floor((millis % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
}
@@ -0,0 +1,112 @@
@use '../../../theme/viewerDefs' as *;
$timeline-entry-height: 20px;
$lane-height: 120px;
$timeline-height: 1rem;
.timeline {
flex: 1;
font-weight: 600;
color: $ui-white;
background-color: $ui-black;
}
.timelineEvents {
position: relative;
height: 100%;
}
.column {
display: flex;
flex-direction: column;
position: absolute;
border-left: 1px solid $ui-black;
// avoiding content being larger than the view
height: calc(100% - 3rem);
// decorate timeline element
&::before {
content: '';
position: absolute;
box-sizing: content-box;
top: -$timeline-height;
left: 0;
right: 0;
height: $timeline-height;
background-color: $white-40;
}
}
.smallArea {
.content {
gap: 0rem;
writing-mode: vertical-rl;
}
.timeOverview {
opacity: 0;
}
}
.hide {
// hide text elements
& > div {
display: none;
}
}
.content {
flex: 1;
display: flex;
flex-direction: column;
gap: 2rem;
padding-top: 0.25rem;
padding-inline-start: 0.25rem;
overflow: hidden;
line-height: 1rem;
background-color: var(--lighter, $viewer-card-bg-color);
border-bottom: 2px solid $ui-black;
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
&[data-status='done'] {
opacity: $opacity-disabled;
}
&[data-status='live'] {
box-shadow: 0 0.25rem 0 0 $active-red;
}
}
.delay {
margin-top: -2rem;
margin-bottom: -1rem;
}
.timeOverview {
padding-top: 0.25rem;
padding-inline-start: 0.25em;
text-transform: capitalize;
white-space: normal;
height: 6rem;
&[data-status='done'] {
opacity: $opacity-disabled;
}
&[data-status='live'] {
.status {
color: $active-red;
}
}
&[data-status='future'] {
.status {
color: $green-500;
}
}
}
.cross {
text-decoration: line-through;
}
@@ -0,0 +1,95 @@
import { memo } from 'react';
import { useViewportSize } from '@mantine/hooks';
import { isOntimeEvent, isPlayableEvent, MaybeNumber, OntimeRundown } from 'ontime-types';
import { checkIsNextDay, dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import TimelineMarkers from './timeline-markers/TimelineMarkers';
import TimelineProgressBar from './timeline-progress-bar/TimelineProgressBar';
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
import { ProgressStatus, TimelineEntry } from './TimelineEntry';
import style from './Timeline.module.scss';
interface TimelineProps {
firstStart: number;
rundown: OntimeRundown;
selectedEventId: string | null;
totalDuration: number;
}
export default memo(Timeline);
function Timeline(props: TimelineProps) {
const { firstStart, rundown, selectedEventId, totalDuration } = props;
const { width: screenWidth } = useViewportSize();
if (totalDuration === 0) {
return null;
}
const { lastEvent } = getLastEvent(rundown);
const startHour = getStartHour(firstStart);
const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0));
let previousEventStartTime: MaybeNumber = null;
// we use selectedEventId as a signifier on whether the timeline is live
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
let elapsedDays = 0;
return (
<div className={style.timeline}>
<TimelineMarkers startHour={startHour} endHour={endHour} />
<TimelineProgressBar startHour={startHour} endHour={endHour} />
<div className={style.timelineEvents}>
{rundown.map((event) => {
// for now we dont render delays and blocks
if (!isOntimeEvent(event) || !isPlayableEvent(event)) {
return null;
}
// keep track of progress of rundown
if (eventStatus === 'live') {
eventStatus = 'future';
}
if (event.id === selectedEventId) {
eventStatus = 'live';
}
// we only need to check for next day if we have a previous event
if (
previousEventStartTime !== null &&
checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
) {
elapsedDays++;
}
const normalisedStart = event.timeStart + elapsedDays * dayInMs;
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
startHour * MILLIS_PER_HOUR,
endHour * MILLIS_PER_HOUR,
normalisedStart + (event.delay ?? 0),
event.duration,
screenWidth,
);
// prepare values for next iteration
previousEventStartTime = normalisedStart;
return (
<TimelineEntry
key={event.id}
colour={event.colour}
delay={event.delay ?? 0}
duration={event.duration}
left={elementLeftPosition}
status={eventStatus}
start={normalisedStart} // dataset solves issues related to crossing midnight
title={event.title}
width={elementWidth}
/>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,94 @@
import { useTimelineStatus } from '../../../common/hooks/useSocket';
import { alpha, cx } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { getStatusLabel } from './timeline.utils';
import style from './Timeline.module.scss';
export type ProgressStatus = 'done' | 'live' | 'future';
interface TimelineEntryProps {
colour: string;
delay: number;
duration: number;
left: number;
status: ProgressStatus;
start: number;
title: string;
width: number;
}
const formatOptions = {
format12: 'hh:mm a',
format24: 'HH:mm',
};
export function TimelineEntry(props: TimelineEntryProps) {
const { colour, delay, duration, left, status, start, title, width } = props;
const formattedStartTime = formatTime(start, formatOptions);
const formattedDuration = formatDuration(duration);
const delayedStart = start + delay;
const hasDelay = delay > 0;
const lighterColour = alpha(colour, 0.7);
const columnClasses = cx([style.column, width < 40 && style.smallArea]);
const contentClasses = cx([style.content, width < 20 && style.hide]);
const showTitle = width > 25;
return (
<div
className={columnClasses}
style={{
'--color': colour,
'--lighter': lighterColour ?? '',
left: `${left}px`,
width: `${width}px`,
}}
>
<div
className={contentClasses}
data-status={status}
style={{
'--color': colour,
}}
>
<div className={hasDelay ? style.cross : undefined}>{formattedStartTime}</div>
{hasDelay && <div className={style.delay}>{formatTime(delayedStart, formatOptions)}</div>}
{showTitle && <div>{title}</div>}
</div>
<div className={style.timeOverview} data-status={status}>
{status !== 'done' && (
<>
<div className={style.duration}>{formattedDuration}</div>
<TimelineEntryStatus status={status} start={delayedStart} />
</>
)}
</div>
</div>
);
}
interface TimelineEntryStatusProps {
status: ProgressStatus;
start: number;
}
// we isolate this component to avoid isolate re-renders provoked by the clock changes
function TimelineEntryStatus(props: TimelineEntryStatusProps) {
const { status, start } = props;
const { clock, offset } = useTimelineStatus();
const { getLocalizedString } = useTranslation();
// start times need to be normalised in a rundown that crosses midnight
let statusText = getStatusLabel(start - clock + offset, status);
if (statusText === 'live') {
statusText = getLocalizedString('timeline.live');
} else if (statusText === 'pending') {
statusText = getLocalizedString('timeline.due');
}
return <div className={style.status}>{statusText}</div>;
}
@@ -0,0 +1,101 @@
@use '../../../theme/viewerDefs' as *;
.timeline {
width: 100vw;
height: 100vh;
padding-top: 0.5rem;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
display: flex;
flex-direction: column;
gap: 2rem;
.project-header {
padding-inline: 2rem;
font-size: clamp(32px, 4.5vw, 64px);
font-weight: 600;
display: flex;
justify-content: space-between;
}
.clock-container {
.label {
font-size: clamp(16px, 1.5vw, 24px);
font-weight: 600;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.time {
font-size: clamp(32px, 3.5vw, 50px);
font-weight: 600;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
}
}
.title-grid {
display: grid;
grid-template-columns: 2fr 3fr;
row-gap: 1rem;
column-gap: 2rem;
grid-template-areas:
'now next'
'now following';
padding-inline: 2rem;
}
.section {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: 0.5rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
border-radius: $element-border-radius;
}
.section--now {
grid-area: now;
}
.section-title {
line-height: 1em;
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.section-title__label {
text-transform: uppercase;
}
.section-title__status {
color: $green-500;
}
.section-content {
min-height: 2em;
line-height: 1em;
font-size: 3rem;
text-transform: uppercase;
font-weight: 600;
}
.section-content--now {
color: $red-500;
}
.section-content--next {
color: $green-500;
}
.section-content--subdue {
opacity: $opacity-disabled;
}
}
@@ -0,0 +1,93 @@
import { useMemo } from 'react';
import { MaybeString, OntimeEvent, ProjectData, Settings, 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 { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import Section from './timeline-section/TimelineSection';
import Timeline from './Timeline';
import { getTimelineOptions } from './timeline.options';
import { getFormattedTimeToStart, getUpcomingEvents, useScopedRundown } from './timeline.utils';
import './TimelinePage.scss';
interface TimelinePageProps {
backstageEvents: OntimeEvent[];
general: ProjectData;
selectedId: MaybeString;
settings: Settings | undefined;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
}
/**
* since we inherit from viewPage
* which refreshes at least once a second
* There is little point splitting or memoising top level elements
*/
export default function TimelinePage(props: TimelinePageProps) {
const { backstageEvents, general, selectedId, settings, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
// holds copy of the rundown with only relevant events
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(backstageEvents, selectedId);
const { getLocalizedString } = useTranslation();
const clock = formatTime(time.clock);
const { now, next, followedBy } = useMemo(() => {
return getUpcomingEvents(scopedRundown, selectedId);
}, [scopedRundown, selectedId]);
useWindowTitle('Timeline');
if (!shouldRender) {
return null;
}
// populate options
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const progressOptions = getTimelineOptions(defaultFormat);
const titleNow = now?.title ?? '-';
const dueText = getLocalizedString('timeline.due').toUpperCase();
const nextText = next !== null ? next.title : '-';
const followedByText = followedBy !== null ? followedBy.title : '-';
const nextStatus = next !== null ? getFormattedTimeToStart(next, time.clock, dueText) : undefined;
const followedByStatus = followedBy !== null ? getFormattedTimeToStart(followedBy, time.clock, dueText) : undefined;
return (
<div className='timeline'>
<ViewParamsEditor viewOptions={progressOptions} />
<div className='project-header'>
{general.title}
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
<SuperscriptTime time={clock} className='time' />
</div>
</div>
<div className='title-grid'>
<Section title={getLocalizedString('timeline.live')} content={titleNow} category='now' />
<Section title={getLocalizedString('common.next')} status={nextStatus} content={nextText} category='next' />
<Section
title={getLocalizedString('timeline.followedby')}
status={followedByStatus}
content={followedByText}
category='next'
/>
</div>
<Timeline
firstStart={firstStart}
rundown={scopedRundown}
selectedEventId={selectedId}
totalDuration={totalDuration}
/>
</div>
);
}
@@ -0,0 +1,66 @@
import { dayInMs } from 'ontime-utils';
import { getElementPosition, makeTimelineSections } from '../timeline.utils';
describe('getCSSPosition()', () => {
it('accounts for rundown with one event', () => {
const scheduleStart = 0;
const scheduleEnd = dayInMs;
const eventStart = 0;
const eventDuration = dayInMs;
const containerWidth = 100;
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(0);
expect(result.width).toBe(containerWidth);
});
it('accounts for an event that starts halfway and ends at end', () => {
const scheduleStart = 0;
const scheduleEnd = 100;
const eventStart = 50;
const eventDuration = 50;
const containerWidth = 100;
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(50);
expect(result.width).toBe(50);
});
it('accounts for an event that starts first and ends halfway', () => {
const scheduleStart = 0;
const scheduleEnd = 100;
const eventStart = 0;
const eventDuration = 50;
const containerWidth = 100;
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(0);
expect(result.width).toBe(50);
});
it('accounts for an event that is in the middle of the rundown', () => {
const scheduleStart = 7;
const scheduleEnd = 23;
const eventStart = 10;
const eventDuration = 1;
const containerWidth = 1000;
// 16 hour event, this gives 62.5px per hour
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(187.5); // 3 * 62.5
expect(result.width).toBe(62.5);
});
});
describe('makeTmelineSections', () => {
it('creates an array between the hours given, end excluded', () => {
const result = makeTimelineSections(11, 17);
expect(result).toEqual(['11:00', '12:00', '13:00', '14:00', '15:00', '16:00']);
});
it('wraps around midnight', () => {
const result = makeTimelineSections(22, 26);
expect(result).toEqual(['22:00', '23:00', '00:00', '01:00']);
});
});
@@ -0,0 +1,16 @@
.markers {
width: 100%;
color: $ui-white;
display: flex;
height: 1rem;
line-height: 1rem;
margin-bottom: 0.25rem;
font-size: calc(1rem - 2px);
justify-content: space-evenly;
& > span {
flex-grow: 1;
border-left: 1px solid $white-7;
height: 100vh;
}
}
@@ -0,0 +1,22 @@
import { makeTimelineSections } from '../timeline.utils';
import style from './TimelineMarkers.module.scss';
interface TimelineMarkersProps {
startHour: number;
endHour: number;
}
export default function TimelineMarkers(props: TimelineMarkersProps) {
const { startHour, endHour } = props;
const elements = makeTimelineSections(startHour, endHour);
return (
<div className={style.markers}>
{elements.map((tag, index) => {
return <span key={`${index}-${tag}`}>{tag}</span>;
})}
</div>
);
}
@@ -0,0 +1,19 @@
.progressBar {
width: 100%;
height: 1rem;
position: relative;
background-color: $gray-1000;
}
.progress {
height: 100%;
position: absolute;
left: 0;
top: 0;
z-index: 2;
background-color: $active-red;
transition-duration: 0.3s;
transition-property: width;
}
@@ -0,0 +1,25 @@
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { useClock } from '../../../../common/hooks/useSocket';
import { getRelativePositionX } from '../timeline.utils';
import style from './TimelineProgressBar.module.scss';
interface ProgressBarProps {
startHour: number;
endHour: number;
}
export default function ProgressBar(props: ProgressBarProps) {
const { startHour, endHour } = props;
// TODO: how to account for days?
const { clock } = useClock();
const width = getRelativePositionX(startHour * MILLIS_PER_HOUR, endHour * MILLIS_PER_HOUR, clock);
return (
<div className={style.progressBar}>
<div className={style.progress} style={{ width: `${width}%` }} />
</div>
);
}
@@ -0,0 +1,29 @@
import { memo } from 'react';
import { MaybeString } from 'ontime-types';
import { cx } from '../../../../common/utils/styleUtils';
interface SectionProps {
category: 'now' | 'next';
content: MaybeString;
title: string;
status?: string;
}
export default memo(Section);
export function Section(props: SectionProps) {
const { category, content, title, status } = props;
const sectionClasses = cx(['section', category === 'now' && 'section--now']);
const contentClasses = cx(['section-content', content ? `section-content--${category}` : 'section-content--subdue']);
return (
<div className={sectionClasses}>
<div className='section-title'>
<span className='section-title__label'>{title}</span>
{status && <span className='section-title__status'>{status}</span>}
</div>
<div className={contentClasses}>{content ?? '-'}</div>
</div>
);
}
@@ -0,0 +1,22 @@
import { getTimeOption } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
return [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide Past Events',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideBackstage',
title: 'Hide Private Events',
description: 'Whether to hide non-public events',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -0,0 +1,211 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isOntimeEvent, isPlayableEvent, MaybeString, OntimeEvent, OntimeRundown, PlayableEvent } from 'ontime-types';
import {
dayInMs,
getEventWithId,
getFirstEvent,
getNextEvent,
getTimeFromPrevious,
isNewLatest,
MILLIS_PER_HOUR,
millisToString,
removeSeconds,
} from 'ontime-utils';
import { clamp } from '../../../common/utils/math';
import { formatDuration } from '../../../common/utils/time';
import { isStringBoolean } from '../common/viewUtils';
import type { ProgressStatus } from './TimelineEntry';
type CSSPosition = {
left: number;
width: number;
};
/**
* Calculates the position (in %) of an element relative to a schedule
*/
export function getRelativePositionX(scheduleStart: number, scheduleEnd: number, now: number): number {
return clamp(((now - scheduleStart) / (scheduleEnd - scheduleStart)) * 100, 0, 100);
}
/**
* Calculates an absolute position of an element based on a schedule
*/
export function getElementPosition(
scheduleStart: number,
scheduleEnd: number,
eventStart: number,
eventDuration: number,
containerWidth: number,
): CSSPosition {
const normalEnd = scheduleEnd < scheduleStart ? scheduleEnd + dayInMs : scheduleEnd;
const totalDuration = normalEnd - scheduleStart;
const width = (eventDuration * containerWidth) / totalDuration;
const left = ((eventStart - scheduleStart) * containerWidth) / totalDuration;
return { left, width };
}
/**
* Gets rounded down hour for a given time
*/
export function getStartHour(startTime: number): number {
const hours = Math.floor(startTime / MILLIS_PER_HOUR);
return hours;
}
/**
* Gets rounded up hour for a given time
*/
export function getEndHour(endTime: number): number {
const hours = Math.ceil(endTime / MILLIS_PER_HOUR);
return hours;
}
/**
* converts a time span into an array of hours
*/
export function makeTimelineSections(firstHour: number, lastHour: number) {
const timelineSections = [];
for (let i = firstHour; i < lastHour; i++) {
timelineSections.push(removeSeconds(millisToString((i % 24) * MILLIS_PER_HOUR)));
}
return timelineSections;
}
/**
* Returns a formatted label for a progress status
*/
export function getStatusLabel(timeToStart: number, status: ProgressStatus): string {
if (status === 'done' || status === 'live') {
return status;
}
if (timeToStart < 0) {
return 'pending';
}
return formatDuration(timeToStart);
}
interface ScopedRundownData {
scopedRundown: PlayableEvent[];
firstStart: number;
totalDuration: number;
}
export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeString): ScopedRundownData {
const [searchParams] = useSearchParams();
const data = useMemo(() => {
if (rundown.length === 0) {
return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
}
const hideBackstage = isStringBoolean(searchParams.get('hideBackstage'));
const hidePast = isStringBoolean(searchParams.get('hidePast'));
const scopedRundown: PlayableEvent[] = [];
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart = null;
let totalDuration = 0;
let lastEntry: PlayableEvent | null = null;
for (let i = 0; i < rundown.length; i++) {
const currentEntry = rundown[i];
// we only deal with playableEvents
if (isOntimeEvent(currentEntry) && isPlayableEvent(currentEntry)) {
if (currentEntry.id === selectedEventId) {
selectedIndex = i;
}
// maybe filter past
if (hidePast && i < selectedIndex) {
continue;
}
// maybe filter backstage
if (!currentEntry.isPublic && hideBackstage) {
continue;
}
// add to scopedRundown
scopedRundown.push(currentEntry);
/**
* Derive timers
* This logic is partially from rundownCache.generate
* With the addition of deriving the current day offset
*/
if (firstStart === null) {
firstStart = currentEntry.timeStart;
}
const timeFromPrevious: number = getTimeFromPrevious(
currentEntry.timeStart,
lastEntry?.timeStart,
lastEntry?.timeEnd,
lastEntry?.duration,
);
if (timeFromPrevious === 0) {
totalDuration += currentEntry.duration;
} else if (timeFromPrevious > 0) {
totalDuration += timeFromPrevious + currentEntry.duration;
} else if (timeFromPrevious < 0) {
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
}
if (isNewLatest(currentEntry.timeStart, currentEntry.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
lastEntry = currentEntry;
}
}
}
return { scopedRundown, firstStart: firstStart ?? 0, totalDuration };
}, [rundown, searchParams, selectedEventId]);
return data;
}
type UpcomingEvents = {
now: OntimeEvent | null;
next: OntimeEvent | null;
followedBy: OntimeEvent | null;
};
/**
* Returns upcoming events from current: now, next and followedBy
*/
export function getUpcomingEvents(events: OntimeRundown, selectedId: MaybeString): UpcomingEvents {
if (events.length === 0) {
return { now: null, next: null, followedBy: null };
}
let now = selectedId ? getEventWithId(events, selectedId) : null;
if (!isOntimeEvent(now)) {
now = null;
}
const next = now ? getNextEvent(events, now.id)?.nextEvent : getFirstEvent(events).firstEvent;
const followedBy = next ? getNextEvent(events, next.id)?.nextEvent : null;
// Return the titles, handling nulls appropriately
return {
now,
next,
followedBy,
};
}
export function getFormattedTimeToStart(event: OntimeEvent, now: number, dueText: string): string {
const timeToStart = event.timeStart - now;
if (timeToStart < 0) {
return dueText;
}
return `T - ${formatDuration(timeToStart)}`;
}
@@ -129,20 +129,22 @@
}
}
.external {
.secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 0.25em;
padding-block: 0.25em;
font-weight: 600;
text-align: center;
color: var(--external-color-override, $external-color);
letter-spacing: 0.5px;
line-height: 0.9em;
padding-bottom: 0.2em;
transition-property: opacity, height;
transition-duration: $viewer-transition-time;
border-top: 1px solid rgba(white, 0.1);
border-top: 1px solid color-mix(in srgb, var(--external-color-override, $external-color) 10%, transparent);
&--hidden {
opacity: 0;
@@ -167,36 +169,28 @@
.message-overlay {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: $viewer-overlay-bg-color;
z-index: -1;
padding: 2vw;
background: var(--background-color-override, $viewer-background-color);
opacity: 0;
transition: $viewer-transition-time;
transition: opacity $viewer-transition-time;
z-index: 2;
&--active {
opacity: 1;
transition: $viewer-transition-time;
transition-property: opacity;
z-index: 2;
}
}
.message {
width: inherit;
padding: 2vw;
position: absolute;
top: 50%;
left: 50%;
color: $viewer-color;
transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
font-size: 15vw;
line-height: 30vh;
display: grid;
place-content: center;
height: 100%;
width: 100%;
color: var(--color-override, $viewer-color);
text-align: center;
font-weight: 600;
}
@@ -2,17 +2,18 @@ import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import {
CustomFields,
Message,
MessageState,
OntimeEvent,
Playback,
Settings,
TimerMessage,
SimpleTimerState,
TimerPhase,
TimerType,
ViewSettings,
} from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants';
import { FitText } from '../../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import TitleCard from '../../../common/components/title-card/TitleCard';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
@@ -47,19 +48,19 @@ const titleVariants = {
export const MotionTitleCard = motion(TitleCard);
interface TimerProps {
auxTimer: SimpleTimerState;
customFields: CustomFields;
eventNext: OntimeEvent | null;
eventNow: OntimeEvent | null;
external: Message;
isMirrored: boolean;
pres: TimerMessage;
message: MessageState;
settings: Settings | undefined;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
}
export default function Timer(props: TimerProps) {
const { customFields, isMirrored, pres, eventNow, eventNext, time, viewSettings, external, settings } = props;
const { auxTimer, customFields, eventNow, eventNext, isMirrored, message, settings, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
@@ -113,7 +114,7 @@ export default function Timer(props: TimerProps) {
const mainFieldNow = (main ? getPropertyValue(eventNow, main) : eventNow?.title) ?? '';
const mainFieldNext = (main ? getPropertyValue(eventNext, main) : eventNext?.title) ?? '';
const showOverlay = pres.text !== '' && pres.visible;
const showOverlay = message.timer.text !== '' && message.timer.visible;
const isPlaying = time.playback !== Playback.Pause;
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
@@ -127,10 +128,20 @@ export default function Timer(props: TimerProps) {
const showFinished = finished && (shouldShowModifiers || showEndMessage);
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
const showBlinking = pres.blink;
const showBlackout = pres.blackout;
const showClock = time.timerType !== TimerType.Clock;
const showExternal = external.visible && external.text;
const secondaryContent = ((): string | undefined => {
if (message.timer.secondarySource === 'aux') {
return getFormattedTimer(auxTimer.current, TimerType.CountDown, getLocalizedString('common.minutes'), {
removeSeconds: userOptions.hideTimerSeconds,
removeLeadingZero: userOptions.removeLeadingZeros,
});
}
if (message.timer.secondarySource === 'external' && message.external) {
return message.external;
}
return;
})();
let timerColor = viewSettings.normalColor;
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
@@ -145,13 +156,14 @@ export default function Timer(props: TimerProps) {
const stageTimerCharacters = display.replace('/:/g', '').length;
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`;
let timerFontSize = 89 / (stageTimerCharacters - 1);
// we need to shrink the timer if the external is going to be there
if (showExternal) {
if (secondaryContent) {
timerFontSize *= 0.8;
}
const externalFontSize = timerFontSize * 0.4;
const timerContainerClasses = `timer-container ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`;
const timerContainerClasses = `timer-container ${message.timer.blink ? (showOverlay ? '' : 'blink') : ''}`;
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
const defaultFormat = getDefaultFormat(settings?.timeFormat);
@@ -160,10 +172,12 @@ export default function Timer(props: TimerProps) {
return (
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
<ViewParamsEditor viewOptions={timerOptions} />
<div className={showBlackout ? 'blackout blackout--active' : 'blackout'} />
<div className={message.timer.blackout ? 'blackout blackout--active' : 'blackout'} />
{!userOptions.hideMessage && (
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
<FitText mode='multi' min={32} max={256} className={`message ${message.timer.blink ? 'blink' : ''}`}>
{message.timer.text}
</FitText>
</div>
)}
@@ -189,10 +203,10 @@ export default function Timer(props: TimerProps) {
</div>
)}
<div
className={`external${showExternal ? '' : ' external--hidden'}`}
className={`secondary${secondaryContent ? '' : ' secondary--hidden'}`}
style={{ fontSize: `${externalFontSize}vw` }}
>
{external.text}
{secondaryContent}
</div>
</div>
-38
View File
@@ -1,51 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react';
import App from './App';
import { ONTIME_VERSION } from './ONTIME_VERSION';
import './index.scss';
const container = document.getElementById('root');
const root = createRoot(container as Element);
// https://docs.sentry.io/platforms/javascript/configuration/filtering/#decluttering-sentry
const sentryRecommendedIgnore = [
// Random plugins/extensions
'top.GLOBALS',
// See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error.html
'originalCreateNotification',
'canvas.contentDocument',
'MyApp_RemoveAllHighlights',
'http://tt.epicplay.com',
"Can't find variable: ZiteReader",
'jigsaw is not defined',
'ComboSearch is not defined',
'http://loading.retry.widdit.com/',
'atomicFindClose',
// Facebook borked
'fb_xd_fragment',
// ISP "optimizing" proxy - `Cache-Control: no-transform` seems to
// reduce this. (thanks @acdha)
// See http://stackoverflow.com/questions/4113268
'bmi_SafeAddOnload',
'EBCallBackMessageReceived',
// See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
'conduitPage',
];
Sentry.init({
dsn: 'https://5e4d2c4b57ab409cb98d4c08b2014755@o4504288369836032.ingest.sentry.io/4504288371343360',
integrations: [new Sentry.BrowserTracing()],
tracesSampleRate: 0.3,
release: ONTIME_VERSION,
enabled: import.meta.env.PROD,
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
});
root.render(
<StrictMode>
<App />
+2 -2
View File
@@ -2,5 +2,5 @@ export const tooltipDelaySlow = 1000;
export const tooltipDelayMid = 500;
export const tooltipDelayFast = 300;
export const queryRefetchInterval = 10000;
export const queryRefetchIntervalSlow = 30000;
export const queryRefetchInterval = 10000; // 10 seconds
export const queryRefetchIntervalSlow = 30000; // 30 seconds

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