mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-03 06:28:01 +00:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 079be74c16 | |||
| c3db240148 | |||
| f2d7f4ab82 | |||
| a5496434f8 | |||
| 42984eed87 | |||
| 59c1c77d48 | |||
| dd13eb31d8 | |||
| 82810f1cb1 | |||
| 71c468d069 | |||
| cf40001a2a | |||
| d458928090 | |||
| 76e12af116 | |||
| b577057768 | |||
| dd4a165b08 | |||
| ab847a324b | |||
| cc5c097ddc | |||
| ea2330e23b | |||
| c5870452e3 | |||
| b73b529c00 | |||
| 8ef807d6cb | |||
| 1d4ddea596 | |||
| 4cba970cda | |||
| bad9dab0a0 | |||
| f11218a757 | |||
| 77e87e1d0c | |||
| a4aa513d1f | |||
| b73448f827 | |||
| 687ad68726 | |||
| 089eba3877 | |||
| 24b0cfd24e | |||
| 5950551da2 | |||
| e46948772e | |||
| 78fa1df99c | |||
| 0500368982 | |||
| 2d53323f60 | |||
| 08ca3cd3a5 | |||
| 16fd44a441 | |||
| 9a6ca7ac67 | |||
| 2c63bbff76 | |||
| d2c34757f9 | |||
| aeadc78578 | |||
| bf98f9792a | |||
| 6d2b59ef73 | |||
| 0a53018930 | |||
| f1cac8d1c4 | |||
| cfb76153a0 | |||
| fee2021ad7 | |||
| db956f7955 | |||
| 887e5c448e | |||
| 6ce275da7a | |||
| 213f516f71 | |||
| 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 |
@@ -40,9 +40,6 @@ dist/
|
||||
ontime-db
|
||||
ontime-external/
|
||||
|
||||
# working database
|
||||
apps/server/src/preloaded-db/db.json
|
||||
|
||||
# versioning file
|
||||
**/ONTIME_VERSION.js
|
||||
|
||||
|
||||
+2
-1
@@ -23,6 +23,7 @@ COPY --from=builder /app/apps/client/build ./client/
|
||||
# Prepare Backend
|
||||
COPY --from=builder /app/apps/server/dist/ ./server/
|
||||
COPY --from=builder /app/apps/server/src/external/ ./external/
|
||||
COPY --from=builder /app/apps/server/src/user/ ./user/
|
||||
|
||||
# Export default ports
|
||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||
@@ -32,4 +33,4 @@ CMD ["node", "server/docker.cjs"]
|
||||
# Build and run commands
|
||||
# !!! Note that this command needs pre-build versions of the UI and server apps
|
||||
# docker buildx build . -t getontime/ontime
|
||||
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/external/db/ -v ./ontime-styles:/external/styles/ getontime/ontime
|
||||
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
|
||||
|
||||
@@ -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.5.0",
|
||||
"version": "3.6.2-beta1",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.5.0",
|
||||
"version": "3.6.2-beta1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useClientPath } from './common/hooks/useClientPath';
|
||||
import Log from './features/log/Log';
|
||||
import withPreset from './features/PresetWrapper';
|
||||
import withData from './features/viewers/ViewWrapper';
|
||||
import ViewLoader from './views/ViewLoader';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION';
|
||||
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
|
||||
|
||||
@@ -27,7 +28,7 @@ const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
||||
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
||||
|
||||
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
|
||||
const Timeline = React.lazy(() => import('./features/viewers/timeline/TimelinePage'));
|
||||
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
||||
const Public = React.lazy(() => import('./features/viewers/public/Public'));
|
||||
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||
@@ -75,16 +76,72 @@ export default function AppRouter() {
|
||||
<React.Suspense fallback={null}>
|
||||
<SentryRoutes>
|
||||
<Route path='/' element={<Navigate to='/timer' />} />
|
||||
<Route path='/timer' element={<STimer />} />
|
||||
<Route path='/public' element={<SPublic />} />
|
||||
<Route path='/minimal' element={<SMinimalTimer />} />
|
||||
<Route path='/clock' element={<SClock />} />
|
||||
|
||||
<Route path='/countdown' element={<SCountdown />} />
|
||||
<Route path='/backstage' element={<SBackstage />} />
|
||||
<Route path='/studio' element={<SStudio />} />
|
||||
<Route
|
||||
path='/timer'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<STimer />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/public'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SPublic />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/minimal'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SMinimalTimer />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/clock'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SClock />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/countdown'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SCountdown />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/backstage'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SBackstage />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/studio'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SStudio />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
{/*/!* Lower third cannot have a loading screen *!/*/}
|
||||
<Route path='/lower' element={<SLowerThird />} />
|
||||
<Route path='/timeline' element={<STimeline />} />
|
||||
<Route
|
||||
path='/timeline'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<STimeline />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
|
||||
@@ -18,6 +18,7 @@ const location = window.location;
|
||||
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
export const isProduction = import.meta.env.MODE === 'production';
|
||||
export const isDev = !isProduction;
|
||||
export const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
||||
|
||||
// resolve port
|
||||
const STATIC_PORT = 4001;
|
||||
@@ -31,5 +32,6 @@ export const projectDataURL = `${serverURL}/project`;
|
||||
export const rundownURL = `${serverURL}/events`;
|
||||
export const ontimeURL = `${serverURL}/ontime`;
|
||||
|
||||
export const stylesPath = 'external/styles/override.css';
|
||||
export const overrideStylesURL = `${serverURL}/${stylesPath}`;
|
||||
export const userAssetsPath = 'user';
|
||||
export const cssOverridePath = 'styles/override.css';
|
||||
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
|
||||
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
|
||||
|
||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||
|
||||
@@ -138,14 +138,6 @@ export async function deleteProject(filename: string): Promise<MessageResponse>
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve application info
|
||||
*/
|
||||
export async function getInfo(): Promise<GetInfo> {
|
||||
const res = await axios.get(`${dbPath}/info`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function gets project from db
|
||||
* @param fileName
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import axios from 'axios';
|
||||
import { GetInfo } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const sessionPath = `${apiEntryUrl}/session`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve application info
|
||||
*/
|
||||
export async function getInfo(): Promise<GetInfo> {
|
||||
const res = await axios.get(`${sessionPath}/info`);
|
||||
return res.data;
|
||||
}
|
||||
@@ -8,5 +8,5 @@
|
||||
.linkIcon {
|
||||
margin-left: $element-inner-spacing;
|
||||
display: inline-block;
|
||||
transform: rotate(45deg);
|
||||
@include rotate-fourty-five;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
@@ -7,26 +8,34 @@ import { Size } from '../../models/Util.type';
|
||||
import copyToClipboard from '../../utils/copyToClipboard';
|
||||
|
||||
interface CopyTagProps {
|
||||
copyValue: string;
|
||||
label: string;
|
||||
className?: string;
|
||||
size?: Size;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
|
||||
const { label, className, size = 'xs', disabled, children } = props;
|
||||
const { copyValue, label, size = 'xs', disabled, children, onClick } = props;
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleClick = () => copyToClipboard(children as string);
|
||||
const handleClick = () => {
|
||||
copyToClipboard(copyValue);
|
||||
setCopied(true);
|
||||
|
||||
// reset copied state
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip label={label} openDelay={tooltipDelayFast}>
|
||||
<ButtonGroup size={size} isAttached className={className}>
|
||||
<Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
|
||||
<ButtonGroup size={size} isAttached>
|
||||
<Button variant='ontime-subtle' tabIndex={-1} onClick={onClick} isDisabled={disabled}>
|
||||
{children}
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label={label}
|
||||
icon={<IoCopy />}
|
||||
icon={copied ? <IoCheckmark /> : <IoCopy />}
|
||||
variant='ontime-filled'
|
||||
tabIndex={-1}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -47,6 +47,8 @@ $button-size: 3rem;
|
||||
@include action-link;
|
||||
padding: 0.75rem 1.5rem;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: $menu-hover-bg;
|
||||
@@ -70,9 +72,33 @@ $button-size: 3rem;
|
||||
|
||||
.linkIcon {
|
||||
margin-left: auto;
|
||||
transform: rotate(45deg);
|
||||
@include rotate-fourty-five;
|
||||
}
|
||||
|
||||
.separator {
|
||||
border-color: $border-color-ondark;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
font-size: calc(1rem - 2px);
|
||||
margin-left: 1rem;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
margin-top: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.interfaces {
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.goIcon {
|
||||
@include rotate-fourty-five;
|
||||
margin-left: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, useRef } from 'react';
|
||||
import { memo, PropsWithChildren, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
@@ -18,11 +18,17 @@ import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutl
|
||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import { isLocalhost, serverPort } from '../../api/constants';
|
||||
import useClickOutside from '../../hooks/useClickOutside';
|
||||
import useElectronEvent from '../../hooks/useElectronEvent';
|
||||
import useInfo from '../../hooks-query/useInfo';
|
||||
import { useClientStore } from '../../stores/clientStore';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
import { isKeyEnter } from '../../utils/keyEvent';
|
||||
import { handleLinks, openLink } from '../../utils/linkUtils';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||
import CopyTag from '../copy-tag/CopyTag';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
@@ -40,6 +46,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { toggleMirror } = useViewOptionsStore();
|
||||
const location = useLocation();
|
||||
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -50,7 +57,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerContent maxWidth='21rem'>
|
||||
<DrawerHeader>
|
||||
<DrawerCloseButton size='lg' />
|
||||
Ontime
|
||||
@@ -96,39 +103,27 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
<hr className={style.separator} />
|
||||
<Link
|
||||
to='/editor'
|
||||
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
|
||||
tabIndex={0}
|
||||
className={`${style.link} ${location.pathname === '/editor' && style.current}`}
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Editor
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<Link
|
||||
to='/cuesheet'
|
||||
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
|
||||
<IoLockClosedOutline />
|
||||
Cuesheet
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
|
||||
</ClientLink>
|
||||
<ClientLink to='op' current={location.pathname === '/op'}>
|
||||
<IoLockClosedOutline />
|
||||
Operator
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
</ClientLink>
|
||||
<hr className={style.separator} />
|
||||
{navigatorConstants.map((route) => (
|
||||
<Link
|
||||
key={route.url}
|
||||
to={route.url}
|
||||
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
|
||||
{route.label}
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
</ClientLink>
|
||||
))}
|
||||
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -137,4 +132,70 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface OtherAddressesProps {
|
||||
currentLocation: string;
|
||||
}
|
||||
|
||||
function OtherAddresses(props: OtherAddressesProps) {
|
||||
const { currentLocation } = props;
|
||||
const { data } = useInfo();
|
||||
|
||||
// there is no point showing this if we only have one interface
|
||||
if (data.networkInterfaces.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.bottom}>
|
||||
<div className={style.sectionHeader}>Accessible on external networks</div>
|
||||
<div className={style.interfaces}>
|
||||
{data?.networkInterfaces?.map((nif) => {
|
||||
if (nif.name === 'localhost') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const address = `http://${nif.address}:${serverPort}${currentLocation}`;
|
||||
return (
|
||||
<CopyTag
|
||||
key={nif.name}
|
||||
copyValue={address}
|
||||
onClick={() => openLink(address)}
|
||||
label='Copy IP or navigate to address'
|
||||
>
|
||||
{nif.address} <IoArrowUp className={style.goIcon} />
|
||||
</CopyTag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ClientLinkProps {
|
||||
current: boolean;
|
||||
to: string;
|
||||
}
|
||||
|
||||
function ClientLink(props: PropsWithChildren<ClientLinkProps>) {
|
||||
const { current, to, children } = props;
|
||||
const { isElectron } = useElectronEvent();
|
||||
|
||||
const classes = cx([style.link, current && style.current]);
|
||||
|
||||
if (isElectron) {
|
||||
return (
|
||||
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
|
||||
{children}
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={`/${to}`} className={classes} tabIndex={0}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(NavigationMenu);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
// attempt to match with ontimeTextInputs
|
||||
.input {
|
||||
color: $gray-200;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background-color: $gray-1200;
|
||||
padding: 0 1rem;
|
||||
font-size: 1rem;
|
||||
height: 2.5rem;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-1100;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: $gray-1000;
|
||||
color: $gray-50;
|
||||
border: 1px solid $blue-500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import PopoverPicker from '../input/popover-picker/PopoverPicker';
|
||||
|
||||
import style from './InlineColourPicker.module.scss';
|
||||
|
||||
interface InlineColourPickerProps {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const ensureHex = (value: string) => {
|
||||
if (!value.startsWith('#')) {
|
||||
return `#${value}`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export default function InlineColourPicker(props: InlineColourPickerProps) {
|
||||
const { name, value } = props;
|
||||
const [colour, setColour] = useState(() => ensureHex(value));
|
||||
|
||||
const debouncedChange = (value: string) => {
|
||||
setColour(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.inline}>
|
||||
<PopoverPicker color={colour} onChange={debouncedChange} />
|
||||
<input type='hidden' name={name} value={colour} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
import InlineColourPicker from './InlineColourPicker';
|
||||
import { ParamField } from './types';
|
||||
|
||||
interface EditFormInputProps {
|
||||
@@ -23,8 +24,8 @@ interface EditFormInputProps {
|
||||
}
|
||||
|
||||
export default function ParamInput(props: EditFormInputProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { paramField } = props;
|
||||
const [searchParams] = useSearchParams();
|
||||
const { id, type, defaultValue } = paramField;
|
||||
|
||||
if (type === 'persist') {
|
||||
@@ -81,6 +82,12 @@ export default function ParamInput(props: EditFormInputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'colour') {
|
||||
const currentvalue = `#${searchParams.get(id) ?? defaultValue}`;
|
||||
|
||||
return <InlineColourPicker name={id} value={currentvalue} />;
|
||||
}
|
||||
|
||||
const defaultStringValue = searchParams.get(id) ?? defaultValue;
|
||||
const { prefix, placeholder } = paramField;
|
||||
|
||||
|
||||
@@ -19,6 +19,16 @@ import style from './ViewParamsEditor.module.scss';
|
||||
|
||||
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||
|
||||
/**
|
||||
* Utility remove the # character from a hex string
|
||||
*/
|
||||
function sanitiseColour(colour: string) {
|
||||
if (colour.startsWith('#')) {
|
||||
return colour.substring(1);
|
||||
}
|
||||
return colour;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a new URLSearchParams object from the given params object
|
||||
*/
|
||||
@@ -40,8 +50,13 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
|
||||
|
||||
// compare which values are different from the default values
|
||||
Object.entries(paramsObj).forEach(([id, value]) => {
|
||||
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
|
||||
newSearchParams.set(id, value);
|
||||
if (typeof value === 'string' && value.length) {
|
||||
// we dont know which values contain colours
|
||||
// unfortunately this means we run all the strings through the sanitation
|
||||
const valueWithoutHash = sanitiseColour(value);
|
||||
if (defaultValues[id] !== valueWithoutHash) {
|
||||
newSearchParams.set(id, valueWithoutHash);
|
||||
}
|
||||
}
|
||||
});
|
||||
return newSearchParams;
|
||||
|
||||
@@ -23,10 +23,11 @@ type MultiOptionsField = {
|
||||
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
|
||||
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
|
||||
type BooleanField = { type: 'boolean'; defaultValue: boolean };
|
||||
type ColourField = { type: 'colour'; defaultValue: string; placeholder?: string };
|
||||
type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
|
||||
|
||||
export type ParamField = BaseField &
|
||||
(StringField | BooleanField | NumberField | OptionsField | MultiOptionsField | PersistedField);
|
||||
(OptionsField | MultiOptionsField | StringField | NumberField | BooleanField | ColourField | PersistedField);
|
||||
export type ViewOption = ParamSection | ParamField;
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@ import { GetInfo } from 'ontime-types';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { APP_INFO } from '../api/constants';
|
||||
import { getInfo } from '../api/db';
|
||||
import { getInfo } from '../api/session';
|
||||
import { ontimePlaceholderInfo } from '../models/Info';
|
||||
|
||||
export default function useInfo() {
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
export default function useElectronEvent() {
|
||||
const isElectron = window?.process?.type === 'renderer';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const sendToElectron = (channel: string, args?: string | Record<string, unknown>) => {
|
||||
if (isElectron) {
|
||||
window?.ipcRenderer.send(channel, args);
|
||||
const isElectron = window.process?.type === 'renderer';
|
||||
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
|
||||
|
||||
export default function useElectronEvent() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const sendToElectron = useCallback((channel: string, args?: string | Record<string, unknown>) => {
|
||||
if (isElectron && ipcRenderer) {
|
||||
ipcRenderer.send(channel, args);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// listen to requests to change the editor location
|
||||
useEffect(() => {
|
||||
if (isElectron) {
|
||||
ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => {
|
||||
navigate(location, { relative: 'route' });
|
||||
});
|
||||
}
|
||||
|
||||
// Clean the listener after the component is dismounted
|
||||
return () => {
|
||||
ipcRenderer?.removeAllListeners();
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
return { isElectron, sendToElectron };
|
||||
}
|
||||
|
||||
@@ -32,7 +32,16 @@ import { useEditorSettings } from '../stores/editorSettings';
|
||||
*/
|
||||
export const useEventAction = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { defaultPublic, linkPrevious, defaultDuration, defaultWarnTime, defaultDangerTime } = useEditorSettings();
|
||||
const {
|
||||
defaultPublic,
|
||||
linkPrevious,
|
||||
defaultTimeStrategy,
|
||||
defaultDuration,
|
||||
defaultWarnTime,
|
||||
defaultDangerTime,
|
||||
defaultTimerType,
|
||||
defaultEndAction,
|
||||
} = useEditorSettings();
|
||||
|
||||
/**
|
||||
* Calls mutation to add new event
|
||||
@@ -46,17 +55,17 @@ export const useEventAction = () => {
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
// options to any new block (event / delay / block)
|
||||
type BaseOptions = {
|
||||
after?: string;
|
||||
};
|
||||
|
||||
// options to blocks of type OntimeEvent
|
||||
type EventOptions = BaseOptions &
|
||||
Partial<{
|
||||
defaultPublic: boolean;
|
||||
linkPrevious: boolean;
|
||||
lastEventId: string;
|
||||
defaultWarnTime: number;
|
||||
defaultDangerTime: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
@@ -68,13 +77,12 @@ export const useEventAction = () => {
|
||||
|
||||
// ************* CHECK OPTIONS specific to events
|
||||
if (isOntimeEvent(newEvent)) {
|
||||
// merge creation time options with event settings
|
||||
const applicationOptions = {
|
||||
after: options?.after,
|
||||
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
||||
lastEventId: options?.lastEventId,
|
||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
||||
defaultWarnTime,
|
||||
defaultDangerTime,
|
||||
};
|
||||
|
||||
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
|
||||
@@ -89,6 +97,7 @@ export const useEventAction = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Override event with options from editor settings
|
||||
if (applicationOptions.defaultPublic) {
|
||||
newEvent.isPublic = true;
|
||||
}
|
||||
@@ -104,6 +113,18 @@ export const useEventAction = () => {
|
||||
if (newEvent.timeWarning === undefined) {
|
||||
newEvent.timeWarning = parseUserTime(defaultWarnTime);
|
||||
}
|
||||
|
||||
if (newEvent.timerType === undefined) {
|
||||
newEvent.timerType = defaultTimerType;
|
||||
}
|
||||
|
||||
if (newEvent.endAction === undefined) {
|
||||
newEvent.endAction = defaultEndAction;
|
||||
}
|
||||
|
||||
if (newEvent.timeStrategy === undefined) {
|
||||
newEvent.timeStrategy = defaultTimeStrategy;
|
||||
}
|
||||
}
|
||||
|
||||
// handle adding options that concern all event type
|
||||
@@ -117,7 +138,18 @@ export const useEventAction = () => {
|
||||
logAxiosError('Failed adding event', error);
|
||||
}
|
||||
},
|
||||
[_addEventMutation, defaultDangerTime, defaultDuration, defaultPublic, defaultWarnTime, linkPrevious, queryClient],
|
||||
[
|
||||
_addEventMutation,
|
||||
defaultDangerTime,
|
||||
defaultDuration,
|
||||
defaultEndAction,
|
||||
defaultPublic,
|
||||
defaultTimerType,
|
||||
defaultTimeStrategy,
|
||||
defaultWarnTime,
|
||||
linkPrevious,
|
||||
queryClient,
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = () => {
|
||||
@@ -187,15 +222,6 @@ export const useRuntimePlaybackOverview = () => {
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useTimelineOverview = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
plannedStart: state.runtime.plannedStart,
|
||||
plannedEnd: state.runtime.plannedEnd,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useTimelineStatus = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
|
||||
@@ -7,5 +7,5 @@ export const ontimePlaceholderInfo: GetInfo = {
|
||||
version: '2.0.0',
|
||||
serverPort: 4001,
|
||||
osc: oscPlaceholderSettings,
|
||||
cssOverride: '',
|
||||
publicDir: '',
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { validateEndAction, validateTimerType, validateTimeStrategy } from 'ontime-utils';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||
@@ -5,39 +7,63 @@ import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||
type EditorSettingsStore = {
|
||||
defaultDuration: string;
|
||||
linkPrevious: boolean;
|
||||
defaultTimeStrategy: TimeStrategy;
|
||||
defaultWarnTime: string;
|
||||
defaultDangerTime: string;
|
||||
defaultPublic: boolean;
|
||||
defaultTimerType: TimerType;
|
||||
defaultEndAction: EndAction;
|
||||
setDefaultDuration: (defaultDuration: string) => void;
|
||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||
setTimeStrategy: (timeStrategy: TimeStrategy) => void;
|
||||
setWarnTime: (warnTime: string) => void;
|
||||
setDangerTime: (dangerTime: string) => void;
|
||||
setDefaultPublic: (defaultPublic: boolean) => void;
|
||||
setDefaultTimerType: (defaultTimerType: TimerType) => void;
|
||||
setDefaultEndAction: (defaultEndAction: EndAction) => void;
|
||||
};
|
||||
|
||||
export const editorSettingsDefaults = {
|
||||
duration: '00:10:00',
|
||||
linkPrevious: true,
|
||||
timeStrategy: TimeStrategy.LockDuration,
|
||||
warnTime: '00:02:00', // 120000 same as backend
|
||||
dangerTime: '00:01:00', // 60000 same as backend
|
||||
isPublic: true,
|
||||
timerType: TimerType.CountDown,
|
||||
endAction: EndAction.None,
|
||||
};
|
||||
|
||||
enum EditorSettingsKeys {
|
||||
DefaultDuration = 'ontime-default-duration',
|
||||
LinkPrevious = 'ontime-link-previous',
|
||||
DefaultTimeStrategy = 'ontime-time-strategy',
|
||||
DefaultWarnTime = 'ontime-default-warn-time',
|
||||
DefaultDangerTime = 'ontime-default-danger-time',
|
||||
DefaultPublic = 'ontime-default-public',
|
||||
DefaultTimerType = 'ontime-default-timer-type',
|
||||
DefaultEndAction = 'ontime-default-end-action',
|
||||
}
|
||||
|
||||
export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
return {
|
||||
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? editorSettingsDefaults.duration,
|
||||
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, editorSettingsDefaults.linkPrevious),
|
||||
defaultTimeStrategy: validateTimeStrategy(
|
||||
localStorage.getItem(EditorSettingsKeys.DefaultTimeStrategy),
|
||||
editorSettingsDefaults.timeStrategy,
|
||||
),
|
||||
defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime,
|
||||
defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime,
|
||||
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
|
||||
defaultTimerType: validateTimerType(
|
||||
localStorage.getItem(EditorSettingsKeys.DefaultTimerType),
|
||||
editorSettingsDefaults.timerType,
|
||||
),
|
||||
defaultEndAction: validateEndAction(
|
||||
localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
|
||||
editorSettingsDefaults.endAction,
|
||||
),
|
||||
|
||||
setDefaultDuration: (defaultDuration) =>
|
||||
set(() => {
|
||||
@@ -50,6 +76,12 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
|
||||
return { linkPrevious };
|
||||
}),
|
||||
setTimeStrategy: (defaultTimeStrategy) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultTimeStrategy, String(defaultTimeStrategy));
|
||||
return { defaultTimeStrategy };
|
||||
}),
|
||||
|
||||
setWarnTime: (defaultWarnTime) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultWarnTime, String(defaultWarnTime));
|
||||
@@ -65,5 +97,15 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
|
||||
return { defaultPublic };
|
||||
}),
|
||||
setDefaultTimerType: (defaultTimerType) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultTimerType, String(defaultTimerType));
|
||||
return { defaultTimerType };
|
||||
}),
|
||||
setDefaultEndAction: (defaultEndAction) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction));
|
||||
return { defaultEndAction };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,60 +1,7 @@
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types';
|
||||
import { RuntimeStore, runtimeStorePlaceholder } from 'ontime-types';
|
||||
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
clock: 0,
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
current: null,
|
||||
duration: null,
|
||||
elapsed: null,
|
||||
expectedFinish: null,
|
||||
finishedAt: null,
|
||||
phase: TimerPhase.None,
|
||||
playback: Playback.Stop,
|
||||
secondaryTimer: null,
|
||||
startedAt: null,
|
||||
},
|
||||
onAir: false,
|
||||
message: {
|
||||
timer: {
|
||||
text: '',
|
||||
visible: false,
|
||||
blink: false,
|
||||
blackout: false,
|
||||
},
|
||||
external: {
|
||||
text: '',
|
||||
visible: false,
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
selectedEventIndex: null,
|
||||
numEvents: 0,
|
||||
offset: 0,
|
||||
plannedStart: 0,
|
||||
plannedEnd: 0,
|
||||
actualStart: null,
|
||||
expectedEnd: null,
|
||||
},
|
||||
currentBlock: {
|
||||
block: null,
|
||||
startedAt: null,
|
||||
},
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
publicEventNow: null,
|
||||
publicEventNext: null,
|
||||
auxtimer1: {
|
||||
current: 0,
|
||||
direction: SimpleDirection.CountUp,
|
||||
duration: 0,
|
||||
playback: SimplePlayback.Stop,
|
||||
},
|
||||
frozen: false,
|
||||
};
|
||||
|
||||
const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
|
||||
|
||||
export const runtimeStore = createWithEqualityFn<RuntimeStore>(
|
||||
|
||||
@@ -3,3 +3,4 @@ export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/rele
|
||||
export const websiteUrl = 'https://www.getontime.no';
|
||||
|
||||
export const documentationUrl = 'https://docs.getontime.no';
|
||||
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||
|
||||
+10
@@ -1,3 +1,7 @@
|
||||
.halfWidth {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -37,3 +41,9 @@
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.twoCols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ export default function UrlPresetsForm() {
|
||||
|
||||
const addNew = () => {
|
||||
prepend({
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
alias: '',
|
||||
pathAndParams: '',
|
||||
});
|
||||
|
||||
+8
-1
@@ -4,6 +4,7 @@ import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||
|
||||
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
||||
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
@@ -36,6 +37,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
onSubmit={handleEdit}
|
||||
initialColour={colour}
|
||||
initialLabel={label}
|
||||
initialKey={field}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -47,7 +49,12 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
<td>
|
||||
<Swatch color={colour} />
|
||||
</td>
|
||||
<td className={style.fullWidth}>{label}</td>
|
||||
<td className={style.halfWidth}>{label}</td>
|
||||
<td className={style.fullWidth}>
|
||||
<CopyTag label='Copy key to use in integrations' copyValue={field}>
|
||||
{field}
|
||||
</CopyTag>
|
||||
</td>
|
||||
<td className={style.actions}>
|
||||
<IconButton
|
||||
size='sm'
|
||||
|
||||
+33
-19
@@ -2,10 +2,11 @@ import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Input } from '@chakra-ui/react';
|
||||
import { CustomField } from 'ontime-types';
|
||||
import { isAlphanumeric } from 'ontime-utils';
|
||||
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
|
||||
|
||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
|
||||
import style from '../FeatureSettings.module.scss';
|
||||
@@ -15,10 +16,13 @@ interface CustomFieldsFormProps {
|
||||
onCancel: () => void;
|
||||
initialColour?: string;
|
||||
initialLabel?: string;
|
||||
initialKey?: string;
|
||||
}
|
||||
|
||||
export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
const { onSubmit, onCancel, initialColour, initialLabel } = props;
|
||||
const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props;
|
||||
const { data } = useCustomFields();
|
||||
|
||||
// we use this to force an update
|
||||
const [_, setColour] = useState(initialColour || '');
|
||||
|
||||
@@ -31,7 +35,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
getValues,
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm({
|
||||
defaultValues: { label: initialLabel || '', colour: initialColour || '' },
|
||||
defaultValues: { label: initialLabel || '', colour: initialColour || '', key: initialKey || '' },
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
@@ -66,28 +70,38 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
|
||||
<div className={style.column}>
|
||||
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
||||
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
||||
<Input
|
||||
{...register('label', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
validate: (value) => {
|
||||
if (value.trim().length === 0) return 'Required field';
|
||||
if (!isAlphanumeric(value)) return 'Only alphanumeric characters are allowed';
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<div className={style.twoCols}>
|
||||
<div>
|
||||
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
||||
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
||||
<Input
|
||||
{...register('label', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
|
||||
validate: (value) => {
|
||||
if (value.trim().length === 0) return 'Required field';
|
||||
if (!isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
|
||||
if (Object.keys(data).includes(value)) return 'Custom fields must be unique';
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Panel.Description>Key (auto-generated value for use in Integrations and API)</Panel.Description>
|
||||
<Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Panel.Description>Colour</Panel.Description>
|
||||
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
||||
</div>
|
||||
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<div className={style.buttonRow}>
|
||||
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
|
||||
|
||||
+9
-4
@@ -6,13 +6,12 @@ import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
||||
import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import { customFieldsDocsUrl } from '../../../../../externals';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
|
||||
import CustomFieldEntry from './CustomFieldEntry';
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
|
||||
const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||
|
||||
export default function CustomFields() {
|
||||
const { data, refetch } = useCustomFields();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
@@ -59,9 +58,14 @@ export default function CustomFields() {
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
Custom fields allow for additional information to be added to an event (eg. light, sound, camera). <br />
|
||||
Custom fields allow for additional information to be added to an event.
|
||||
<br />
|
||||
This data is not used by Ontime.
|
||||
<br />
|
||||
This data is not used by Ontime, but provides place for cueing or department specific information (eg.
|
||||
light, sound, camera).
|
||||
<br />
|
||||
<br />
|
||||
Custom fields can be used width the Integrations feature using the generated key.
|
||||
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -73,6 +77,7 @@ export default function CustomFields() {
|
||||
<tr>
|
||||
<th>Colour</th>
|
||||
<th>Name</th>
|
||||
<th>Key (used in Integrations)</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -84,8 +84,8 @@ export default function ViewSettingsForm() {
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
You can override the styles of the viewers with a custom CSS file. <br />
|
||||
{info?.cssOverride && `In your installation the file is at ${info?.cssOverride}`}
|
||||
You can the Ontime views or customise its styles by modifying the provided CSS file. <br />
|
||||
The CSS file is in the user directory at {`${info.publicDir}/user/styles/override.css`}
|
||||
<br />
|
||||
<br />
|
||||
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
|
||||
@@ -147,7 +147,7 @@ export default function ViewSettingsForm() {
|
||||
variant='ontime-filled'
|
||||
maxLength={150}
|
||||
width='275px'
|
||||
placeholder='Message shown when timer reaches end'
|
||||
placeholder='Shown when timer reaches end'
|
||||
{...register('endMessage')}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function HttpIntegrations() {
|
||||
id: generateId(),
|
||||
cycle: 'onLoad',
|
||||
message: '',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function OscIntegrations() {
|
||||
cycle: 'onLoad',
|
||||
address: '',
|
||||
payload: '',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+67
-26
@@ -1,5 +1,5 @@
|
||||
import { Select, Switch } from '@chakra-ui/react';
|
||||
import { EndAction, TimerType } from 'ontime-types';
|
||||
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { parseUserTime } from 'ontime-utils';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
@@ -7,17 +7,28 @@ import { editorSettingsDefaults, useEditorSettings } from '../../../../common/st
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
export default function EditorSettingsForm() {
|
||||
const eventSettings = useEditorSettings((state) => state);
|
||||
const {
|
||||
defaultDuration,
|
||||
linkPrevious,
|
||||
defaultTimeStrategy,
|
||||
defaultWarnTime,
|
||||
defaultDangerTime,
|
||||
defaultPublic,
|
||||
defaultTimerType,
|
||||
defaultEndAction,
|
||||
setDefaultDuration,
|
||||
setLinkPrevious,
|
||||
setTimeStrategy,
|
||||
setWarnTime,
|
||||
setDangerTime,
|
||||
setDefaultPublic,
|
||||
setDefaultTimerType,
|
||||
setDefaultEndAction,
|
||||
} = useEditorSettings((state) => state);
|
||||
|
||||
const setDefaultDuration = eventSettings.setDefaultDuration;
|
||||
const setLinkPrevious = eventSettings.setLinkPrevious;
|
||||
const setWarnTime = eventSettings.setWarnTime;
|
||||
const setDangerTime = eventSettings.setDangerTime;
|
||||
const setDefaultPublic = eventSettings.setDefaultPublic;
|
||||
|
||||
const durationInMs = parseUserTime(eventSettings.defaultDuration);
|
||||
const warnTimeInMs = parseUserTime(eventSettings.defaultWarnTime);
|
||||
const dangerTimeInMs = parseUserTime(eventSettings.defaultDangerTime);
|
||||
const durationInMs = parseUserTime(defaultDuration);
|
||||
const warnTimeInMs = parseUserTime(defaultWarnTime);
|
||||
const dangerTimeInMs = parseUserTime(defaultDangerTime);
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
@@ -26,6 +37,36 @@ export default function EditorSettingsForm() {
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Title>Rundown defaults for new events</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Link previous'
|
||||
description='Whether the start time of new events should be linked to the previous event end time'
|
||||
/>
|
||||
<Switch
|
||||
variant='ontime'
|
||||
size='lg'
|
||||
defaultChecked={linkPrevious}
|
||||
onChange={(event) => setLinkPrevious(event.target.checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Timer strategy'
|
||||
description='Which time should be maintained when event schedule is recalculated'
|
||||
/>
|
||||
<Select
|
||||
variant='ontime'
|
||||
size='sm'
|
||||
width='auto'
|
||||
value={defaultTimeStrategy}
|
||||
onChange={(event) => setTimeStrategy(event.target.value as TimeStrategy)}
|
||||
>
|
||||
<option value={TimeStrategy.LockDuration}>Duration</option>
|
||||
<option value={TimeStrategy.LockEnd}>End Time</option>
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Default duration' description='Default duration for new events' />
|
||||
@@ -36,21 +77,15 @@ export default function EditorSettingsForm() {
|
||||
placeholder={editorSettingsDefaults.duration}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Link previous'
|
||||
description='Whether the start time of new events should be linked to the previous event end time'
|
||||
/>
|
||||
<Switch
|
||||
variant='ontime'
|
||||
size='lg'
|
||||
defaultChecked={eventSettings.linkPrevious}
|
||||
onChange={(event) => setLinkPrevious(event.target.checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Timer type' description='Default type of timer for new events' />
|
||||
<Select variant='ontime' size='sm' width='auto' isDisabled>
|
||||
<Select
|
||||
variant='ontime'
|
||||
size='sm'
|
||||
width='auto'
|
||||
value={defaultTimerType}
|
||||
onChange={(event) => setDefaultTimerType(event.target.value as TimerType)}
|
||||
>
|
||||
<option value={TimerType.CountDown}>Count down</option>
|
||||
<option value={TimerType.CountUp}>Count up</option>
|
||||
<option value={TimerType.TimeToEnd}>Time to end</option>
|
||||
@@ -59,7 +94,13 @@ export default function EditorSettingsForm() {
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='End Action' description='Default end action for new events' />
|
||||
<Select variant='ontime' size='sm' width='auto' isDisabled>
|
||||
<Select
|
||||
variant='ontime'
|
||||
size='sm'
|
||||
width='auto'
|
||||
value={defaultEndAction}
|
||||
onChange={(event) => setDefaultEndAction(event.target.value as EndAction)}
|
||||
>
|
||||
<option value={EndAction.None}>None</option>
|
||||
<option value={EndAction.Stop}>Stop</option>
|
||||
<option value={EndAction.LoadNext}>Load next</option>
|
||||
@@ -93,7 +134,7 @@ export default function EditorSettingsForm() {
|
||||
<Switch
|
||||
variant='ontime'
|
||||
size='lg'
|
||||
defaultChecked={eventSettings.defaultPublic}
|
||||
defaultChecked={defaultPublic}
|
||||
onChange={(event) => setDefaultPublic(event.target.checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
|
||||
+6
@@ -4,3 +4,9 @@
|
||||
gap: $section-spacing;
|
||||
row-gap: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.goIcon {
|
||||
@include rotate-fourty-five;
|
||||
margin-left: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import { serverPort } from '../../../../common/api/constants';
|
||||
import AppLink from '../../../../common/components/app-link/AppLink';
|
||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import { openLink } from '../../../../common/utils/linkUtils';
|
||||
|
||||
import style from './NetworkInterfaces.module.scss';
|
||||
|
||||
export default function InfoNif() {
|
||||
const { data } = useInfo();
|
||||
|
||||
const handleClick = (address: string) => openLink(address);
|
||||
|
||||
return (
|
||||
<div className={style.interfaces}>
|
||||
{data?.networkInterfaces?.map((nif) => (
|
||||
<AppLink key={nif.address} href={`http://${nif.address}:${serverPort}`}>
|
||||
{`${nif.name} - ${nif.address}`}
|
||||
</AppLink>
|
||||
))}
|
||||
{data?.networkInterfaces?.map((nif) => {
|
||||
const address = `http://${nif.address}:${serverPort}`;
|
||||
|
||||
return (
|
||||
<CopyTag
|
||||
key={nif.name}
|
||||
copyValue={address}
|
||||
onClick={() => handleClick(address)}
|
||||
label='Copy IP or navigate to address'
|
||||
>
|
||||
{`${nif.name} - ${nif.address}`} <IoArrowUp className={style.goIcon} />
|
||||
</CopyTag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChangeEvent, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Button, Input } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
|
||||
@@ -13,14 +14,17 @@ import ProjectList from './ProjectList';
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
export default function ManageProjects() {
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState<'import' | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isCreatingProject = searchParams.get('create') === 'true';
|
||||
|
||||
const handleToggleCreate = () => {
|
||||
setIsCreatingProject((prev) => !prev);
|
||||
searchParams.set('create', isCreatingProject ? 'false' : 'true');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
const handleSelectFile = () => {
|
||||
@@ -49,7 +53,8 @@ export default function ManageProjects() {
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setIsCreatingProject(false);
|
||||
searchParams.delete('create');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
</div>
|
||||
</Panel.Title>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<div className={style.innerColumn}>
|
||||
<Panel.Section className={style.innerColumn}>
|
||||
<label>
|
||||
Project title
|
||||
<Input
|
||||
@@ -145,7 +145,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
{...register('backstageUrl')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Panel.Section>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -92,7 +93,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
|
||||
</div>
|
||||
</Panel.Title>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<div className={style.innerColumn}>
|
||||
<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.
|
||||
@@ -121,7 +122,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
|
||||
<Switch variant='ontime' {...register('http')} />
|
||||
HTTP Integration
|
||||
</label>
|
||||
</div>
|
||||
</Panel.Section>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,9 +53,11 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.inlineLabels {
|
||||
label {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { isLocalhost } from '../../../../common/api/constants';
|
||||
import useElectronEvent from '../../../../common/hooks/useElectronEvent';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
@@ -31,9 +32,10 @@ export default function ShutdownPanel() {
|
||||
This will shutdown the Ontime server. <br />
|
||||
The runtime state will be lost, but your project is kept for next time.
|
||||
</Panel.Paragraph>
|
||||
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!isElectron}>
|
||||
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!(isElectron || isLocalhost)}>
|
||||
Shutdown ontime
|
||||
</Button>
|
||||
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
|
||||
<AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
|
||||
@@ -193,7 +193,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
<Panel.ListGroup>
|
||||
<div className={style.buttonRow}>
|
||||
{isAuthenticating && <Spinner />}
|
||||
<CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
|
||||
<CopyTag copyValue={authKey ?? ''} label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
|
||||
{authKey ? authKey : 'Upload files to generate Auth Key'}
|
||||
</CopyTag>
|
||||
<Button
|
||||
|
||||
+5
-5
@@ -3,9 +3,8 @@ import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils';
|
||||
|
||||
import { isAlphanumeric } from '../../../../../common/utils/regex';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
import useGoogleSheet from '../useGoogleSheet';
|
||||
import { useSheetStore } from '../useSheetStore';
|
||||
@@ -190,9 +189,10 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
defaultValue={ontimeName}
|
||||
placeholder='Name of the field as shown in Ontime'
|
||||
{...register(`custom.${index}.ontimeName`, {
|
||||
pattern: {
|
||||
value: isAlphanumeric,
|
||||
message: 'Custom field name must be alphanumeric',
|
||||
validate: (value) => {
|
||||
if (!isAlphanumericWithSpace(value))
|
||||
return 'Only alphanumeric characters and space are allowed';
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
+5
-4
@@ -24,7 +24,8 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
// we only count Ontime Events which are 1 based in client
|
||||
let eventIndex = 0;
|
||||
|
||||
const fieldHeaders = Object.keys(customFields);
|
||||
const fieldKeys = Object.keys(customFields);
|
||||
const fieldLabels = fieldKeys.map((key) => customFields[key].label);
|
||||
|
||||
return (
|
||||
<Panel.Table>
|
||||
@@ -44,8 +45,8 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
<th>Colour</th>
|
||||
<th>Timer Type</th>
|
||||
<th>End Action</th>
|
||||
{fieldHeaders.map((field) => (
|
||||
<th key={field}>{field}</th>
|
||||
{fieldLabels.map((label) => (
|
||||
<th key={label}>{label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -102,7 +103,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
<Tag>{event.endAction}</Tag>
|
||||
</td>
|
||||
{isOntimeEvent(event) &&
|
||||
fieldHeaders.map((field) => {
|
||||
fieldKeys.map((field) => {
|
||||
let value = '';
|
||||
if (field in event.custom) {
|
||||
value = event.custom[field];
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { IconButton, Input } from '@chakra-ui/react';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
import { IoEye } from '@react-icons/all-files/io5/IoEye';
|
||||
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
||||
|
||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
import style from './InputRow.module.scss';
|
||||
@@ -13,15 +12,13 @@ interface InputRowProps {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
text: string;
|
||||
visible?: boolean;
|
||||
readonly?: boolean;
|
||||
actionHandler: (action: string, payload: object) => void;
|
||||
visible: boolean;
|
||||
actionHandler: () => void;
|
||||
changeHandler: (newValue: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function InputRow(props: InputRowProps) {
|
||||
const { label, placeholder, text, visible, actionHandler, changeHandler, className, readonly } = props;
|
||||
const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const cursorPositionRef = useRef(0);
|
||||
@@ -39,41 +36,27 @@ export default function InputRow(props: InputRowProps) {
|
||||
changeHandler(event.target.value);
|
||||
};
|
||||
|
||||
const classes = cx([style.inputRow, className]);
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className={style.inputRow}>
|
||||
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
|
||||
<div className={style.inputItems}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
readOnly={readonly}
|
||||
disabled={readonly}
|
||||
value={text}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{readonly ? (
|
||||
<IconButton
|
||||
size='sm'
|
||||
isDisabled
|
||||
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||
aria-label={`Toggle ${label}`}
|
||||
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||
/>
|
||||
) : (
|
||||
<TooltipActionBtn
|
||||
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
|
||||
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
||||
aria-label={`Toggle ${label}`}
|
||||
openDelay={tooltipDelayMid}
|
||||
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='sm'
|
||||
/>
|
||||
)}
|
||||
<TooltipActionBtn
|
||||
clickHandler={actionHandler}
|
||||
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
||||
aria-label={`Toggle ${label}`}
|
||||
openDelay={tooltipDelayMid}
|
||||
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='sm'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,3 @@
|
||||
.messageContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $section-spacing;
|
||||
}
|
||||
|
||||
.buttonSection {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $element-spacing;
|
||||
margin-top: -0.5rem;
|
||||
}
|
||||
|
||||
.singleAction {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $element-spacing;
|
||||
margin-top: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
@@ -26,3 +6,89 @@
|
||||
color: $action-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
display: grid;
|
||||
gap: $element-spacing;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
|
||||
.preview {
|
||||
background-color: $ui-black;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.corner {
|
||||
@include rotate-fourty-five;
|
||||
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
cursor: pointer;
|
||||
color: $ui-white;
|
||||
transition-property: color;
|
||||
transition-duration: $transition-time-action;
|
||||
|
||||
&:hover {
|
||||
color: $ontime-color;
|
||||
}
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $element-spacing;
|
||||
}
|
||||
|
||||
.eventStatus {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
margin: 0.5rem 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--override-colour, $ui-white);
|
||||
|
||||
&[data-phase='pending'] {
|
||||
color: $ontime-roll;
|
||||
}
|
||||
&[data-phase='overtime'] {
|
||||
color: $playback-negative;
|
||||
}
|
||||
&[data-phase='none'] {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
}
|
||||
|
||||
.secondaryContent {
|
||||
border-top: 1px solid $white-7;
|
||||
}
|
||||
|
||||
.blackout {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timerIndicators {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.statusIcon {
|
||||
color: $gray-1000;
|
||||
|
||||
&[data-active='true'] {
|
||||
color: $active-indicator;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-top: 1px solid $gray-1000;
|
||||
}
|
||||
|
||||
@@ -1,64 +1,52 @@
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { IoEye } from '@react-icons/all-files/io5/IoEye';
|
||||
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
||||
import { IoSunny } from '@react-icons/all-files/io5/IoSunny';
|
||||
import { IoSunnyOutline } from '@react-icons/all-files/io5/IoSunnyOutline';
|
||||
|
||||
import { setMessage, useMessageControl } from '../../../common/hooks/useSocket';
|
||||
import { enDash } from '../../../common/utils/styleUtils';
|
||||
import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
|
||||
|
||||
import InputRow from './InputRow';
|
||||
|
||||
import style from './MessageControl.module.scss';
|
||||
|
||||
const noop = () => undefined;
|
||||
import TimerControlsPreview from './TimerViewControl';
|
||||
|
||||
export default function MessageControl() {
|
||||
const message = useMessageControl();
|
||||
const blink = message.timer.blink;
|
||||
const blackout = message.timer.blackout;
|
||||
|
||||
return (
|
||||
<div className={style.messageContainer}>
|
||||
<InputRow
|
||||
label='Timer'
|
||||
placeholder='Message shown in stage timer'
|
||||
text={message.timer.text}
|
||||
visible={message.timer.visible}
|
||||
changeHandler={(newValue) => setMessage.timerText(newValue)}
|
||||
actionHandler={() => setMessage.timerVisible(!message.timer.visible)}
|
||||
/>
|
||||
<div className={style.buttonSection}>
|
||||
<Button
|
||||
size='sm'
|
||||
className={`${blink ? style.blink : ''}`}
|
||||
variant={blink ? 'ontime-filled' : 'ontime-subtle'}
|
||||
leftIcon={blink ? <IoSunny size='1rem' /> : <IoSunnyOutline size='1rem' />}
|
||||
onClick={() => setMessage.timerBlink(!blink)}
|
||||
data-testid='toggle timer blink'
|
||||
>
|
||||
Blink
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
className={style.blackoutButton}
|
||||
variant={blackout ? 'ontime-filled' : 'ontime-subtle'}
|
||||
leftIcon={blackout ? <IoEye size='1rem' /> : <IoEyeOffOutline size='1rem' />}
|
||||
onClick={() => setMessage.timerBlackout(!blackout)}
|
||||
data-testid='toggle timer blackout'
|
||||
>
|
||||
Blackout screen
|
||||
</Button>
|
||||
</div>
|
||||
<InputRow
|
||||
label='External Message (read only)'
|
||||
placeholder={enDash}
|
||||
readonly
|
||||
text={message.external.text}
|
||||
visible={message.external.visible}
|
||||
changeHandler={noop}
|
||||
actionHandler={noop}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<TimerControlsPreview />
|
||||
<TimerMessageInput />
|
||||
<ExternalInput />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TimerMessageInput() {
|
||||
const { text, visible } = useTimerMessageInput();
|
||||
|
||||
return (
|
||||
<InputRow
|
||||
label='Timer Message'
|
||||
placeholder='Message shown fullscreen in stage timer'
|
||||
text={text}
|
||||
visible={visible}
|
||||
changeHandler={(newValue) => setMessage.timerText(newValue)}
|
||||
actionHandler={() => setMessage.timerVisible(!visible)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalInput() {
|
||||
const { text, visible } = useExternalMessageInput();
|
||||
|
||||
const toggleExternal = () => {
|
||||
if (visible) {
|
||||
setMessage.timerSecondary(null);
|
||||
} else {
|
||||
setMessage.timerSecondary('external');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<InputRow
|
||||
label='External Message'
|
||||
placeholder='Message shown as secondary text in stage timer'
|
||||
text={text}
|
||||
visible={visible}
|
||||
changeHandler={(newValue) => setMessage.externalText(newValue)}
|
||||
actionHandler={toggleExternal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,16 +3,19 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
|
||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import MessageControl from './MessageControl';
|
||||
|
||||
import style from '../../editors/Editor.module.scss';
|
||||
|
||||
const MessageControlExport = () => {
|
||||
const classes = cx([style.content, style.contentColumnLayout]);
|
||||
|
||||
return (
|
||||
<div className={style.messages} data-testid='panel-messages-control'>
|
||||
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'messagecontrol')} />
|
||||
<div className={style.content}>
|
||||
<div className={classes}>
|
||||
<ErrorBoundary>
|
||||
<MessageControl />
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
import { IoFlag } from '@react-icons/all-files/io5/IoFlag';
|
||||
import { IoTime } from '@react-icons/all-files/io5/IoTime';
|
||||
import { TimerPhase, TimerType } from 'ontime-types';
|
||||
|
||||
import { useMessagePreview } from '../../../common/hooks/useSocket';
|
||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
import style from './MessageControl.module.scss';
|
||||
|
||||
export default function TimerPreview() {
|
||||
const { blink, blackout, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } =
|
||||
useMessagePreview();
|
||||
const { data } = useViewSettings();
|
||||
|
||||
const contentClasses = cx([style.previewContent, blink && style.blink, blackout && style.blackout]);
|
||||
|
||||
const main = (() => {
|
||||
if (showTimerMessage) return 'Message';
|
||||
if (phase === TimerPhase.Pending) return 'Standby to start';
|
||||
if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message';
|
||||
return 'Timer';
|
||||
})();
|
||||
|
||||
const secondary = (() => {
|
||||
// message is a fullscreen overlay
|
||||
if (showTimerMessage) return null;
|
||||
|
||||
// we need to check aux first since it takes priority
|
||||
if (showAuxTimer) return 'Aux Timer';
|
||||
if (showExternalMessage) return 'External message';
|
||||
return null;
|
||||
})();
|
||||
|
||||
const overrideColour = (() => {
|
||||
// override fallback colours from starter project
|
||||
if (phase === TimerPhase.Warning) return data.warningColor ?? '#FFAB33';
|
||||
if (phase === TimerPhase.Danger) return data.dangerColor ?? '#ED3333';
|
||||
return data.normalColor ?? '#FFFC';
|
||||
})();
|
||||
|
||||
const showColourOverride = main == 'Timer';
|
||||
|
||||
return (
|
||||
<div className={style.preview}>
|
||||
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'timer')} />
|
||||
<div className={contentClasses}>
|
||||
<div
|
||||
className={style.mainContent}
|
||||
data-phase={showColourOverride && phase}
|
||||
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
|
||||
>
|
||||
{main}
|
||||
</div>
|
||||
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
|
||||
</div>
|
||||
<div className={style.eventStatus}>
|
||||
<Tooltip label='Time type: Count down' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||
<IoArrowDown className={style.statusIcon} data-active={timerType === TimerType.CountDown} />
|
||||
</Tooltip>
|
||||
<Tooltip label='Time type: Count up' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||
<IoArrowUp className={style.statusIcon} data-active={timerType === TimerType.CountUp} />
|
||||
</Tooltip>
|
||||
<Tooltip label='Time type: Clock' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||
<IoTime className={style.statusIcon} data-active={timerType === TimerType.Clock} />
|
||||
</Tooltip>
|
||||
<Tooltip label='Time type: Time to end' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||
<IoFlag className={style.statusIcon} data-active={timerType === TimerType.TimeToEnd} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Button } from '@chakra-ui/react';
|
||||
|
||||
import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket';
|
||||
|
||||
import TimerPreview from './TimerPreview';
|
||||
|
||||
import style from './MessageControl.module.scss';
|
||||
|
||||
export default function TimerControlsPreview() {
|
||||
const { blackout, blink, secondarySource } = useTimerViewControl();
|
||||
|
||||
const toggleSecondary = (newValue: 'aux' | 'external' | null) => {
|
||||
if (secondarySource === newValue) {
|
||||
setMessage.timerSecondary(null);
|
||||
} else {
|
||||
setMessage.timerSecondary(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.previewContainer}>
|
||||
<TimerPreview />
|
||||
|
||||
<div className={style.options}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={secondarySource === 'aux' ? 'ontime-filled' : 'ontime-subtle'}
|
||||
onClick={() => toggleSecondary('aux')}
|
||||
>
|
||||
Show Aux timer
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={secondarySource === 'external' ? 'ontime-filled' : 'ontime-subtle'}
|
||||
onClick={() => toggleSecondary('external')}
|
||||
>
|
||||
Show external
|
||||
</Button>
|
||||
|
||||
<hr className={style.divider} />
|
||||
|
||||
<Button
|
||||
size='sm'
|
||||
variant={blink ? 'ontime-filled' : 'ontime-subtle'}
|
||||
onClick={() => setMessage.timerBlink(!blink)}
|
||||
data-testid='toggle timer blink'
|
||||
>
|
||||
Blink
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
className={style.blackoutButton}
|
||||
variant={blackout ? 'ontime-filled' : 'ontime-subtle'}
|
||||
onClick={() => setMessage.timerBlackout(!blackout)}
|
||||
data-testid='toggle timer blackout'
|
||||
>
|
||||
Blackout screen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { Header } from '@tanstack/react-table';
|
||||
import { OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import styles from '../Cuesheet.module.scss';
|
||||
|
||||
interface SortableCellProps {
|
||||
@@ -29,8 +27,6 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
|
||||
transition,
|
||||
};
|
||||
|
||||
const resizerClasses = cx([styles.resizer, header.column.getIsResizing() ? styles.isResizing : null]);
|
||||
|
||||
return (
|
||||
<th ref={setNodeRef} style={dragStyle} colSpan={colSpan}>
|
||||
<div {...attributes} {...listeners}>
|
||||
@@ -41,7 +37,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
|
||||
onMouseDown: header.getResizeHandler(),
|
||||
onTouchStart: header.getResizeHandler(),
|
||||
}}
|
||||
className={resizerClasses}
|
||||
className={styles.resizer}
|
||||
/>
|
||||
</th>
|
||||
);
|
||||
|
||||
@@ -29,10 +29,12 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||
showPrevious,
|
||||
togglePreviousVisibility,
|
||||
showDelayBlock,
|
||||
hideSeconds,
|
||||
showDelayedTimes,
|
||||
toggleIndexColumn,
|
||||
toggleDelayedTimes,
|
||||
toggleDelayVisibility,
|
||||
toggleSecondsVisibility,
|
||||
} = useCuesheetSettings();
|
||||
|
||||
return (
|
||||
@@ -80,6 +82,10 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||
<Switch variant='ontime' size='sm' isChecked={showDelayBlock} onChange={() => toggleDelayVisibility()} />
|
||||
Show delay blocks
|
||||
</label>
|
||||
<label className={style.option}>
|
||||
<Switch variant='ontime' size='sm' isChecked={hideSeconds} onChange={() => toggleSecondsVisibility()} />
|
||||
Hide seconds
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.rightPanel}>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback } from 'react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
|
||||
import RunningTime from '../viewers/common/running-time/RunningTime';
|
||||
@@ -19,20 +18,28 @@ function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
|
||||
|
||||
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
||||
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
|
||||
const cellValue = (getValue() as number | null) ?? 0;
|
||||
const delayValue = (original as OntimeEvent)?.delay ?? 0;
|
||||
|
||||
return (
|
||||
<span className={style.time}>
|
||||
<DelayIndicator delayValue={delayValue} />
|
||||
<RunningTime value={cellValue} />
|
||||
<RunningTime value={cellValue} hideSeconds={hideSeconds} />
|
||||
{delayValue !== 0 && showDelayedTimes && (
|
||||
<RunningTime className={style.delayedTime} value={cellValue + delayValue} />
|
||||
<RunningTime className={style.delayedTime} value={cellValue + delayValue} hideSeconds={hideSeconds} />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MakeDuration({ getValue }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
|
||||
const cellValue = (getValue() as number | null) ?? 0;
|
||||
|
||||
return <RunningTime value={cellValue} hideSeconds={hideSeconds} />;
|
||||
}
|
||||
|
||||
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
@@ -97,7 +104,7 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
|
||||
accessorKey: 'duration',
|
||||
id: 'duration',
|
||||
header: 'Duration',
|
||||
cell: (row) => millisToString(row.getValue() as number | null),
|
||||
cell: MakeDuration,
|
||||
size: 75,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ interface CuesheetSettings {
|
||||
showPrevious: boolean;
|
||||
showDelayBlock: boolean;
|
||||
showDelayedTimes: boolean;
|
||||
hideSeconds: boolean;
|
||||
|
||||
toggleSettings: (newValue?: boolean) => void;
|
||||
toggleFollow: (newValue?: boolean) => void;
|
||||
@@ -16,6 +17,7 @@ interface CuesheetSettings {
|
||||
toggleIndexColumn: (newValue?: boolean) => void;
|
||||
toggleDelayVisibility: (newValue?: boolean) => void;
|
||||
toggleDelayedTimes: (newValue?: boolean) => void;
|
||||
toggleSecondsVisibility: (newValue?: boolean) => void;
|
||||
}
|
||||
|
||||
function toggle(oldValue: boolean, value?: boolean) {
|
||||
@@ -31,6 +33,7 @@ enum CuesheetKeys {
|
||||
PreviousVisibility = 'ontime-cuesheet-show-previous',
|
||||
ColumnIndex = 'ontime-cuesheet-show-index-column',
|
||||
DelayedTimes = 'ontime-cuesheet-show-delayed',
|
||||
Seconds = 'ontime-cuesheet-hide-sceconds',
|
||||
}
|
||||
|
||||
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
|
||||
@@ -40,6 +43,7 @@ export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
|
||||
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
|
||||
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
|
||||
showDelayedTimes: booleanFromLocalStorage(CuesheetKeys.DelayedTimes, false),
|
||||
hideSeconds: booleanFromLocalStorage(CuesheetKeys.Seconds, false),
|
||||
|
||||
toggleSettings: (newValue?: boolean) => set((state) => ({ showSettings: toggle(state.showSettings, newValue) })),
|
||||
toggleFollow: (newValue?: boolean) =>
|
||||
@@ -72,4 +76,10 @@ export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
|
||||
localStorage.setItem(CuesheetKeys.DelayedTimes, String(showDelayedTimes));
|
||||
return { showDelayedTimes };
|
||||
}),
|
||||
toggleSecondsVisibility: (newValue?: boolean) =>
|
||||
set((state) => {
|
||||
const hideSeconds = toggle(state.hideSeconds, newValue);
|
||||
localStorage.setItem(CuesheetKeys.Seconds, String(hideSeconds));
|
||||
return { hideSeconds };
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -40,7 +40,7 @@ $panel-gap: 0.5rem;
|
||||
.playback,
|
||||
.messages {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--editor--panel__br);
|
||||
background-color: $bg-container-l2;
|
||||
padding: 1rem;
|
||||
}
|
||||
@@ -62,3 +62,10 @@ $panel-gap: 0.5rem;
|
||||
.content {
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.contentColumnLayout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $section-spacing;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
@use '../../theme/ontimeStyles' as *;
|
||||
|
||||
@mixin absolute-top-right($distance) {
|
||||
// declare editor specific styling constants
|
||||
:root {
|
||||
--editor--panel__br: 8px;
|
||||
}
|
||||
|
||||
@mixin corner() {
|
||||
display: none;
|
||||
transform: rotate(45deg);
|
||||
|
||||
position: absolute;
|
||||
top: $distance;
|
||||
right: $distance;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
cursor: pointer;
|
||||
color: $ui-white;
|
||||
transition-property: color;
|
||||
@@ -15,17 +23,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
@mixin corner() {
|
||||
display: none;
|
||||
@include absolute-top-right(0.5rem);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
@mixin panel() {
|
||||
display: flex;
|
||||
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--editor--panel__br);
|
||||
height: 100%;
|
||||
background-color: $bg-container-l2;
|
||||
padding: 1rem;
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { CSSProperties, useCallback, useEffect, useState } from 'react';
|
||||
import { CSSProperties, memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||
@@ -126,10 +126,32 @@ export default function EventEditor() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.footer}>
|
||||
<CopyTag label='OSC trigger by id'>{`/ontime/load/id "${event.id}"`}</CopyTag>
|
||||
<CopyTag label='OSC trigger by cue'>{`/ontime/load/cue "${event.cue}"`}</CopyTag>
|
||||
</div>
|
||||
<EventEditorFooter id={event.id} cue={event.cue} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EventEditorFooterProps {
|
||||
id: string;
|
||||
cue: string;
|
||||
}
|
||||
|
||||
const EventEditorFooter = memo(_EventEditorFooter);
|
||||
|
||||
function _EventEditorFooter(props: EventEditorFooterProps) {
|
||||
const { id, cue } = props;
|
||||
|
||||
const loadById = `/ontime/load/id "${id}"`;
|
||||
const loadByCue = `/ontime/load/cue "${cue}"`;
|
||||
|
||||
return (
|
||||
<div className={style.footer}>
|
||||
<CopyTag copyValue={loadById} label='OSC trigger by ID'>
|
||||
{loadById}
|
||||
</CopyTag>
|
||||
<CopyTag copyValue={loadByCue} label='OSC trigger by cue'>
|
||||
{loadByCue}
|
||||
</CopyTag>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { MaybeString, OntimeEvent, TimeStrategy } from 'ontime-types';
|
||||
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { tooltipDelayFast, tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
import style from './TimeInputFlow.module.scss';
|
||||
|
||||
@@ -66,10 +66,12 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
|
||||
placeholder='Start'
|
||||
disabled={Boolean(linkStart)}
|
||||
>
|
||||
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
|
||||
<span className={style.timeLabel}>S</span>
|
||||
<span className={style.fourtyfive}>{linkStart ? <IoLink /> : <IoUnlink />}</span>
|
||||
</InputRightElement>
|
||||
<Tooltip label='Link start to previous end' openDelay={tooltipDelayMid}>
|
||||
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
|
||||
<span className={style.timeLabel}>S</span>
|
||||
<span className={style.fourtyfive}>{linkStart ? <IoLink /> : <IoUnlink />}</span>
|
||||
</InputRightElement>
|
||||
</Tooltip>
|
||||
</TimeInputWithButton>
|
||||
|
||||
<TimeInputWithButton<TimeActions>
|
||||
@@ -80,14 +82,16 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
|
||||
disabled={isLockedDuration}
|
||||
placeholder='End'
|
||||
>
|
||||
<InputRightElement
|
||||
className={activeEnd}
|
||||
onClick={() => handleChangeStrategy(TimeStrategy.LockEnd)}
|
||||
data-testid='lock__end'
|
||||
>
|
||||
<span className={style.timeLabel}>E</span>
|
||||
{isLockedEnd ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||
</InputRightElement>
|
||||
<Tooltip label='Lock end' openDelay={tooltipDelayMid}>
|
||||
<InputRightElement
|
||||
className={activeEnd}
|
||||
onClick={() => handleChangeStrategy(TimeStrategy.LockEnd)}
|
||||
data-testid='lock__end'
|
||||
>
|
||||
<span className={style.timeLabel}>E</span>
|
||||
{isLockedEnd ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||
</InputRightElement>
|
||||
</Tooltip>
|
||||
</TimeInputWithButton>
|
||||
|
||||
<TimeInputWithButton<TimeActions>
|
||||
@@ -97,14 +101,16 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
|
||||
disabled={isLockedEnd}
|
||||
placeholder='Duration'
|
||||
>
|
||||
<InputRightElement
|
||||
className={activeDuration}
|
||||
onClick={() => handleChangeStrategy(TimeStrategy.LockDuration)}
|
||||
data-testid='lock__duration'
|
||||
>
|
||||
<span className={style.timeLabel}>D</span>
|
||||
{isLockedDuration ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||
</InputRightElement>
|
||||
<Tooltip label='Lock duration' openDelay={tooltipDelayMid}>
|
||||
<InputRightElement
|
||||
className={activeDuration}
|
||||
onClick={() => handleChangeStrategy(TimeStrategy.LockDuration)}
|
||||
data-testid='lock__duration'
|
||||
>
|
||||
<span className={style.timeLabel}>D</span>
|
||||
{isLockedDuration ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||
</InputRightElement>
|
||||
</Tooltip>
|
||||
</TimeInputWithButton>
|
||||
|
||||
{overMidnight && (
|
||||
|
||||
@@ -2,13 +2,13 @@ import { ComponentType, useMemo } from 'react';
|
||||
import { ViewExtendedTimer } from 'common/models/TimeManager.type';
|
||||
import {
|
||||
CustomFields,
|
||||
Message,
|
||||
MessageState,
|
||||
OntimeEvent,
|
||||
ProjectData,
|
||||
Runtime,
|
||||
Settings,
|
||||
SimpleTimerState,
|
||||
SupportedEvent,
|
||||
TimerMessage,
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
import { useStore } from 'zustand';
|
||||
@@ -23,17 +23,17 @@ 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;
|
||||
@@ -68,7 +68,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
||||
}, [rundownData]);
|
||||
|
||||
// websocket data
|
||||
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime } =
|
||||
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime, auxtimer1 } =
|
||||
useStore(runtimeStore);
|
||||
const publicSelectedId = publicEventNow?.id ?? null;
|
||||
const selectedId = eventNow?.id ?? null;
|
||||
@@ -86,27 +86,22 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
||||
timerType: eventNow?.timerType ?? null,
|
||||
};
|
||||
|
||||
// prevent render until we get all the data we need
|
||||
if (!viewSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewNavigationMenu />
|
||||
<Component
|
||||
{...props}
|
||||
auxTimer={auxtimer1}
|
||||
backstageEvents={rundownData}
|
||||
customFields={customFields}
|
||||
eventNext={eventNext}
|
||||
eventNow={eventNow}
|
||||
events={publicEvents}
|
||||
external={message.external}
|
||||
general={project}
|
||||
isMirrored={isMirrored}
|
||||
message={message}
|
||||
nextId={nextId}
|
||||
onAir={onAir}
|
||||
pres={message.timer}
|
||||
publicEventNext={publicEventNext}
|
||||
publicEventNow={publicEventNow}
|
||||
publicSelectedId={publicSelectedId}
|
||||
|
||||
@@ -2,17 +2,15 @@ import { useEffect, useState } from 'react';
|
||||
import QRCode from 'react-qr-code';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { CustomFields, OntimeEvent, ProjectData, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
|
||||
import { CustomFields, OntimeEvent, ProjectData, Settings, SupportedEvent } from 'ontime-types';
|
||||
import { millisToString, removeLeadingZero } from 'ontime-utils';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import ProgressBar from '../../../common/components/progress-bar/ProgressBar';
|
||||
import Schedule from '../../../common/components/schedule/Schedule';
|
||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { timerPlaceholderMin } from '../../../common/utils/styleUtils';
|
||||
@@ -37,25 +35,12 @@ interface BackstageProps {
|
||||
backstageEvents: OntimeEvent[];
|
||||
selectedId: string | null;
|
||||
general: ProjectData;
|
||||
viewSettings: ViewSettings;
|
||||
settings: Settings | undefined;
|
||||
}
|
||||
|
||||
export default function Backstage(props: BackstageProps) {
|
||||
const {
|
||||
customFields,
|
||||
isMirrored,
|
||||
eventNow,
|
||||
eventNext,
|
||||
time,
|
||||
backstageEvents,
|
||||
selectedId,
|
||||
general,
|
||||
viewSettings,
|
||||
settings,
|
||||
} = props;
|
||||
const { customFields, isMirrored, eventNow, eventNext, time, backstageEvents, selectedId, general, settings } = props;
|
||||
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const [blinkClass, setBlinkClass] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -73,11 +58,6 @@ export default function Backstage(props: BackstageProps) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [selectedId]);
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clock = formatTime(time.clock);
|
||||
const startedAt = formatTime(time.startedAt);
|
||||
const isNegative = (time.current ?? 0) < 0;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Settings, ViewSettings } from 'ontime-types';
|
||||
import { Settings } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { OverridableOptions } from '../../../common/models/View.types';
|
||||
@@ -17,22 +15,15 @@ import './Clock.scss';
|
||||
interface ClockProps {
|
||||
isMirrored: boolean;
|
||||
time: ViewExtendedTimer;
|
||||
viewSettings: ViewSettings;
|
||||
settings: Settings | undefined;
|
||||
}
|
||||
|
||||
export default function Clock(props: ClockProps) {
|
||||
const { isMirrored, time, viewSettings, settings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { isMirrored, time, settings } = props;
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useWindowTitle('Clock');
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// get config from url: key, text, font, size, hidenav
|
||||
// eg. http://localhost:3000/clock?key=f00&text=fff
|
||||
// Check for user options
|
||||
|
||||
@@ -8,26 +8,23 @@ export const getClockOptions = (timeFormat: string): ViewOption[] => [
|
||||
{
|
||||
id: 'key',
|
||||
title: 'Key Colour',
|
||||
description: 'Background colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '00000000 (default)',
|
||||
description: 'Background or key colour for entire view. Default: #000000',
|
||||
type: 'colour',
|
||||
defaultValue: '000000',
|
||||
},
|
||||
{
|
||||
id: 'text',
|
||||
title: 'Text Colour',
|
||||
description: 'Text colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: 'fffff (default)',
|
||||
description: 'Text colour. Default: #FFFFFF',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFFFFF',
|
||||
},
|
||||
{
|
||||
id: 'textbg',
|
||||
title: 'Text Background',
|
||||
description: 'Colour of text background in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '00000000 (default)',
|
||||
description: 'Background colour for timer text. Default: #FFF0 (transparent)',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFF0',
|
||||
},
|
||||
{
|
||||
id: 'font',
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
OntimeEvent,
|
||||
OntimeRundownEntry,
|
||||
Playback,
|
||||
Runtime,
|
||||
Settings,
|
||||
SupportedEvent,
|
||||
TimerPhase,
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
import { OntimeEvent, OntimeRundownEntry, Playback, Runtime, Settings, SupportedEvent, TimerPhase } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
||||
@@ -34,12 +23,10 @@ interface CountdownProps {
|
||||
selectedId: string | null;
|
||||
settings: Settings | undefined;
|
||||
time: ViewExtendedTimer;
|
||||
viewSettings: ViewSettings;
|
||||
}
|
||||
|
||||
export default function Countdown(props: CountdownProps) {
|
||||
const { isMirrored, backstageEvents, runtime, selectedId, settings, time, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { isMirrored, backstageEvents, runtime, selectedId, settings, time } = props;
|
||||
const [searchParams] = useSearchParams();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
@@ -80,11 +67,6 @@ export default function Countdown(props: CountdownProps) {
|
||||
}
|
||||
}, [backstageEvents, searchParams]);
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { message: runningMessage, timer: runningTimer } = fetchTimerData(time, follow, selectedId, runtime.offset);
|
||||
|
||||
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
|
||||
|
||||
@@ -39,16 +39,16 @@ const defaultOptions: Readonly<LowerOptions> = {
|
||||
width: 45,
|
||||
topSrc: 'title',
|
||||
bottomSrc: 'lowerMsg',
|
||||
topColour: '000000ff',
|
||||
bottomColour: '000000ff',
|
||||
topBg: '00000000',
|
||||
bottomBg: '00000000',
|
||||
topColour: '000000',
|
||||
bottomColour: '000000',
|
||||
topBg: 'FFF0',
|
||||
bottomBg: 'FFF0',
|
||||
topSize: '65px',
|
||||
bottomSize: '40px',
|
||||
transition: 3,
|
||||
delay: 3,
|
||||
key: 'ffffffff',
|
||||
lineColour: 'ff0000ff',
|
||||
key: 'FFF0',
|
||||
lineColour: 'FF0000',
|
||||
lineHeight: '0.4em',
|
||||
};
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@ import { ViewOption } from '../../../common/components/view-params-editor/types'
|
||||
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
|
||||
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
|
||||
title: 'Title',
|
||||
note: 'Note',
|
||||
});
|
||||
|
||||
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
|
||||
title: 'Title',
|
||||
note: 'Note',
|
||||
none: 'None',
|
||||
});
|
||||
|
||||
@@ -31,39 +33,22 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
|
||||
values: bottomSourceOptions,
|
||||
defaultValue: 'none',
|
||||
},
|
||||
{ section: 'View animation' },
|
||||
{
|
||||
id: 'transition',
|
||||
title: 'Transition',
|
||||
description: 'Transition in time in seconds (default 3)',
|
||||
type: 'number',
|
||||
placeholder: '3 (default)',
|
||||
},
|
||||
{
|
||||
id: 'delay',
|
||||
title: 'Delay',
|
||||
description: 'Delay between transition in and out in seconds (default 3)',
|
||||
type: 'number',
|
||||
placeholder: '3 (default)',
|
||||
},
|
||||
{ section: 'View style override' },
|
||||
{
|
||||
id: 'top-colour',
|
||||
title: 'Top Text Colour',
|
||||
description: 'Top text colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '0000ff (default)',
|
||||
},
|
||||
{
|
||||
id: 'bottom-colour',
|
||||
title: 'Bottom Text Colour',
|
||||
description: 'Bottom text colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '0000ff (default)',
|
||||
},
|
||||
{
|
||||
id: 'top-bg',
|
||||
title: 'Top Background Colour',
|
||||
description: 'Top text background colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '00000000 (default)',
|
||||
},
|
||||
{
|
||||
id: 'bottom-bg',
|
||||
title: 'Bottom Background Colour',
|
||||
description: 'Bottom text background colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '00000000 (default)',
|
||||
},
|
||||
{
|
||||
id: 'top-size',
|
||||
title: 'Top Text Size',
|
||||
@@ -86,35 +71,47 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
|
||||
prefix: '%',
|
||||
placeholder: '45 (default)',
|
||||
},
|
||||
{
|
||||
id: 'transition',
|
||||
title: 'Transition',
|
||||
description: 'Transition in time in seconds (default 3)',
|
||||
type: 'number',
|
||||
placeholder: '3 (default)',
|
||||
},
|
||||
{
|
||||
id: 'delay',
|
||||
title: 'Delay',
|
||||
description: 'Delay between transition in and out in seconds (default 3)',
|
||||
type: 'number',
|
||||
placeholder: '3 (default)',
|
||||
},
|
||||
{
|
||||
id: 'key',
|
||||
title: 'Key Colour',
|
||||
description: 'Colour of the background',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: 'ffffffff (default)',
|
||||
description: 'Colour of the background. Default: #FFF0 (transparent)',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFF0',
|
||||
},
|
||||
{
|
||||
id: 'top-colour',
|
||||
title: 'Top Text Colour',
|
||||
description: 'Top text colour. Default: #000000',
|
||||
type: 'colour',
|
||||
defaultValue: '000000',
|
||||
},
|
||||
{
|
||||
id: 'bottom-colour',
|
||||
title: 'Bottom Text Colour',
|
||||
description: 'Bottom text colour. Default: #000000',
|
||||
type: 'colour',
|
||||
defaultValue: '000000',
|
||||
},
|
||||
{
|
||||
id: 'top-bg',
|
||||
title: 'Top Background Colour',
|
||||
description: 'Top text background colour. Default: #FFF0 (transparent)',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFF0',
|
||||
},
|
||||
{
|
||||
id: 'bottom-bg',
|
||||
title: 'Bottom Background Colour',
|
||||
description: 'Bottom text background colour. Default: #FFF0 (transparent)',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFF0',
|
||||
},
|
||||
{
|
||||
id: 'line-colour',
|
||||
title: 'Line Colour',
|
||||
description: 'Colour of the line',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: 'ff0000ff (default)',
|
||||
description: 'Colour of the line. Default: #FF0000',
|
||||
type: 'colour',
|
||||
defaultValue: 'FF0000',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { OverridableOptions } from '../../../common/models/View.types';
|
||||
@@ -22,17 +20,11 @@ interface MinimalTimerProps {
|
||||
|
||||
export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
const { isMirrored, time, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useWindowTitle('Minimal Timer');
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: this should be tied to the params
|
||||
// USER OPTIONS
|
||||
const userOptions: OverridableOptions = {
|
||||
@@ -169,6 +161,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
<div
|
||||
className={timerClasses}
|
||||
style={{
|
||||
color: userOptions.textColour,
|
||||
fontSize: `${timerFontSize}vw`,
|
||||
fontFamily: userOptions.font,
|
||||
top: userOptions.top,
|
||||
|
||||
@@ -23,26 +23,23 @@ export const MINIMAL_TIMER_OPTIONS: ViewOption[] = [
|
||||
{
|
||||
id: 'key',
|
||||
title: 'Key Colour',
|
||||
description: 'Background colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '00000000 (default)',
|
||||
description: 'Background or key colour for entire view. Default: #000000',
|
||||
type: 'colour',
|
||||
defaultValue: '000000',
|
||||
},
|
||||
{
|
||||
id: 'text',
|
||||
title: 'Text Colour',
|
||||
description: 'Text colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: 'fffff (default)',
|
||||
description: 'Text colour. Default: #FFFFFF',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFFFFF',
|
||||
},
|
||||
{
|
||||
id: 'textbg',
|
||||
title: 'Text Background',
|
||||
description: 'Colour of text background in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '00000000 (default)',
|
||||
description: 'Background colour for timer text. Default: #FFF0 (transparent)',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFF0',
|
||||
},
|
||||
{
|
||||
id: 'font',
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import QRCode from 'react-qr-code';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { CustomFields, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
|
||||
import { CustomFields, OntimeEvent, ProjectData, Settings } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import Schedule from '../../../common/components/schedule/Schedule';
|
||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
||||
@@ -33,7 +31,6 @@ interface BackstageProps {
|
||||
events: OntimeEvent[];
|
||||
publicSelectedId: string | null;
|
||||
general: ProjectData;
|
||||
viewSettings: ViewSettings;
|
||||
settings: Settings | undefined;
|
||||
}
|
||||
|
||||
@@ -47,21 +44,14 @@ export default function Public(props: BackstageProps) {
|
||||
events,
|
||||
publicSelectedId,
|
||||
general,
|
||||
viewSettings,
|
||||
settings,
|
||||
} = props;
|
||||
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useWindowTitle('Public Schedule');
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clock = formatTime(time.clock);
|
||||
const qrSize = Math.max(window.innerWidth / 15, 128);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getTimeOption, makeOptionsFromCustomFields } from '../../../common/comp
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||
|
||||
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields);
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
||||
|
||||
return [
|
||||
{ section: 'Clock Options' },
|
||||
|
||||
@@ -120,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,12 +1,10 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import type { MaybeString, OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types';
|
||||
import type { MaybeString, OntimeEvent, OntimeRundown, Settings } 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';
|
||||
@@ -25,17 +23,11 @@ interface StudioClockProps {
|
||||
selectedId: MaybeString;
|
||||
nextId: MaybeString;
|
||||
onAir: boolean;
|
||||
viewSettings: ViewSettings;
|
||||
settings: Settings | undefined;
|
||||
}
|
||||
|
||||
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 { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, settings } = props;
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
@@ -77,13 +69,7 @@ export default function StudioClock(props: StudioClockProps) {
|
||||
<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' : ''}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { useViewportSize } from '@mantine/hooks';
|
||||
import { isOntimeEvent, MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||
import { checkIsNextDay, dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import { useTimelineOverview } from '../../../common/hooks/useSocket';
|
||||
|
||||
import TimelineMarkers from './timeline-markers/TimelineMarkers';
|
||||
import ProgressBar 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 {
|
||||
selectedEventId: string | null;
|
||||
rundown: OntimeEvent[];
|
||||
}
|
||||
|
||||
export default memo(Timeline);
|
||||
|
||||
function Timeline(props: TimelineProps) {
|
||||
const { selectedEventId, rundown } = props;
|
||||
const { width: screenWidth } = useViewportSize();
|
||||
const { plannedStart, plannedEnd } = useTimelineOverview();
|
||||
|
||||
if (plannedStart === null || plannedEnd === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { lastEvent } = getLastEvent(rundown);
|
||||
const startHour = getStartHour(plannedStart);
|
||||
const endHour = getEndHour(plannedEnd + (lastEvent?.delay ?? 0));
|
||||
|
||||
let hasTimelinePassedMidnight = false;
|
||||
let previousEventStartTime: MaybeNumber = null;
|
||||
// we use selectedEventId as a signifier on whether the timeline is live
|
||||
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
|
||||
|
||||
return (
|
||||
<div className={style.timeline}>
|
||||
<TimelineMarkers startHour={startHour} endHour={endHour} />
|
||||
<ProgressBar startHour={startHour} endHour={endHour} />
|
||||
<div className={style.timelineEvents}>
|
||||
{rundown.map((event) => {
|
||||
// for now we dont render delays and blocks
|
||||
if (!isOntimeEvent(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// keep track of progress of rundown
|
||||
if (eventStatus === 'live') {
|
||||
eventStatus = 'future';
|
||||
}
|
||||
if (event.id === selectedEventId) {
|
||||
eventStatus = 'live';
|
||||
}
|
||||
|
||||
if (!hasTimelinePassedMidnight) {
|
||||
// we need to offset the start to account for midnight
|
||||
hasTimelinePassedMidnight = previousEventStartTime !== null && event.timeStart < previousEventStartTime;
|
||||
}
|
||||
// TODO: timeline must accumulate normalised time over days
|
||||
const isNextDay =
|
||||
previousEventStartTime !== null
|
||||
? checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
|
||||
: false;
|
||||
const normalisedStart = hasTimelinePassedMidnight || isNextDay ? event.timeStart + dayInMs : event.timeStart;
|
||||
previousEventStartTime = normalisedStart;
|
||||
|
||||
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
|
||||
startHour * MILLIS_PER_HOUR,
|
||||
endHour * MILLIS_PER_HOUR,
|
||||
normalisedStart + (event.delay ?? 0),
|
||||
event.duration,
|
||||
screenWidth,
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
.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
@@ -1,25 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { isOntimeEvent, MaybeString, OntimeEvent } from 'ontime-types';
|
||||
import {
|
||||
dayInMs,
|
||||
getEventWithId,
|
||||
getFirstEvent,
|
||||
getNextEvent,
|
||||
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);
|
||||
}
|
||||
|
||||
export function getScopedRundown(rundown: OntimeEvent[], selectedEventId: MaybeString): OntimeEvent[] {
|
||||
if (rundown.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const params = new URL(document.location.href).searchParams;
|
||||
const hideBackstage = isStringBoolean(params.get('hideBackstage'));
|
||||
const hidePast = isStringBoolean(params.get('hidePast'));
|
||||
|
||||
let scopedRundown = [...rundown];
|
||||
|
||||
if (hidePast && selectedEventId) {
|
||||
const currentIndex = rundown.findIndex((event) => event.id === selectedEventId);
|
||||
if (currentIndex >= 0) {
|
||||
scopedRundown = scopedRundown.slice(currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (hideBackstage) {
|
||||
scopedRundown = scopedRundown.filter((event) => event.isPublic);
|
||||
}
|
||||
|
||||
return scopedRundown;
|
||||
}
|
||||
|
||||
type UpcomingEvents = {
|
||||
now: OntimeEvent | null;
|
||||
next: OntimeEvent | null;
|
||||
followedBy: OntimeEvent | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns upcoming events from current: now, next and followedBy
|
||||
*/
|
||||
export function getUpcomingEvents(events: OntimeEvent[], selectedId: MaybeString): UpcomingEvents {
|
||||
if (events.length === 0) {
|
||||
return { now: null, next: null, followedBy: null };
|
||||
}
|
||||
|
||||
const now = selectedId ? getEventWithId(events, selectedId) : getFirstEvent(events)?.firstEvent;
|
||||
|
||||
if (!isOntimeEvent(now)) {
|
||||
return { now: null, next: null, followedBy: null };
|
||||
}
|
||||
|
||||
const next = getNextEvent(events, now.id)?.nextEvent;
|
||||
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,21 +2,20 @@ 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';
|
||||
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';
|
||||
@@ -47,31 +46,25 @@ 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();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useWindowTitle('Timer');
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// USER OPTIONS
|
||||
const userOptions = {
|
||||
hideClock: false,
|
||||
@@ -113,7 +106,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 +120,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 +148,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 +164,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 +195,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>
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||
|
||||
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title' });
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
||||
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
||||
|
||||
return [
|
||||
{ section: 'Clock Options' },
|
||||
getTimeOption(timeFormat),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const ontimeAlertOnDark = {
|
||||
container: {
|
||||
fontSize: 'calc(1rem - 1px)',
|
||||
backgroundColor: '#1a1a1a', // $gray-1300
|
||||
backgroundColor: '#202020', // $gray-1200
|
||||
color: '#e2e2e2', // $gray-200
|
||||
borderRadius: '3px',
|
||||
},
|
||||
|
||||
@@ -21,3 +21,11 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@mixin rotate-fourty-five {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.fourtyfive {
|
||||
@include rotate-fourty-five;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
$transition-time-action: 0.1s;
|
||||
$transition-time-feedback: 0.3s;
|
||||
|
||||
$blinking-time: 1.2s;
|
||||
|
||||
$component-border-radius-md: 3px;
|
||||
$component-border-radius-sm: 2px;
|
||||
|
||||
@@ -61,10 +59,13 @@ $text-body-size: calc(1rem - 1px);
|
||||
$min-tablet: 500px;
|
||||
|
||||
.blink {
|
||||
animation: blink $blinking-time linear infinite;
|
||||
animation: blink 1s step-start infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% {
|
||||
opacity: 100%;
|
||||
}
|
||||
50% {
|
||||
opacity: 20%;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ $viewer-background-color: $ui-black; // --background-color-override
|
||||
$viewer-color: rgba(white, 80%); // --color-override
|
||||
$viewer-secondary-color: rgba(white, 45%); // --secondary-color-override
|
||||
$viewer-card-bg-color: rgba(white, 7%); // --card-background-color-override
|
||||
$viewer-overlay-bg-color: rgba(black, 90%);
|
||||
$element-border-radius: 8px;
|
||||
|
||||
// Properties related to timer
|
||||
@@ -27,8 +26,8 @@ $timer-color: rgba(white, 80%); // --timer-color-override
|
||||
$timer-finished-color: $playback-negative;
|
||||
$timer-bold-font-family: 'Arial Black', sans-serif; // --card-background-color-override
|
||||
|
||||
$external-color: rgba(white, 70%); // --external-color-override
|
||||
$external-color: rgba(white, 85%); // --external-color-override
|
||||
|
||||
// properties of other timers (clock and countdown)
|
||||
$timer-label-size: clamp(16px, 1.5vw, 24px);
|
||||
$timer-value-size: clamp(2rem, 3.5vw, 3.5rem);
|
||||
$timer-value-size: clamp(2rem, 3.5vw, 3.5rem);
|
||||
|
||||
@@ -4,6 +4,8 @@ export const ontimeDrawer = {
|
||||
backgroundColor: '#202020', // $gray-1250
|
||||
},
|
||||
body: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
color: '#fefefe', // $gray-50
|
||||
backgroundColor: '#202020', // $gray-1250
|
||||
},
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export const navigatorConstants = [
|
||||
{ url: '/timer', label: 'Timer' },
|
||||
{ url: '/clock', label: 'Clock' },
|
||||
{ url: '/minimal', label: 'Minimal Timer' },
|
||||
{ url: '/backstage', label: 'Backstage' },
|
||||
{ url: '/timeline', label: 'Timeline (beta)' },
|
||||
{ url: '/public', label: 'Public' },
|
||||
{ url: '/lower', label: 'Lower Thirds' },
|
||||
{ url: '/studio', label: 'Studio Clock' },
|
||||
{ url: '/countdown', label: 'Countdown' },
|
||||
{ url: 'timer', label: 'Timer' },
|
||||
{ url: 'minimal', label: 'Minimal Timer' },
|
||||
{ url: 'clock', label: 'Wall Clock' },
|
||||
{ url: 'backstage', label: 'Backstage' },
|
||||
{ url: 'timeline', label: 'Timeline (beta)' },
|
||||
{ url: 'public', label: 'Public' },
|
||||
{ url: 'lower', label: 'Lower Thirds' },
|
||||
{ url: 'studio', label: 'Studio Clock' },
|
||||
{ url: 'countdown', label: 'Countdown' },
|
||||
];
|
||||
|
||||
// default time format to use for users in 12 hour clocks
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
@use '../theme/viewerDefs' as *;
|
||||
|
||||
$dot-size: 0.5rem;
|
||||
$dot-spacing: 1.5rem;
|
||||
|
||||
.loader {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background-color: var(--background-color-override, $viewer-background-color);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 5rem;
|
||||
|
||||
div {
|
||||
position: absolute;
|
||||
width: $dot-size;
|
||||
height: $dot-size;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent-color-override, $ontime-color);
|
||||
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
||||
|
||||
&:nth-child(1) {
|
||||
left: $dot-size;
|
||||
animation: lds-ellipsis1 0.6s infinite;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
left: $dot-size;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
left: calc($dot-size + $dot-spacing);
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
left: calc($dot-size + 2 * $dot-spacing);
|
||||
animation: lds-ellipsis3 0.6s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lds-ellipsis1 {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lds-ellipsis2 {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate($dot-spacing, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lds-ellipsis3 {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { overrideStylesURL } from '../common/api/constants';
|
||||
import { useRuntimeStylesheet } from '../common/hooks/useRuntimeStylesheet';
|
||||
import useViewSettings from '../common/hooks-query/useViewSettings';
|
||||
|
||||
import style from './ViewLoader.module.scss';
|
||||
|
||||
export default function ViewLoader({ children }: PropsWithChildren) {
|
||||
const { data } = useViewSettings();
|
||||
const { shouldRender } = useRuntimeStylesheet(data.overrideStyles && overrideStylesURL);
|
||||
|
||||
// eventually we would want to leverage suspense here
|
||||
// while the feature is not ready, we simply trigger a loader
|
||||
// suspense would have the advantage of being triggered also by react-query
|
||||
|
||||
if (!shouldRender) {
|
||||
return (
|
||||
<div className={style.loader}>
|
||||
<div className={style.ellipsis}>
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment -- ensuring JSX return
|
||||
return <>{children}</>;
|
||||
}
|
||||
+24
-22
@@ -1,39 +1,40 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
@use '../../theme/viewerDefs' as *;
|
||||
|
||||
$timeline-entry-height: 20px;
|
||||
$lane-height: 120px;
|
||||
$timeline-height: 1rem;
|
||||
$timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-override, $viewer-background-color));
|
||||
|
||||
.timeline {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
color: $ui-white;
|
||||
background-color: $ui-black;
|
||||
}
|
||||
|
||||
.timelineEvents {
|
||||
color: var(--color-override, $viewer-color);
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
box-sizing: content-box;
|
||||
// create progress background
|
||||
box-shadow: inset 0 1rem 0 0 var(--card-background-color-override, $viewer-card-bg-color);
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
border-left: 1px solid $ui-black;
|
||||
border-left: 1px solid var(--background-color-override, $viewer-background-color);
|
||||
// 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;
|
||||
// generate combined timeline
|
||||
.timelineBlock {
|
||||
height: $timeline-height;
|
||||
background: $timeline-color;
|
||||
width: 100%;
|
||||
|
||||
&[data-status='done'] {
|
||||
background: $active-red;
|
||||
}
|
||||
|
||||
&[data-status='live'] {
|
||||
background: linear-gradient(to right, $active-red var(--progress, 0%), $timeline-color var(--progress, 0%));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +66,9 @@ $timeline-height: 1rem;
|
||||
overflow: hidden;
|
||||
line-height: 1rem;
|
||||
|
||||
background-color: var(--lighter, $viewer-card-bg-color);
|
||||
border-bottom: 2px solid $ui-black;
|
||||
background-color: var(--lighter, var(--card-background-color-override, $viewer-card-bg-color));
|
||||
border-bottom: 2px solid var(--background-color-override, $viewer-background-color);
|
||||
border-top: 2px solid var(--background-color-override, $viewer-background-color);
|
||||
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
|
||||
|
||||
&[data-status='done'] {
|
||||
@@ -0,0 +1,91 @@
|
||||
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 { 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} />
|
||||
{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>
|
||||
);
|
||||
}
|
||||
+20
-10
@@ -1,9 +1,10 @@
|
||||
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 { useTimelineStatus, useTimer } from '../../common/hooks/useSocket';
|
||||
import { getProgress } from '../../common/utils/getProgress';
|
||||
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 { getStatusLabel, getTimeToStart } from './timeline.utils';
|
||||
|
||||
import style from './Timeline.module.scss';
|
||||
|
||||
@@ -48,6 +49,7 @@ export function TimelineEntry(props: TimelineEntryProps) {
|
||||
width: `${width}px`,
|
||||
}}
|
||||
>
|
||||
{status === 'live' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />}
|
||||
<div
|
||||
className={contentClasses}
|
||||
data-status={status}
|
||||
@@ -63,7 +65,7 @@ export function TimelineEntry(props: TimelineEntryProps) {
|
||||
{status !== 'done' && (
|
||||
<>
|
||||
<div className={style.duration}>{formattedDuration}</div>
|
||||
<TimelineEntryStatus status={status} start={delayedStart} />
|
||||
<TimelineEntryStatus delay={delay} start={start} status={status} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -72,18 +74,19 @@ export function TimelineEntry(props: TimelineEntryProps) {
|
||||
}
|
||||
|
||||
interface TimelineEntryStatusProps {
|
||||
status: ProgressStatus;
|
||||
delay: number;
|
||||
start: number;
|
||||
status: ProgressStatus;
|
||||
}
|
||||
|
||||
// we isolate this component to avoid isolate re-renders provoked by the clock changes
|
||||
// extract component to isolate re-renders provoked by the clock changes
|
||||
function TimelineEntryStatus(props: TimelineEntryStatusProps) {
|
||||
const { status, start } = props;
|
||||
const { delay, start, status } = 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);
|
||||
let statusText = getStatusLabel(getTimeToStart(clock, start, delay, offset), status);
|
||||
if (statusText === 'live') {
|
||||
statusText = getLocalizedString('timeline.live');
|
||||
} else if (statusText === 'pending') {
|
||||
@@ -92,3 +95,10 @@ function TimelineEntryStatus(props: TimelineEntryStatusProps) {
|
||||
|
||||
return <div className={style.status}>{statusText}</div>;
|
||||
}
|
||||
|
||||
/** Generates a block level progress bar */
|
||||
function ActiveBlock() {
|
||||
const { current, duration } = useTimer();
|
||||
const progress = getProgress(current, duration);
|
||||
return <div data-status='live' className={style.timelineBlock} style={{ '--progress': `${progress}%` }} />;
|
||||
}
|
||||
+9
-1
@@ -1,4 +1,4 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
@use '../../theme/viewerDefs' as *;
|
||||
|
||||
.timeline {
|
||||
width: 100vw;
|
||||
@@ -85,6 +85,14 @@
|
||||
font-size: 3rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
|
||||
max-height: 2em;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.section-content--now {
|
||||
+36
-26
@@ -1,29 +1,27 @@
|
||||
import { useMemo } from 'react';
|
||||
import { MaybeString, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
|
||||
import { MaybeString, OntimeEvent, ProjectData, Runtime, Settings } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
|
||||
import { formatDuration, formatTime, getDefaultFormat } from '../../common/utils/time';
|
||||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
|
||||
import Section from './timeline-section/TimelineSection';
|
||||
import Timeline from './Timeline';
|
||||
import { getTimelineOptions } from './timeline.options';
|
||||
import { getFormattedTimeToStart, getScopedRundown, getUpcomingEvents } from './timeline.utils';
|
||||
import { getTimeToStart, getUpcomingEvents, useScopedRundown } from './timeline.utils';
|
||||
|
||||
import './TimelinePage.scss';
|
||||
|
||||
interface TimelinePageProps {
|
||||
backstageEvents: OntimeEvent[];
|
||||
general: ProjectData;
|
||||
runtime: Runtime;
|
||||
selectedId: MaybeString;
|
||||
settings: Settings | undefined;
|
||||
time: ViewExtendedTimer;
|
||||
viewSettings: ViewSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,27 +30,18 @@ interface TimelinePageProps {
|
||||
* 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);
|
||||
|
||||
const { backstageEvents, general, runtime, selectedId, settings, time } = props;
|
||||
// holds copy of the rundown with only relevant events
|
||||
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(backstageEvents, selectedId);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const clock = formatTime(time.clock);
|
||||
|
||||
// holds copy of the rundown with only relevant events
|
||||
const scopedRundown = useMemo(() => {
|
||||
return getScopedRundown(backstageEvents, selectedId);
|
||||
}, [backstageEvents, selectedId]);
|
||||
|
||||
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);
|
||||
@@ -61,10 +50,26 @@ export default function TimelinePage(props: TimelinePageProps) {
|
||||
const dueText = getLocalizedString('timeline.due').toUpperCase();
|
||||
const nextText = next !== null ? next.title : '-';
|
||||
const followedByText = followedBy !== null ? followedBy.title : '-';
|
||||
let nextStatus: string | undefined;
|
||||
let followedByStatus: string | undefined;
|
||||
|
||||
const nextStatus = next !== null ? getFormattedTimeToStart(next, time.clock, dueText) : undefined;
|
||||
const followedByStatus = followedBy !== null ? getFormattedTimeToStart(followedBy, time.clock, dueText) : undefined;
|
||||
if (next !== null) {
|
||||
const timeToStart = getTimeToStart(time.clock, next.timeStart, next?.delay ?? 0, runtime.offset);
|
||||
if (timeToStart < 0) {
|
||||
nextStatus = dueText;
|
||||
} else {
|
||||
nextStatus = `T - ${formatDuration(timeToStart)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (followedBy !== null) {
|
||||
const timeToStart = getTimeToStart(time.clock, followedBy.timeStart, followedBy?.delay ?? 0, runtime.offset);
|
||||
if (timeToStart < 0) {
|
||||
followedByStatus = dueText;
|
||||
} else {
|
||||
followedByStatus = `T - ${formatDuration(timeToStart)}`;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className='timeline'>
|
||||
<ViewParamsEditor viewOptions={progressOptions} />
|
||||
@@ -86,7 +91,12 @@ export default function TimelinePage(props: TimelinePageProps) {
|
||||
category='next'
|
||||
/>
|
||||
</div>
|
||||
<Timeline selectedEventId={selectedId} rundown={scopedRundown} />
|
||||
<Timeline
|
||||
firstStart={firstStart}
|
||||
rundown={scopedRundown}
|
||||
selectedEventId={selectedId}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+34
-8
@@ -1,6 +1,6 @@
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import { getElementPosition, makeTimelineSections } from '../timeline.utils';
|
||||
import { getElementPosition, getTimeToStart, makeTimelineSections } from '../timeline.utils';
|
||||
|
||||
describe('getCSSPosition()', () => {
|
||||
it('accounts for rundown with one event', () => {
|
||||
@@ -53,14 +53,40 @@ describe('getCSSPosition()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeTmelineSections', () => {
|
||||
describe('makeTimelineSections', () => {
|
||||
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']);
|
||||
expect(result).toEqual([11, 12, 13, 14, 15, 16]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeToStart()', () => {
|
||||
it("is the gap between now and the event's start time accounted for delays", () => {
|
||||
const now = 150;
|
||||
const start = 150;
|
||||
const delay = 50;
|
||||
|
||||
const result = getTimeToStart(now, start, delay, 0);
|
||||
expect(result).toBe(50);
|
||||
});
|
||||
|
||||
it('accounts for offsets when running behind', () => {
|
||||
const now = 150;
|
||||
const start = 150;
|
||||
const delay = 50;
|
||||
const offset = -50; // running behind
|
||||
|
||||
const result = getTimeToStart(now, start, delay, offset);
|
||||
expect(result).toBe(50 + 50);
|
||||
});
|
||||
|
||||
it('accounts for offsets when running ahead', () => {
|
||||
const now = 150;
|
||||
const start = 150;
|
||||
const delay = 50;
|
||||
const offset = 10; // running behind
|
||||
|
||||
const result = getTimeToStart(now, start, delay, offset);
|
||||
expect(result).toBe(50 - 10);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user