mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86e6f8b58c | |||
| 1de062a38e | |||
| eb7a30fcf3 | |||
| e2cbe43a2b | |||
| 7877af571f | |||
| 086bd5b644 | |||
| f045cf2292 | |||
| 78e2f217a4 | |||
| 8a5cef79a8 | |||
| 4d8bea2940 | |||
| fa709dc9be | |||
| 177c9a35ec | |||
| 7f7691a452 | |||
| 92a16ea33b | |||
| bc6b7c5596 | |||
| 66083813f9 | |||
| d8cf1abb37 | |||
| 5b471980f5 | |||
| ad8f6dfcc7 | |||
| 4706ed39f6 | |||
| a8182cac8c | |||
| f9a5c55c07 | |||
| 980ae7a7c5 | |||
| db8ed93d41 | |||
| f8ca5b9cef | |||
| b9db08089f | |||
| a3020ef3b2 | |||
| 2492972097 | |||
| aa4ee546ec | |||
| 0abaf7724d | |||
| 7b932ef0f7 | |||
| 8bd7645c95 | |||
| 81f0e61953 | |||
| 3877e207d0 | |||
| d78e8838c3 | |||
| 16c7107069 | |||
| e20cd87673 | |||
| db5fbd8019 | |||
| 25aefe322b | |||
| 73db14c383 | |||
| f5936e5254 | |||
| 25c7915cf7 | |||
| c43b6f37f9 | |||
| 6e5034ef72 | |||
| 66774321a5 | |||
| d71df886f2 | |||
| abcee741e2 | |||
| ee1b5b7fdd | |||
| 1b1823e0fe | |||
| 340f5478fb | |||
| f708dc0bc4 | |||
| 49467c66b7 | |||
| b539b2ecd5 | |||
| dc06a9afc3 | |||
| d3620b52f4 | |||
| 66ed92e215 | |||
| 347f333b3e | |||
| c3fb3061f6 | |||
| a26aa9e01c | |||
| da11ef64c2 | |||
| 3012d73285 | |||
| bad491063d | |||
| a103f8b347 | |||
| dffccd258f | |||
| ce8b0d9850 | |||
| e500fa6b5e | |||
| 67be4a5631 | |||
| c7893938eb | |||
| 158ef05ff0 | |||
| 20838c038a | |||
| 1ddcec993a | |||
| 91d6adf8e0 | |||
| a7fe5eceef | |||
| e6e00c0d4a | |||
| edc7f20d7a | |||
| cb773ded9f | |||
| 2f65711078 | |||
| 3056b75960 | |||
| dd9423abf4 | |||
| dee6a67018 | |||
| a79fb4ecbb | |||
| a53a1f87be | |||
| ef9afcbf3e | |||
| 33b04e01c9 |
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm lint-staged
|
||||
@@ -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
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
|
||||
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
|
||||
|
||||
## Using Ontime?
|
||||
## Need help?
|
||||
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
|
||||
|
||||
## Using Ontime?
|
||||
Let us know!
|
||||
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
|
||||
|
||||
@@ -35,6 +37,16 @@ Ontime is made by entertainment and broadcast engineers and used by
|
||||
- Theatres and opera houses
|
||||
- Houses of worship
|
||||
|
||||
## Main features
|
||||
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
|
||||
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
|
||||
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
|
||||
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
|
||||
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
|
||||
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
|
||||
|
||||
... and a lot more ...
|
||||
|
||||
### For live environments
|
||||
|
||||
Ontime is designed for use in live environments. \
|
||||
@@ -83,6 +95,7 @@ IP.ADDRESS:4001/clock > Simple clock view
|
||||
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
|
||||
IP.ADDRESS:4001/countdown > Countdown to anything
|
||||
IP.ADDRESS:4001/studio > Studio Clock
|
||||
IP.ADDRESS:4001/timeline > Timeline
|
||||
```
|
||||
|
||||
```
|
||||
@@ -100,26 +113,13 @@ IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
||||
IP.ADDRESS:4001/operator > automated views for operators
|
||||
```
|
||||
|
||||
More documentation is available [in our docs](https://docs.getontime.no)
|
||||
|
||||
## Main features
|
||||
|
||||
- [x] Distribute data over network and render it in the browser
|
||||
- [x] Collaborative
|
||||
- [x] Extendable
|
||||
- [x] Send messages to different screen types
|
||||
- [x] Differentiate between backstage and public data
|
||||
- [x] Workflow for managing delays
|
||||
- [x] Rich protocol integrations for Control and Feedback
|
||||
- [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
|
||||
- [x] Multi-platform (available on Windows, MacOS and Linux)
|
||||
- [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
|
||||
More information is available [in our docs](https://docs.getontime.no)
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Continued development
|
||||
|
||||
Ontime is under active development. We continue adding and tweaking features in collaboration with users.
|
||||
Ontime is under active development. We continue adding and improving features in collaboration with users.
|
||||
|
||||
Have an idea? Reach out via [email](mail@getontime.no)
|
||||
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||
@@ -161,8 +161,9 @@ If you are a developer and would like to contribute with code, please open an is
|
||||
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
|
||||
|
||||
## Links
|
||||
|
||||
See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no)
|
||||
- [Ontime website](https://getontime.no)
|
||||
- [Documentation](https://docs.getontime.no)
|
||||
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+36
-15
@@ -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>
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
|
||||
@@ -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}%` }} />;
|
||||
}
|
||||
|
||||
-27
@@ -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']);
|
||||
});
|
||||
});
|
||||
+16
@@ -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>
|
||||
);
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
+25
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user