Compare commits

..

2 Commits

Author SHA1 Message Date
Carlos Valente 281b1649fe fix: ignore 0 duration events 2024-09-04 20:29:43 +02:00
Carlos Valente 652fe25fd1 fix: issue with roll evaluating multiple days 2024-09-04 20:29:38 +02:00
197 changed files with 2309 additions and 3916 deletions
+3
View File
@@ -40,6 +40,9 @@ dist/
ontime-db ontime-db
ontime-external/ ontime-external/
# working database
apps/server/src/preloaded-db/db.json
# versioning file # versioning file
**/ONTIME_VERSION.js **/ONTIME_VERSION.js
+1 -2
View File
@@ -23,7 +23,6 @@ COPY --from=builder /app/apps/client/build ./client/
# Prepare Backend # Prepare Backend
COPY --from=builder /app/apps/server/dist/ ./server/ COPY --from=builder /app/apps/server/dist/ ./server/
COPY --from=builder /app/apps/server/src/external/ ./external/ COPY --from=builder /app/apps/server/src/external/ ./external/
COPY --from=builder /app/apps/server/src/user/ ./user/
# Export default ports # Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp EXPOSE 4001/tcp 8888/udp 9999/udp
@@ -33,4 +32,4 @@ CMD ["node", "server/docker.cjs"]
# Build and run commands # Build and run commands
# !!! Note that this command needs pre-build versions of the UI and server apps # !!! Note that this command needs pre-build versions of the UI and server apps
# docker buildx build . -t getontime/ontime # docker buildx build . -t getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ 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
+18 -19
View File
@@ -14,10 +14,8 @@
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a> - Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a> - Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
## 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? ## Using Ontime?
Let us know! Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback. Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
@@ -37,16 +35,6 @@ Ontime is made by entertainment and broadcast engineers and used by
- Theatres and opera houses - Theatres and opera houses
- Houses of worship - 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 ### For live environments
Ontime is designed for use in live environments. \ Ontime is designed for use in live environments. \
@@ -95,7 +83,6 @@ IP.ADDRESS:4001/clock > Simple clock view
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
IP.ADDRESS:4001/countdown > Countdown to anything IP.ADDRESS:4001/countdown > Countdown to anything
IP.ADDRESS:4001/studio > Studio Clock IP.ADDRESS:4001/studio > Studio Clock
IP.ADDRESS:4001/timeline > Timeline
``` ```
``` ```
@@ -113,13 +100,26 @@ IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/operator > automated views for operators IP.ADDRESS:4001/operator > automated views for operators
``` ```
More information is available [in our docs](https://docs.getontime.no) 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)
## Roadmap ## Roadmap
### Continued development ### Continued development
Ontime is under active development. We continue adding and improving features in collaboration with users. Ontime is under active development. We continue adding and tweaking features in collaboration with users.
Have an idea? Reach out via [email](mail@getontime.no) Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new) or [open an issue](https://github.com/cpvalente/ontime/issues/new)
@@ -161,9 +161,8 @@ 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) Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
## Links ## Links
- [Ontime website](https://getontime.no)
- [Documentation](https://docs.getontime.no) See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no)
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
## License ## License
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.6.2", "version": "3.5.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.6.2", "version": "3.5.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+10 -67
View File
@@ -14,7 +14,6 @@ import { useClientPath } from './common/hooks/useClientPath';
import Log from './features/log/Log'; import Log from './features/log/Log';
import withPreset from './features/PresetWrapper'; import withPreset from './features/PresetWrapper';
import withData from './features/viewers/ViewWrapper'; import withData from './features/viewers/ViewWrapper';
import ViewLoader from './views/ViewLoader';
import { ONTIME_VERSION } from './ONTIME_VERSION'; import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config'; import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
@@ -28,7 +27,7 @@ const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown')); const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage')); const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage')); const Timeline = React.lazy(() => import('./features/viewers/timeline/TimelinePage'));
const Public = React.lazy(() => import('./features/viewers/public/Public')); const Public = React.lazy(() => import('./features/viewers/public/Public'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird')); const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock')); const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
@@ -76,72 +75,16 @@ export default function AppRouter() {
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
<SentryRoutes> <SentryRoutes>
<Route path='/' element={<Navigate to='/timer' />} /> <Route path='/' element={<Navigate to='/timer' />} />
<Route <Route path='/timer' element={<STimer />} />
path='/timer' <Route path='/public' element={<SPublic />} />
element={ <Route path='/minimal' element={<SMinimalTimer />} />
<ViewLoader> <Route path='/clock' element={<SClock />} />
<STimer />
</ViewLoader> <Route path='/countdown' element={<SCountdown />} />
} <Route path='/backstage' element={<SBackstage />} />
/> <Route path='/studio' element={<SStudio />} />
<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='/lower' element={<SLowerThird />} />
<Route <Route path='/timeline' element={<STimeline />} />
path='/timeline'
element={
<ViewLoader>
<STimeline />
</ViewLoader>
}
/>
{/*/!* Protected Routes *!/*/} {/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} /> <Route path='/editor' element={<Editor />} />
+2 -4
View File
@@ -18,7 +18,6 @@ const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws'; const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production'; export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction; export const isDev = !isProduction;
export const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
// resolve port // resolve port
const STATIC_PORT = 4001; const STATIC_PORT = 4001;
@@ -32,6 +31,5 @@ export const projectDataURL = `${serverURL}/project`;
export const rundownURL = `${serverURL}/events`; export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`; export const ontimeURL = `${serverURL}/ontime`;
export const userAssetsPath = 'user'; export const stylesPath = 'external/styles/override.css';
export const cssOverridePath = 'styles/override.css'; export const overrideStylesURL = `${serverURL}/${stylesPath}`;
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
+9 -1
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types'; import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils'; import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
@@ -138,6 +138,14 @@ export async function deleteProject(filename: string): Promise<MessageResponse>
return res.data; 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 * Utility function gets project from db
* @param fileName * @param fileName
-14
View File
@@ -1,14 +0,0 @@
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 { .linkIcon {
margin-left: $element-inner-spacing; margin-left: $element-inner-spacing;
display: inline-block; display: inline-block;
@include rotate-fourty-five; transform: rotate(45deg);
} }
@@ -1,6 +1,5 @@
import { PropsWithChildren, useState } from 'react'; import { PropsWithChildren } from 'react';
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/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 { IoCopy } from '@react-icons/all-files/io5/IoCopy';
import { tooltipDelayFast } from '../../../ontimeConfig'; import { tooltipDelayFast } from '../../../ontimeConfig';
@@ -8,34 +7,26 @@ import { Size } from '../../models/Util.type';
import copyToClipboard from '../../utils/copyToClipboard'; import copyToClipboard from '../../utils/copyToClipboard';
interface CopyTagProps { interface CopyTagProps {
copyValue: string;
label: string; label: string;
className?: string;
size?: Size; size?: Size;
disabled?: boolean; disabled?: boolean;
onClick?: () => void;
} }
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) { export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
const { copyValue, label, size = 'xs', disabled, children, onClick } = props; const { label, className, size = 'xs', disabled, children } = props;
const [copied, setCopied] = useState(false);
const handleClick = () => { const handleClick = () => copyToClipboard(children as string);
copyToClipboard(copyValue);
setCopied(true);
// reset copied state
setTimeout(() => setCopied(false), 1000);
};
return ( return (
<Tooltip label={label} openDelay={tooltipDelayFast}> <Tooltip label={label} openDelay={tooltipDelayFast}>
<ButtonGroup size={size} isAttached> <ButtonGroup size={size} isAttached className={className}>
<Button variant='ontime-subtle' tabIndex={-1} onClick={onClick} isDisabled={disabled}> <Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
{children} {children}
</Button> </Button>
<IconButton <IconButton
aria-label={label} aria-label={label}
icon={copied ? <IoCheckmark /> : <IoCopy />} icon={<IoCopy />}
variant='ontime-filled' variant='ontime-filled'
tabIndex={-1} tabIndex={-1}
onClick={handleClick} onClick={handleClick}
@@ -1,63 +0,0 @@
/**
* 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>
);
}
@@ -1,18 +0,0 @@
/**
* @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,8 +47,6 @@ $button-size: 3rem;
@include action-link; @include action-link;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
gap: 0.5rem; gap: 0.5rem;
width: 100%;
cursor: pointer;
&:hover { &:hover {
background-color: $menu-hover-bg; background-color: $menu-hover-bg;
@@ -72,33 +70,9 @@ $button-size: 3rem;
.linkIcon { .linkIcon {
margin-left: auto; margin-left: auto;
@include rotate-fourty-five; transform: rotate(45deg);
} }
.separator { .separator {
border-color: $border-color-ondark; 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, PropsWithChildren, useRef } from 'react'; import { memo, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Link, useLocation } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
Drawer, Drawer,
DrawerBody, DrawerBody,
@@ -18,17 +18,11 @@ import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutl
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical'; import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { isLocalhost, serverPort } from '../../api/constants';
import useClickOutside from '../../hooks/useClickOutside'; import useClickOutside from '../../hooks/useClickOutside';
import useElectronEvent from '../../hooks/useElectronEvent';
import useInfo from '../../hooks-query/useInfo';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent'; import { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks, openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import { RenameClientModal } from '../client-modal/RenameClientModal'; import { RenameClientModal } from '../client-modal/RenameClientModal';
import CopyTag from '../copy-tag/CopyTag';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -46,7 +40,6 @@ function NavigationMenu(props: NavigationMenuProps) {
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure(); const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore(); const { toggleMirror } = useViewOptionsStore();
const location = useLocation();
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
@@ -57,7 +50,7 @@ function NavigationMenu(props: NavigationMenuProps) {
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} /> <RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'> <Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay /> <DrawerOverlay />
<DrawerContent maxWidth='21rem'> <DrawerContent>
<DrawerHeader> <DrawerHeader>
<DrawerCloseButton size='lg' /> <DrawerCloseButton size='lg' />
Ontime Ontime
@@ -103,27 +96,39 @@ function NavigationMenu(props: NavigationMenuProps) {
<hr className={style.separator} /> <hr className={style.separator} />
<Link <Link
to='/editor' to='/editor'
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
tabIndex={0} tabIndex={0}
className={`${style.link} ${location.pathname === '/editor' && style.current}`}
> >
<IoLockClosedOutline /> <IoLockClosedOutline />
Editor Editor
<IoArrowUp className={style.linkIcon} />
</Link> </Link>
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}> <Link
to='/cuesheet'
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
tabIndex={0}
>
<IoLockClosedOutline /> <IoLockClosedOutline />
Cuesheet Cuesheet
</ClientLink> <IoArrowUp className={style.linkIcon} />
<ClientLink to='op' current={location.pathname === '/op'}> </Link>
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
<IoLockClosedOutline /> <IoLockClosedOutline />
Operator Operator
</ClientLink> <IoArrowUp className={style.linkIcon} />
</Link>
<hr className={style.separator} /> <hr className={style.separator} />
{navigatorConstants.map((route) => ( {navigatorConstants.map((route) => (
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}> <Link
key={route.url}
to={route.url}
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
tabIndex={0}
>
{route.label} {route.label}
</ClientLink> <IoArrowUp className={style.linkIcon} />
</Link>
))} ))}
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
</DrawerBody> </DrawerBody>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
@@ -132,70 +137,4 @@ 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); export default memo(NavigationMenu);
@@ -1,27 +0,0 @@
.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;
}
}
@@ -1,33 +0,0 @@
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,7 +16,6 @@ import {
import { isStringBoolean } from '../../../features/viewers/common/viewUtils'; import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import InlineColourPicker from './InlineColourPicker';
import { ParamField } from './types'; import { ParamField } from './types';
interface EditFormInputProps { interface EditFormInputProps {
@@ -24,8 +23,8 @@ interface EditFormInputProps {
} }
export default function ParamInput(props: EditFormInputProps) { export default function ParamInput(props: EditFormInputProps) {
const { paramField } = props;
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { paramField } = props;
const { id, type, defaultValue } = paramField; const { id, type, defaultValue } = paramField;
if (type === 'persist') { if (type === 'persist') {
@@ -82,12 +81,6 @@ 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 defaultStringValue = searchParams.get(id) ?? defaultValue;
const { prefix, placeholder } = paramField; const { prefix, placeholder } = paramField;
@@ -19,16 +19,6 @@ import style from './ViewParamsEditor.module.scss';
type ViewParamsObj = { [key: string]: string | FormDataEntryValue }; 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 * Makes a new URLSearchParams object from the given params object
*/ */
@@ -50,13 +40,8 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
// compare which values are different from the default values // compare which values are different from the default values
Object.entries(paramsObj).forEach(([id, value]) => { Object.entries(paramsObj).forEach(([id, value]) => {
if (typeof value === 'string' && value.length) { if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
// we dont know which values contain colours newSearchParams.set(id, value);
// 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; return newSearchParams;
@@ -23,11 +23,10 @@ type MultiOptionsField = {
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string }; type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string }; type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean }; type BooleanField = { type: 'boolean'; defaultValue: boolean };
type ColourField = { type: 'colour'; defaultValue: string; placeholder?: string };
type PersistedField = { type: 'persist'; defaultValue?: string; value: string }; type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
export type ParamField = BaseField & export type ParamField = BaseField &
(OptionsField | MultiOptionsField | StringField | NumberField | BooleanField | ColourField | PersistedField); (StringField | BooleanField | NumberField | OptionsField | MultiOptionsField | PersistedField);
export type ViewOption = ParamSection | ParamField; export type ViewOption = ParamSection | ParamField;
/** /**
@@ -3,7 +3,7 @@ import { GetInfo } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_INFO } from '../api/constants'; import { APP_INFO } from '../api/constants';
import { getInfo } from '../api/session'; import { getInfo } from '../api/db';
import { ontimePlaceholderInfo } from '../models/Info'; import { ontimePlaceholderInfo } from '../models/Info';
export default function useInfo() { export default function useInfo() {
@@ -1,31 +1,11 @@
import { useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
const isElectron = window.process?.type === 'renderer';
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
export default function useElectronEvent() { export default function useElectronEvent() {
const navigate = useNavigate(); const isElectron = window?.process?.type === 'renderer';
const sendToElectron = useCallback((channel: string, args?: string | Record<string, unknown>) => { const sendToElectron = (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) { if (isElectron) {
ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => { window?.ipcRenderer.send(channel, args);
navigate(location, { relative: 'route' });
});
} }
};
// Clean the listener after the component is dismounted
return () => {
ipcRenderer?.removeAllListeners();
};
}, [navigate]);
return { isElectron, sendToElectron }; return { isElectron, sendToElectron };
} }
+6 -38
View File
@@ -32,16 +32,7 @@ import { useEditorSettings } from '../stores/editorSettings';
*/ */
export const useEventAction = () => { export const useEventAction = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const { defaultPublic, linkPrevious, defaultDuration, defaultWarnTime, defaultDangerTime } = useEditorSettings();
defaultPublic,
linkPrevious,
defaultTimeStrategy,
defaultDuration,
defaultWarnTime,
defaultDangerTime,
defaultTimerType,
defaultEndAction,
} = useEditorSettings();
/** /**
* Calls mutation to add new event * Calls mutation to add new event
@@ -55,17 +46,17 @@ export const useEventAction = () => {
networkMode: 'always', networkMode: 'always',
}); });
// options to any new block (event / delay / block)
type BaseOptions = { type BaseOptions = {
after?: string; after?: string;
}; };
// options to blocks of type OntimeEvent
type EventOptions = BaseOptions & type EventOptions = BaseOptions &
Partial<{ Partial<{
defaultPublic: boolean; defaultPublic: boolean;
linkPrevious: boolean; linkPrevious: boolean;
lastEventId: string; lastEventId: string;
defaultWarnTime: number;
defaultDangerTime: number;
}>; }>;
/** /**
@@ -77,12 +68,13 @@ export const useEventAction = () => {
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEvent)) { if (isOntimeEvent(newEvent)) {
// merge creation time options with event settings
const applicationOptions = { const applicationOptions = {
after: options?.after, after: options?.after,
defaultPublic: options?.defaultPublic ?? defaultPublic, defaultPublic: options?.defaultPublic ?? defaultPublic,
lastEventId: options?.lastEventId, lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious, linkPrevious: options?.linkPrevious ?? linkPrevious,
defaultWarnTime,
defaultDangerTime,
}; };
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) { if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
@@ -97,7 +89,6 @@ export const useEventAction = () => {
} }
} }
// Override event with options from editor settings
if (applicationOptions.defaultPublic) { if (applicationOptions.defaultPublic) {
newEvent.isPublic = true; newEvent.isPublic = true;
} }
@@ -113,18 +104,6 @@ export const useEventAction = () => {
if (newEvent.timeWarning === undefined) { if (newEvent.timeWarning === undefined) {
newEvent.timeWarning = parseUserTime(defaultWarnTime); 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 // handle adding options that concern all event type
@@ -138,18 +117,7 @@ export const useEventAction = () => {
logAxiosError('Failed adding event', error); logAxiosError('Failed adding event', error);
} }
}, },
[ [_addEventMutation, defaultDangerTime, defaultDuration, defaultPublic, defaultWarnTime, linkPrevious, queryClient],
_addEventMutation,
defaultDangerTime,
defaultDuration,
defaultEndAction,
defaultPublic,
defaultTimerType,
defaultTimeStrategy,
defaultWarnTime,
linkPrevious,
queryClient,
],
); );
/** /**
+159
View File
@@ -0,0 +1,159 @@
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;
+14 -40
View File
@@ -1,4 +1,4 @@
import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types'; import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime'; import { useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
@@ -28,43 +28,11 @@ export const useOperator = () => {
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
export const useTimerViewControl = () => { export const useMessageControl = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
blackout: state.message.timer.blackout, timer: state.message.timer,
blink: state.message.timer.blink, external: state.message.external,
secondarySource: state.message.timer.secondarySource, onAir: state.onAir,
});
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); return useRuntimeStore(featureSelector);
@@ -73,11 +41,8 @@ export const useMessagePreview = () => {
export const setMessage = { export const setMessage = {
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }), timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }), timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
externalText: (payload: string) => socketSendJson('message', { external: payload }),
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }), timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }), timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
timerSecondary: (payload: TimerMessage['secondarySource']) =>
socketSendJson('message', { timer: { secondarySource: payload } }),
}; };
export const usePlaybackControl = () => { export const usePlaybackControl = () => {
@@ -222,6 +187,15 @@ export const useRuntimePlaybackOverview = () => {
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
export const useTimelineOverview = () => {
const featureSelector = (state: RuntimeStore) => ({
plannedStart: state.runtime.plannedStart,
plannedEnd: state.runtime.plannedEnd,
});
return useRuntimeStore(featureSelector);
};
export const useTimelineStatus = () => { export const useTimelineStatus = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
clock: state.clock, clock: state.clock,
+1 -1
View File
@@ -7,5 +7,5 @@ export const ontimePlaceholderInfo: GetInfo = {
version: '2.0.0', version: '2.0.0',
serverPort: 4001, serverPort: 4001,
osc: oscPlaceholderSettings, osc: oscPlaceholderSettings,
publicDir: '', cssOverride: '',
}; };
@@ -1,5 +1,3 @@
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
import { validateEndAction, validateTimerType, validateTimeStrategy } from 'ontime-utils';
import { create } from 'zustand'; import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage'; import { booleanFromLocalStorage } from '../utils/localStorage';
@@ -7,63 +5,39 @@ import { booleanFromLocalStorage } from '../utils/localStorage';
type EditorSettingsStore = { type EditorSettingsStore = {
defaultDuration: string; defaultDuration: string;
linkPrevious: boolean; linkPrevious: boolean;
defaultTimeStrategy: TimeStrategy;
defaultWarnTime: string; defaultWarnTime: string;
defaultDangerTime: string; defaultDangerTime: string;
defaultPublic: boolean; defaultPublic: boolean;
defaultTimerType: TimerType;
defaultEndAction: EndAction;
setDefaultDuration: (defaultDuration: string) => void; setDefaultDuration: (defaultDuration: string) => void;
setLinkPrevious: (linkPrevious: boolean) => void; setLinkPrevious: (linkPrevious: boolean) => void;
setTimeStrategy: (timeStrategy: TimeStrategy) => void;
setWarnTime: (warnTime: string) => void; setWarnTime: (warnTime: string) => void;
setDangerTime: (dangerTime: string) => void; setDangerTime: (dangerTime: string) => void;
setDefaultPublic: (defaultPublic: boolean) => void; setDefaultPublic: (defaultPublic: boolean) => void;
setDefaultTimerType: (defaultTimerType: TimerType) => void;
setDefaultEndAction: (defaultEndAction: EndAction) => void;
}; };
export const editorSettingsDefaults = { export const editorSettingsDefaults = {
duration: '00:10:00', duration: '00:10:00',
linkPrevious: true, linkPrevious: true,
timeStrategy: TimeStrategy.LockDuration,
warnTime: '00:02:00', // 120000 same as backend warnTime: '00:02:00', // 120000 same as backend
dangerTime: '00:01:00', // 60000 same as backend dangerTime: '00:01:00', // 60000 same as backend
isPublic: true, isPublic: true,
timerType: TimerType.CountDown,
endAction: EndAction.None,
}; };
enum EditorSettingsKeys { enum EditorSettingsKeys {
DefaultDuration = 'ontime-default-duration', DefaultDuration = 'ontime-default-duration',
LinkPrevious = 'ontime-link-previous', LinkPrevious = 'ontime-link-previous',
DefaultTimeStrategy = 'ontime-time-strategy',
DefaultWarnTime = 'ontime-default-warn-time', DefaultWarnTime = 'ontime-default-warn-time',
DefaultDangerTime = 'ontime-default-danger-time', DefaultDangerTime = 'ontime-default-danger-time',
DefaultPublic = 'ontime-default-public', DefaultPublic = 'ontime-default-public',
DefaultTimerType = 'ontime-default-timer-type',
DefaultEndAction = 'ontime-default-end-action',
} }
export const useEditorSettings = create<EditorSettingsStore>((set) => { export const useEditorSettings = create<EditorSettingsStore>((set) => {
return { return {
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? editorSettingsDefaults.duration, defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? editorSettingsDefaults.duration,
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, editorSettingsDefaults.linkPrevious), linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, editorSettingsDefaults.linkPrevious),
defaultTimeStrategy: validateTimeStrategy(
localStorage.getItem(EditorSettingsKeys.DefaultTimeStrategy),
editorSettingsDefaults.timeStrategy,
),
defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime, defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime,
defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime, defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime,
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic), defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
defaultTimerType: validateTimerType(
localStorage.getItem(EditorSettingsKeys.DefaultTimerType),
editorSettingsDefaults.timerType,
),
defaultEndAction: validateEndAction(
localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
editorSettingsDefaults.endAction,
),
setDefaultDuration: (defaultDuration) => setDefaultDuration: (defaultDuration) =>
set(() => { set(() => {
@@ -76,12 +50,6 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious)); localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
return { linkPrevious }; return { linkPrevious };
}), }),
setTimeStrategy: (defaultTimeStrategy) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultTimeStrategy, String(defaultTimeStrategy));
return { defaultTimeStrategy };
}),
setWarnTime: (defaultWarnTime) => setWarnTime: (defaultWarnTime) =>
set(() => { set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultWarnTime, String(defaultWarnTime)); localStorage.setItem(EditorSettingsKeys.DefaultWarnTime, String(defaultWarnTime));
@@ -97,15 +65,5 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic)); localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { 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 };
}),
}; };
}); });
+54 -1
View File
@@ -1,7 +1,60 @@
import isEqual from 'react-fast-compare'; import isEqual from 'react-fast-compare';
import { RuntimeStore, runtimeStorePlaceholder } from 'ontime-types'; import { Playback, RuntimeStore, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types';
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional'; 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); const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
export const runtimeStore = createWithEqualityFn<RuntimeStore>( export const runtimeStore = createWithEqualityFn<RuntimeStore>(
-1
View File
@@ -3,4 +3,3 @@ export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/rele
export const websiteUrl = 'https://www.getontime.no'; export const websiteUrl = 'https://www.getontime.no';
export const documentationUrl = 'https://docs.getontime.no'; export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
@@ -1,7 +1,3 @@
.halfWidth {
width: 50%;
}
.fullWidth { .fullWidth {
width: 100%; width: 100%;
} }
@@ -41,9 +37,3 @@
.flex { .flex {
display: flex; display: flex;
} }
.twoCols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@@ -78,7 +78,7 @@ export default function UrlPresetsForm() {
const addNew = () => { const addNew = () => {
prepend({ prepend({
enabled: true, enabled: false,
alias: '', alias: '',
pathAndParams: '', pathAndParams: '',
}); });
@@ -4,7 +4,6 @@ import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { CustomField, CustomFieldLabel } from 'ontime-types'; import { CustomField, CustomFieldLabel } from 'ontime-types';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Swatch from '../../../../../common/components/input/colour-input/Swatch'; import Swatch from '../../../../../common/components/input/colour-input/Swatch';
import CustomFieldForm from './CustomFieldForm'; import CustomFieldForm from './CustomFieldForm';
@@ -37,7 +36,6 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
onSubmit={handleEdit} onSubmit={handleEdit}
initialColour={colour} initialColour={colour}
initialLabel={label} initialLabel={label}
initialKey={field}
/> />
</td> </td>
</tr> </tr>
@@ -49,12 +47,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
<td> <td>
<Swatch color={colour} /> <Swatch color={colour} />
</td> </td>
<td className={style.halfWidth}>{label}</td> <td className={style.fullWidth}>{label}</td>
<td className={style.fullWidth}>
<CopyTag label='Copy key to use in integrations' copyValue={field}>
{field}
</CopyTag>
</td>
<td className={style.actions}> <td className={style.actions}>
<IconButton <IconButton
size='sm' size='sm'
@@ -2,11 +2,10 @@ import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Button, Input } from '@chakra-ui/react'; import { Button, Input } from '@chakra-ui/react';
import { CustomField } from 'ontime-types'; import { CustomField } from 'ontime-types';
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils'; import { isAlphanumeric } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../../common/api/utils';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import * as Panel from '../../PanelUtils'; import * as Panel from '../../PanelUtils';
import style from '../FeatureSettings.module.scss'; import style from '../FeatureSettings.module.scss';
@@ -16,13 +15,10 @@ interface CustomFieldsFormProps {
onCancel: () => void; onCancel: () => void;
initialColour?: string; initialColour?: string;
initialLabel?: string; initialLabel?: string;
initialKey?: string;
} }
export default function CustomFieldForm(props: CustomFieldsFormProps) { export default function CustomFieldForm(props: CustomFieldsFormProps) {
const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props; const { onSubmit, onCancel, initialColour, initialLabel } = props;
const { data } = useCustomFields();
// we use this to force an update // we use this to force an update
const [_, setColour] = useState(initialColour || ''); const [_, setColour] = useState(initialColour || '');
@@ -35,7 +31,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
getValues, getValues,
formState: { errors, isSubmitting, isValid, isDirty }, formState: { errors, isSubmitting, isValid, isDirty },
} = useForm({ } = useForm({
defaultValues: { label: initialLabel || '', colour: initialColour || '', key: initialKey || '' }, defaultValues: { label: initialLabel || '', colour: initialColour || '' },
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
@@ -70,38 +66,28 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
return ( return (
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}> <form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
<div className={style.twoCols}> <div className={style.column}>
<div> <Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description> {errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>} <Input
<Input {...register('label', {
{...register('label', { required: { value: true, message: 'Required field' },
required: { value: true, message: 'Required field' }, validate: (value) => {
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'), if (value.trim().length === 0) return 'Required field';
validate: (value) => { if (!isAlphanumeric(value)) return 'Only alphanumeric characters are allowed';
if (value.trim().length === 0) return 'Required field'; return true;
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'
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>
<div> <div>
<Panel.Description>Colour</Panel.Description> <Panel.Description>Colour</Panel.Description>
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} /> <SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
</div> </div>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>} {errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<div className={style.buttonRow}> <div className={style.buttonRow}>
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}> <Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
@@ -6,12 +6,13 @@ import { CustomField, CustomFieldLabel } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields'; import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
import ExternalLink from '../../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { customFieldsDocsUrl } from '../../../../../externals';
import * as Panel from '../../PanelUtils'; import * as Panel from '../../PanelUtils';
import CustomFieldEntry from './CustomFieldEntry'; import CustomFieldEntry from './CustomFieldEntry';
import CustomFieldForm from './CustomFieldForm'; import CustomFieldForm from './CustomFieldForm';
const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
export default function CustomFields() { export default function CustomFields() {
const { data, refetch } = useCustomFields(); const { data, refetch } = useCustomFields();
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
@@ -58,14 +59,9 @@ export default function CustomFields() {
<Alert status='info' variant='ontime-on-dark-info'> <Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon /> <AlertIcon />
<AlertDescription> <AlertDescription>
Custom fields allow for additional information to be added to an event. Custom fields allow for additional information to be added to an event (eg. light, sound, camera). <br />
<br /> <br />
<br /> This data is not used by Ontime.
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> <ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@@ -77,7 +73,6 @@ export default function CustomFields() {
<tr> <tr>
<th>Colour</th> <th>Colour</th>
<th>Name</th> <th>Name</th>
<th>Key (used in Integrations)</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@@ -84,8 +84,8 @@ export default function ViewSettingsForm() {
<Alert status='info' variant='ontime-on-dark-info'> <Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon /> <AlertIcon />
<AlertDescription> <AlertDescription>
You can the Ontime views or customise its styles by modifying the provided CSS file. <br /> You can override the styles of the viewers with a custom CSS file. <br />
The CSS file is in the user directory at {`${info.publicDir}/user/styles/override.css`} {info?.cssOverride && `In your installation the file is at ${info?.cssOverride}`}
<br /> <br />
<br /> <br />
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink> <ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
@@ -147,7 +147,7 @@ export default function ViewSettingsForm() {
variant='ontime-filled' variant='ontime-filled'
maxLength={150} maxLength={150}
width='275px' width='275px'
placeholder='Shown when timer reaches end' placeholder='Message shown when timer reaches end'
{...register('endMessage')} {...register('endMessage')}
/> />
</Panel.ListItem> </Panel.ListItem>
@@ -60,7 +60,7 @@ export default function HttpIntegrations() {
id: generateId(), id: generateId(),
cycle: 'onLoad', cycle: 'onLoad',
message: '', message: '',
enabled: true, enabled: false,
}); });
}; };
@@ -75,7 +75,7 @@ export default function OscIntegrations() {
cycle: 'onLoad', cycle: 'onLoad',
address: '', address: '',
payload: '', payload: '',
enabled: true, enabled: false,
}); });
}; };
@@ -1,5 +1,5 @@
import { Select, Switch } from '@chakra-ui/react'; import { Select, Switch } from '@chakra-ui/react';
import { EndAction, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, TimerType } from 'ontime-types';
import { parseUserTime } from 'ontime-utils'; import { parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../../common/components/input/time-input/TimeInput';
@@ -7,28 +7,17 @@ import { editorSettingsDefaults, useEditorSettings } from '../../../../common/st
import * as Panel from '../PanelUtils'; import * as Panel from '../PanelUtils';
export default function EditorSettingsForm() { export default function EditorSettingsForm() {
const { const eventSettings = useEditorSettings((state) => state);
defaultDuration,
linkPrevious,
defaultTimeStrategy,
defaultWarnTime,
defaultDangerTime,
defaultPublic,
defaultTimerType,
defaultEndAction,
setDefaultDuration,
setLinkPrevious,
setTimeStrategy,
setWarnTime,
setDangerTime,
setDefaultPublic,
setDefaultTimerType,
setDefaultEndAction,
} = useEditorSettings((state) => state);
const durationInMs = parseUserTime(defaultDuration); const setDefaultDuration = eventSettings.setDefaultDuration;
const warnTimeInMs = parseUserTime(defaultWarnTime); const setLinkPrevious = eventSettings.setLinkPrevious;
const dangerTimeInMs = parseUserTime(defaultDangerTime); 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);
return ( return (
<Panel.Section> <Panel.Section>
@@ -37,36 +26,6 @@ export default function EditorSettingsForm() {
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Panel.Title>Rundown defaults for new events</Panel.Title> <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.ListGroup>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='Default duration' description='Default duration for new events' /> <Panel.Field title='Default duration' description='Default duration for new events' />
@@ -78,14 +37,20 @@ export default function EditorSettingsForm() {
/> />
</Panel.ListItem> </Panel.ListItem>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='Timer type' description='Default type of timer for new events' /> <Panel.Field
<Select title='Link previous'
description='Whether the start time of new events should be linked to the previous event end time'
/>
<Switch
variant='ontime' variant='ontime'
size='sm' size='lg'
width='auto' defaultChecked={eventSettings.linkPrevious}
value={defaultTimerType} onChange={(event) => setLinkPrevious(event.target.checked)}
onChange={(event) => setDefaultTimerType(event.target.value as TimerType)} />
> </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>
<option value={TimerType.CountDown}>Count down</option> <option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option> <option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.TimeToEnd}>Time to end</option> <option value={TimerType.TimeToEnd}>Time to end</option>
@@ -94,13 +59,7 @@ export default function EditorSettingsForm() {
</Panel.ListItem> </Panel.ListItem>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='End Action' description='Default end action for new events' /> <Panel.Field title='End Action' description='Default end action for new events' />
<Select <Select variant='ontime' size='sm' width='auto' isDisabled>
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.None}>None</option>
<option value={EndAction.Stop}>Stop</option> <option value={EndAction.Stop}>Stop</option>
<option value={EndAction.LoadNext}>Load next</option> <option value={EndAction.LoadNext}>Load next</option>
@@ -134,7 +93,7 @@ export default function EditorSettingsForm() {
<Switch <Switch
variant='ontime' variant='ontime'
size='lg' size='lg'
defaultChecked={defaultPublic} defaultChecked={eventSettings.defaultPublic}
onChange={(event) => setDefaultPublic(event.target.checked)} onChange={(event) => setDefaultPublic(event.target.checked)}
/> />
</Panel.ListItem> </Panel.ListItem>
@@ -4,9 +4,3 @@
gap: $section-spacing; gap: $section-spacing;
row-gap: $element-inner-spacing; row-gap: $element-inner-spacing;
} }
.goIcon {
@include rotate-fourty-five;
margin-left: 0.25rem;
margin-bottom: 0.25rem;
}
@@ -1,33 +1,19 @@
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { serverPort } from '../../../../common/api/constants'; import { serverPort } from '../../../../common/api/constants';
import CopyTag from '../../../../common/components/copy-tag/CopyTag'; import AppLink from '../../../../common/components/app-link/AppLink';
import useInfo from '../../../../common/hooks-query/useInfo'; import useInfo from '../../../../common/hooks-query/useInfo';
import { openLink } from '../../../../common/utils/linkUtils';
import style from './NetworkInterfaces.module.scss'; import style from './NetworkInterfaces.module.scss';
export default function InfoNif() { export default function InfoNif() {
const { data } = useInfo(); const { data } = useInfo();
const handleClick = (address: string) => openLink(address);
return ( return (
<div className={style.interfaces}> <div className={style.interfaces}>
{data?.networkInterfaces?.map((nif) => { {data?.networkInterfaces?.map((nif) => (
const address = `http://${nif.address}:${serverPort}`; <AppLink key={nif.address} href={`http://${nif.address}:${serverPort}`}>
{`${nif.name} - ${nif.address}`}
return ( </AppLink>
<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> </div>
); );
} }
@@ -1,5 +1,4 @@
import { ChangeEvent, useRef, useState } from 'react'; import { ChangeEvent, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Button, Input } from '@chakra-ui/react'; import { Button, Input } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
@@ -14,17 +13,14 @@ import ProjectList from './ProjectList';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
export default function ManageProjects() { export default function ManageProjects() {
const [searchParams, setSearchParams] = useSearchParams(); const [isCreatingProject, setIsCreatingProject] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState<'import' | null>(null); const [loading, setLoading] = useState<'import' | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const isCreatingProject = searchParams.get('create') === 'true';
const handleToggleCreate = () => { const handleToggleCreate = () => {
searchParams.set('create', isCreatingProject ? 'false' : 'true'); setIsCreatingProject((prev) => !prev);
setSearchParams(searchParams);
}; };
const handleSelectFile = () => { const handleSelectFile = () => {
@@ -53,8 +49,7 @@ export default function ManageProjects() {
}; };
const handleCloseForm = () => { const handleCloseForm = () => {
searchParams.delete('create'); setIsCreatingProject(false);
setSearchParams(searchParams);
}; };
return ( return (
@@ -78,7 +78,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
</div> </div>
</Panel.Title> </Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>} {error && <Panel.Error>{error}</Panel.Error>}
<Panel.Section className={style.innerColumn}> <div className={style.innerColumn}>
<label> <label>
Project title Project title
<Input <Input
@@ -145,7 +145,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
</label> </label>
</Panel.Section> </div>
</Panel.Section> </Panel.Section>
); );
} }
@@ -6,7 +6,6 @@ import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_DATA } from '../../../../common/api/constants'; import { PROJECT_DATA } from '../../../../common/api/constants';
import { getDb, patchData } from '../../../../common/api/db'; import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../PanelUtils'; import * as Panel from '../PanelUtils';
import { makeProjectPatch } from './project.utils'; import { makeProjectPatch } from './project.utils';
@@ -93,7 +92,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
</div> </div>
</Panel.Title> </Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>} {error && <Panel.Error>{error}</Panel.Error>}
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}> <div className={style.innerColumn}>
<Panel.Description> <Panel.Description>
Select partial data from {`"${fileName}"`} to merge into the current project. Select partial data from {`"${fileName}"`} to merge into the current project.
<br /> This process is irreversible. <br /> This process is irreversible.
@@ -122,7 +121,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
<Switch variant='ontime' {...register('http')} /> <Switch variant='ontime' {...register('http')} />
HTTP Integration HTTP Integration
</label> </label>
</Panel.Section> </div>
</Panel.Section> </Panel.Section>
); );
} }
@@ -53,11 +53,9 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1em; gap: 1em;
}
.inlineLabels {
label { label {
display: flex; display: flex;
gap: 1rem; gap: 1em;
} }
} }
@@ -10,7 +10,6 @@ import {
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { isLocalhost } from '../../../../common/api/constants';
import useElectronEvent from '../../../../common/hooks/useElectronEvent'; import useElectronEvent from '../../../../common/hooks/useElectronEvent';
import * as Panel from '../PanelUtils'; import * as Panel from '../PanelUtils';
@@ -32,10 +31,9 @@ export default function ShutdownPanel() {
This will shutdown the Ontime server. <br /> This will shutdown the Ontime server. <br />
The runtime state will be lost, but your project is kept for next time. The runtime state will be lost, but your project is kept for next time.
</Panel.Paragraph> </Panel.Paragraph>
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!(isElectron || isLocalhost)}> <Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!isElectron}>
Shutdown ontime Shutdown ontime
</Button> </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}> <AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
<AlertDialogOverlay> <AlertDialogOverlay>
<AlertDialogContent> <AlertDialogContent>
@@ -193,7 +193,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
<Panel.ListGroup> <Panel.ListGroup>
<div className={style.buttonRow}> <div className={style.buttonRow}>
{isAuthenticating && <Spinner />} {isAuthenticating && <Spinner />}
<CopyTag copyValue={authKey ?? ''} label='Google Auth Key' disabled={!canAuthenticate} size='sm'> <CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
{authKey ? authKey : 'Upload files to generate Auth Key'} {authKey ? authKey : 'Upload files to generate Auth Key'}
</CopyTag> </CopyTag>
<Button <Button
@@ -3,8 +3,9 @@ import { useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react'; import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { isAlphanumeric } from '../../../../../common/utils/regex';
import * as Panel from '../../PanelUtils'; import * as Panel from '../../PanelUtils';
import useGoogleSheet from '../useGoogleSheet'; import useGoogleSheet from '../useGoogleSheet';
import { useSheetStore } from '../useSheetStore'; import { useSheetStore } from '../useSheetStore';
@@ -189,10 +190,9 @@ export default function ImportMapForm(props: ImportMapFormProps) {
defaultValue={ontimeName} defaultValue={ontimeName}
placeholder='Name of the field as shown in Ontime' placeholder='Name of the field as shown in Ontime'
{...register(`custom.${index}.ontimeName`, { {...register(`custom.${index}.ontimeName`, {
validate: (value) => { pattern: {
if (!isAlphanumericWithSpace(value)) value: isAlphanumeric,
return 'Only alphanumeric characters and space are allowed'; message: 'Custom field name must be alphanumeric',
return true;
}, },
})} })}
/> />
@@ -24,8 +24,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
// we only count Ontime Events which are 1 based in client // we only count Ontime Events which are 1 based in client
let eventIndex = 0; let eventIndex = 0;
const fieldKeys = Object.keys(customFields); const fieldHeaders = Object.keys(customFields);
const fieldLabels = fieldKeys.map((key) => customFields[key].label);
return ( return (
<Panel.Table> <Panel.Table>
@@ -45,8 +44,8 @@ export default function PreviewRundown(props: PreviewRundownProps) {
<th>Colour</th> <th>Colour</th>
<th>Timer Type</th> <th>Timer Type</th>
<th>End Action</th> <th>End Action</th>
{fieldLabels.map((label) => ( {fieldHeaders.map((field) => (
<th key={label}>{label}</th> <th key={field}>{field}</th>
))} ))}
</tr> </tr>
</thead> </thead>
@@ -103,7 +102,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
<Tag>{event.endAction}</Tag> <Tag>{event.endAction}</Tag>
</td> </td>
{isOntimeEvent(event) && {isOntimeEvent(event) &&
fieldKeys.map((field) => { fieldHeaders.map((field) => {
let value = ''; let value = '';
if (field in event.custom) { if (field in event.custom) {
value = event.custom[field]; value = event.custom[field];
@@ -1,9 +1,10 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { Input } from '@chakra-ui/react'; import { IconButton, Input } from '@chakra-ui/react';
import { IoEye } from '@react-icons/all-files/io5/IoEye'; import { IoEye } from '@react-icons/all-files/io5/IoEye';
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline'; import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn'; import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig'; import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './InputRow.module.scss'; import style from './InputRow.module.scss';
@@ -12,13 +13,15 @@ interface InputRowProps {
label: string; label: string;
placeholder: string; placeholder: string;
text: string; text: string;
visible: boolean; visible?: boolean;
actionHandler: () => void; readonly?: boolean;
actionHandler: (action: string, payload: object) => void;
changeHandler: (newValue: string) => void; changeHandler: (newValue: string) => void;
className?: string;
} }
export default function InputRow(props: InputRowProps) { export default function InputRow(props: InputRowProps) {
const { label, placeholder, text, visible, actionHandler, changeHandler } = props; const { label, placeholder, text, visible, actionHandler, changeHandler, className, readonly } = props;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const cursorPositionRef = useRef(0); const cursorPositionRef = useRef(0);
@@ -36,27 +39,41 @@ export default function InputRow(props: InputRowProps) {
changeHandler(event.target.value); changeHandler(event.target.value);
}; };
const classes = cx([style.inputRow, className]);
return ( return (
<div className={style.inputRow}> <div className={classes}>
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label> <label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
<div className={style.inputItems}> <div className={style.inputItems}>
<Input <Input
ref={inputRef} ref={inputRef}
size='sm' size='sm'
variant='ontime-filled' variant='ontime-filled'
readOnly={readonly}
disabled={readonly}
value={text} value={text}
onChange={handleInputChange} onChange={handleInputChange}
placeholder={placeholder} placeholder={placeholder}
/> />
<TooltipActionBtn {readonly ? (
clickHandler={actionHandler} <IconButton
tooltip={visible ? 'Make invisible' : 'Make visible'} size='sm'
aria-label={`Toggle ${label}`} isDisabled
openDelay={tooltipDelayMid} icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />} aria-label={`Toggle ${label}`}
variant={visible ? 'ontime-filled' : 'ontime-subtle'} variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm' />
/> ) : (
<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'
/>
)}
</div> </div>
</div> </div>
); );
@@ -1,3 +1,23 @@
.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 { .label {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
color: $label-gray; color: $label-gray;
@@ -6,89 +26,3 @@
color: $action-text-color; 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,52 +1,64 @@
import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket'; 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 InputRow from './InputRow'; import InputRow from './InputRow';
import TimerControlsPreview from './TimerViewControl';
import style from './MessageControl.module.scss';
const noop = () => undefined;
export default function MessageControl() { export default function MessageControl() {
return ( const message = useMessageControl();
<> const blink = message.timer.blink;
<TimerControlsPreview /> const blackout = message.timer.blackout;
<TimerMessageInput />
<ExternalInput />
</>
);
}
function TimerMessageInput() {
const { text, visible } = useTimerMessageInput();
return ( return (
<InputRow <div className={style.messageContainer}>
label='Timer Message' <InputRow
placeholder='Message shown fullscreen in stage timer' label='Timer'
text={text} placeholder='Message shown in stage timer'
visible={visible} text={message.timer.text}
changeHandler={(newValue) => setMessage.timerText(newValue)} visible={message.timer.visible}
actionHandler={() => setMessage.timerVisible(!visible)} changeHandler={(newValue) => setMessage.timerText(newValue)}
/> actionHandler={() => setMessage.timerVisible(!message.timer.visible)}
); />
} <div className={style.buttonSection}>
<Button
function ExternalInput() { size='sm'
const { text, visible } = useExternalMessageInput(); className={`${blink ? style.blink : ''}`}
variant={blink ? 'ontime-filled' : 'ontime-subtle'}
const toggleExternal = () => { leftIcon={blink ? <IoSunny size='1rem' /> : <IoSunnyOutline size='1rem' />}
if (visible) { onClick={() => setMessage.timerBlink(!blink)}
setMessage.timerSecondary(null); data-testid='toggle timer blink'
} else { >
setMessage.timerSecondary('external'); Blink
} </Button>
}; <Button
size='sm'
return ( className={style.blackoutButton}
<InputRow variant={blackout ? 'ontime-filled' : 'ontime-subtle'}
label='External Message' leftIcon={blackout ? <IoEye size='1rem' /> : <IoEyeOffOutline size='1rem' />}
placeholder='Message shown as secondary text in stage timer' onClick={() => setMessage.timerBlackout(!blackout)}
text={text} data-testid='toggle timer blackout'
visible={visible} >
changeHandler={(newValue) => setMessage.externalText(newValue)} Blackout screen
actionHandler={toggleExternal} </Button>
/> </div>
<InputRow
label='External Message (read only)'
placeholder={enDash}
readonly
text={message.external.text}
visible={message.external.visible}
changeHandler={noop}
actionHandler={noop}
/>
</div>
); );
} }
@@ -3,19 +3,16 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
import { handleLinks } from '../../../common/utils/linkUtils'; import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils';
import MessageControl from './MessageControl'; import MessageControl from './MessageControl';
import style from '../../editors/Editor.module.scss'; import style from '../../editors/Editor.module.scss';
const MessageControlExport = () => { const MessageControlExport = () => {
const classes = cx([style.content, style.contentColumnLayout]);
return ( return (
<div className={style.messages} data-testid='panel-messages-control'> <div className={style.messages} data-testid='panel-messages-control'>
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'messagecontrol')} /> <IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'messagecontrol')} />
<div className={classes}> <div className={style.content}>
<ErrorBoundary> <ErrorBoundary>
<MessageControl /> <MessageControl />
</ErrorBoundary> </ErrorBoundary>
@@ -1,78 +0,0 @@
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>
);
}
@@ -1,62 +0,0 @@
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 handleTimeUpdate = (_field: string, value: string) => {
const newTime = parseUserTime(value); const newTime = parseUserTime(value);
setDuration(newTime / 1000); // frontend api is seconds based setDuration(newTime / 1000); //frontend api is seconds based;
}; };
return ( return (
@@ -50,15 +50,9 @@ $table-header-font-size: calc(1rem - 3px);
font-size: $table-header-font-size; font-size: $table-header-font-size;
color: $gray-700; color: $gray-700;
}
th { th {
background-color: $gray-1300; background-color: $gray-1300;
&:hover {
.resizer {
width: 0.5rem;
}
} }
} }
@@ -132,19 +126,30 @@ th {
.resizer { .resizer {
cursor: col-resize; cursor: col-resize;
opacity: $opacity-disabled; opacity: 0;
display: inline-block; display: inline-block;
width: 0; width: 3px;
height: 100%; height: 100%;
position: absolute; position: absolute;
right: 0; right: 0;
top: 0; top: 0;
transform: translateX(50%);
background-color: $action-blue; background-color: $action-blue;
user-select: none; user-select: none;
touch-action: none; touch-action: none;
transition-duration: $transition-time-action;
transition-property: width, background-color;
&:hover { &:hover {
opacity: $opacity-disabled;
width: 6px;
}
&.isResizing {
opacity: 1; opacity: 1;
width: 6px;
background-color: $action-blue;
} }
} }
@@ -4,6 +4,8 @@ import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table'; import { Header } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types'; import { OntimeRundownEntry } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils';
import styles from '../Cuesheet.module.scss'; import styles from '../Cuesheet.module.scss';
interface SortableCellProps { interface SortableCellProps {
@@ -27,6 +29,8 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
transition, transition,
}; };
const resizerClasses = cx([styles.resizer, header.column.getIsResizing() ? styles.isResizing : null]);
return ( return (
<th ref={setNodeRef} style={dragStyle} colSpan={colSpan}> <th ref={setNodeRef} style={dragStyle} colSpan={colSpan}>
<div {...attributes} {...listeners}> <div {...attributes} {...listeners}>
@@ -37,7 +41,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
onMouseDown: header.getResizeHandler(), onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(), onTouchStart: header.getResizeHandler(),
}} }}
className={styles.resizer} className={resizerClasses}
/> />
</th> </th>
); );
@@ -29,12 +29,10 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
showPrevious, showPrevious,
togglePreviousVisibility, togglePreviousVisibility,
showDelayBlock, showDelayBlock,
hideSeconds,
showDelayedTimes, showDelayedTimes,
toggleIndexColumn, toggleIndexColumn,
toggleDelayedTimes, toggleDelayedTimes,
toggleDelayVisibility, toggleDelayVisibility,
toggleSecondsVisibility,
} = useCuesheetSettings(); } = useCuesheetSettings();
return ( return (
@@ -82,10 +80,6 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
<Switch variant='ontime' size='sm' isChecked={showDelayBlock} onChange={() => toggleDelayVisibility()} /> <Switch variant='ontime' size='sm' isChecked={showDelayBlock} onChange={() => toggleDelayVisibility()} />
Show delay blocks Show delay blocks
</label> </label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={hideSeconds} onChange={() => toggleSecondsVisibility()} />
Hide seconds
</label>
</div> </div>
</div> </div>
<div className={style.rightPanel}> <div className={style.rightPanel}>
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark'; import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { CellContext, ColumnDef } from '@tanstack/react-table'; import { CellContext, ColumnDef } from '@tanstack/react-table';
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types'; import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator'; import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
import RunningTime from '../viewers/common/running-time/RunningTime'; import RunningTime from '../viewers/common/running-time/RunningTime';
@@ -18,28 +19,20 @@ function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) { function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes); const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
const cellValue = (getValue() as number | null) ?? 0; const cellValue = (getValue() as number | null) ?? 0;
const delayValue = (original as OntimeEvent)?.delay ?? 0; const delayValue = (original as OntimeEvent)?.delay ?? 0;
return ( return (
<span className={style.time}> <span className={style.time}>
<DelayIndicator delayValue={delayValue} /> <DelayIndicator delayValue={delayValue} />
<RunningTime value={cellValue} hideSeconds={hideSeconds} /> <RunningTime value={cellValue} />
{delayValue !== 0 && showDelayedTimes && ( {delayValue !== 0 && showDelayedTimes && (
<RunningTime className={style.delayedTime} value={cellValue + delayValue} hideSeconds={hideSeconds} /> <RunningTime className={style.delayedTime} value={cellValue + delayValue} />
)} )}
</span> </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>) { function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
@@ -104,7 +97,7 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
accessorKey: 'duration', accessorKey: 'duration',
id: 'duration', id: 'duration',
header: 'Duration', header: 'Duration',
cell: MakeDuration, cell: (row) => millisToString(row.getValue() as number | null),
size: 75, size: 75,
}, },
{ {
@@ -9,7 +9,6 @@ interface CuesheetSettings {
showPrevious: boolean; showPrevious: boolean;
showDelayBlock: boolean; showDelayBlock: boolean;
showDelayedTimes: boolean; showDelayedTimes: boolean;
hideSeconds: boolean;
toggleSettings: (newValue?: boolean) => void; toggleSettings: (newValue?: boolean) => void;
toggleFollow: (newValue?: boolean) => void; toggleFollow: (newValue?: boolean) => void;
@@ -17,7 +16,6 @@ interface CuesheetSettings {
toggleIndexColumn: (newValue?: boolean) => void; toggleIndexColumn: (newValue?: boolean) => void;
toggleDelayVisibility: (newValue?: boolean) => void; toggleDelayVisibility: (newValue?: boolean) => void;
toggleDelayedTimes: (newValue?: boolean) => void; toggleDelayedTimes: (newValue?: boolean) => void;
toggleSecondsVisibility: (newValue?: boolean) => void;
} }
function toggle(oldValue: boolean, value?: boolean) { function toggle(oldValue: boolean, value?: boolean) {
@@ -33,7 +31,6 @@ enum CuesheetKeys {
PreviousVisibility = 'ontime-cuesheet-show-previous', PreviousVisibility = 'ontime-cuesheet-show-previous',
ColumnIndex = 'ontime-cuesheet-show-index-column', ColumnIndex = 'ontime-cuesheet-show-index-column',
DelayedTimes = 'ontime-cuesheet-show-delayed', DelayedTimes = 'ontime-cuesheet-show-delayed',
Seconds = 'ontime-cuesheet-hide-sceconds',
} }
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({ export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
@@ -43,7 +40,6 @@ export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true), showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true), showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
showDelayedTimes: booleanFromLocalStorage(CuesheetKeys.DelayedTimes, false), showDelayedTimes: booleanFromLocalStorage(CuesheetKeys.DelayedTimes, false),
hideSeconds: booleanFromLocalStorage(CuesheetKeys.Seconds, false),
toggleSettings: (newValue?: boolean) => set((state) => ({ showSettings: toggle(state.showSettings, newValue) })), toggleSettings: (newValue?: boolean) => set((state) => ({ showSettings: toggle(state.showSettings, newValue) })),
toggleFollow: (newValue?: boolean) => toggleFollow: (newValue?: boolean) =>
@@ -76,10 +72,4 @@ export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
localStorage.setItem(CuesheetKeys.DelayedTimes, String(showDelayedTimes)); localStorage.setItem(CuesheetKeys.DelayedTimes, String(showDelayedTimes));
return { 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, .playback,
.messages { .messages {
position: relative; position: relative;
border-radius: var(--editor--panel__br); border-radius: 8px;
background-color: $bg-container-l2; background-color: $bg-container-l2;
padding: 1rem; padding: 1rem;
} }
@@ -62,10 +62,3 @@ $panel-gap: 0.5rem;
.content { .content {
padding-top: 1.5rem; padding-top: 1.5rem;
} }
.contentColumnLayout {
display: flex;
flex-direction: column;
gap: $section-spacing;
color: $ui-white;
}
@@ -1,18 +1,10 @@
@use '../../theme/ontimeColours' as *; @use '../../theme/ontimeColours' as *;
@use '../../theme/ontimeStyles' as *; @use '../../theme/ontimeStyles' as *;
// declare editor specific styling constants @mixin absolute-top-right($distance) {
:root {
--editor--panel__br: 8px;
}
@mixin corner() {
display: none;
transform: rotate(45deg);
position: absolute; position: absolute;
top: 0.5rem; top: $distance;
right: 0.5rem; right: $distance;
cursor: pointer; cursor: pointer;
color: $ui-white; color: $ui-white;
transition-property: color; transition-property: color;
@@ -23,11 +15,17 @@
} }
} }
@mixin corner() {
display: none;
@include absolute-top-right(0.5rem);
transform: rotate(45deg);
}
@mixin panel() { @mixin panel() {
display: flex; display: flex;
position: relative; position: relative;
border-radius: var(--editor--panel__br); border-radius: 8px;
height: 100%; height: 100%;
background-color: $bg-container-l2; background-color: $bg-container-l2;
padding: 1rem; padding: 1rem;
@@ -21,6 +21,10 @@ import EventBlockProgressBar from './composite/EventBlockProgressBar';
import style from './EventBlock.module.scss'; import style from './EventBlock.module.scss';
const tooltipProps = {
openDelay: tooltipDelayMid,
};
interface EventBlockInnerProps { interface EventBlockInnerProps {
timeStart: number; timeStart: number;
timeEnd: number; timeEnd: number;
@@ -94,7 +98,11 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
</div> </div>
<div className={style.titleSection}> <div className={style.titleSection}>
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} /> <EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
{isNext && <span className={style.nextTag}>UP NEXT</span>} {isNext && (
<Tooltip label='Next event' {...tooltipProps}>
<span className={style.nextTag}>UP NEXT</span>
</Tooltip>
)}
</div> </div>
<EventBlockPlayback <EventBlockPlayback
eventId={eventId} eventId={eventId}
@@ -110,17 +118,17 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
{loaded && <EventBlockProgressBar />} {loaded && <EventBlockProgressBar />}
</div> </div>
<div className={style.eventStatus} tabIndex={-1}> <div className={style.eventStatus} tabIndex={-1}>
<Tooltip label={`Time type: ${timerType}`} openDelay={tooltipDelayMid}> <Tooltip label={`Time type: ${timerType}`} {...tooltipProps}>
<span> <span>
<TimerIcon type={timerType} className={style.statusIcon} /> <TimerIcon type={timerType} className={style.statusIcon} />
</span> </span>
</Tooltip> </Tooltip>
<Tooltip label={`End action: ${endAction}`} openDelay={tooltipDelayMid}> <Tooltip label={`End action: ${endAction}`} {...tooltipProps}>
<span> <span>
<EndActionIcon action={endAction} className={style.statusIcon} /> <EndActionIcon action={endAction} className={style.statusIcon} />
</span> </span>
</Tooltip> </Tooltip>
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} openDelay={tooltipDelayMid}> <Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} {...tooltipProps}>
<span> <span>
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} /> <IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
</span> </span>
@@ -1,4 +1,4 @@
import { CSSProperties, memo, useCallback, useEffect, useState } from 'react'; import { CSSProperties, useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types'; import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types';
@@ -126,32 +126,10 @@ export default function EventEditor() {
})} })}
</div> </div>
</div> </div>
<EventEditorFooter id={event.id} cue={event.cue} /> <div className={style.footer}>
</div> <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>
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> </div>
); );
} }
@@ -10,7 +10,7 @@ import { MaybeString, OntimeEvent, TimeStrategy } from 'ontime-types';
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton'; import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayFast, tooltipDelayMid } from '../../../ontimeConfig'; import { tooltipDelayFast } from '../../../ontimeConfig';
import style from './TimeInputFlow.module.scss'; import style from './TimeInputFlow.module.scss';
@@ -66,12 +66,10 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
placeholder='Start' placeholder='Start'
disabled={Boolean(linkStart)} disabled={Boolean(linkStart)}
> >
<Tooltip label='Link start to previous end' openDelay={tooltipDelayMid}> <InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}> <span className={style.timeLabel}>S</span>
<span className={style.timeLabel}>S</span> <span className={style.fourtyfive}>{linkStart ? <IoLink /> : <IoUnlink />}</span>
<span className={style.fourtyfive}>{linkStart ? <IoLink /> : <IoUnlink />}</span> </InputRightElement>
</InputRightElement>
</Tooltip>
</TimeInputWithButton> </TimeInputWithButton>
<TimeInputWithButton<TimeActions> <TimeInputWithButton<TimeActions>
@@ -82,16 +80,14 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
disabled={isLockedDuration} disabled={isLockedDuration}
placeholder='End' placeholder='End'
> >
<Tooltip label='Lock end' openDelay={tooltipDelayMid}> <InputRightElement
<InputRightElement className={activeEnd}
className={activeEnd} onClick={() => handleChangeStrategy(TimeStrategy.LockEnd)}
onClick={() => handleChangeStrategy(TimeStrategy.LockEnd)} data-testid='lock__end'
data-testid='lock__end' >
> <span className={style.timeLabel}>E</span>
<span className={style.timeLabel}>E</span> {isLockedEnd ? <IoLockClosed /> : <IoLockOpenOutline />}
{isLockedEnd ? <IoLockClosed /> : <IoLockOpenOutline />} </InputRightElement>
</InputRightElement>
</Tooltip>
</TimeInputWithButton> </TimeInputWithButton>
<TimeInputWithButton<TimeActions> <TimeInputWithButton<TimeActions>
@@ -101,16 +97,14 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
disabled={isLockedEnd} disabled={isLockedEnd}
placeholder='Duration' placeholder='Duration'
> >
<Tooltip label='Lock duration' openDelay={tooltipDelayMid}> <InputRightElement
<InputRightElement className={activeDuration}
className={activeDuration} onClick={() => handleChangeStrategy(TimeStrategy.LockDuration)}
onClick={() => handleChangeStrategy(TimeStrategy.LockDuration)} data-testid='lock__duration'
data-testid='lock__duration' >
> <span className={style.timeLabel}>D</span>
<span className={style.timeLabel}>D</span> {isLockedDuration ? <IoLockClosed /> : <IoLockOpenOutline />}
{isLockedDuration ? <IoLockClosed /> : <IoLockOpenOutline />} </InputRightElement>
</InputRightElement>
</Tooltip>
</TimeInputWithButton> </TimeInputWithButton>
{overMidnight && ( {overMidnight && (
@@ -2,13 +2,13 @@ import { ComponentType, useMemo } from 'react';
import { ViewExtendedTimer } from 'common/models/TimeManager.type'; import { ViewExtendedTimer } from 'common/models/TimeManager.type';
import { import {
CustomFields, CustomFields,
MessageState, Message,
OntimeEvent, OntimeEvent,
ProjectData, ProjectData,
Runtime, Runtime,
Settings, Settings,
SimpleTimerState,
SupportedEvent, SupportedEvent,
TimerMessage,
ViewSettings, ViewSettings,
} from 'ontime-types'; } from 'ontime-types';
import { useStore } from 'zustand'; import { useStore } from 'zustand';
@@ -23,17 +23,17 @@ import { runtimeStore } from '../../common/stores/runtime';
import { useViewOptionsStore } from '../../common/stores/viewOptions'; import { useViewOptionsStore } from '../../common/stores/viewOptions';
type WithDataProps = { type WithDataProps = {
auxTimer: SimpleTimerState;
backstageEvents: OntimeEvent[]; backstageEvents: OntimeEvent[];
customFields: CustomFields; customFields: CustomFields;
eventNext: OntimeEvent | null; eventNext: OntimeEvent | null;
eventNow: OntimeEvent | null; eventNow: OntimeEvent | null;
events: OntimeEvent[]; events: OntimeEvent[];
external: Message;
general: ProjectData; general: ProjectData;
isMirrored: boolean; isMirrored: boolean;
message: MessageState;
nextId: string | null; nextId: string | null;
onAir: boolean; onAir: boolean;
pres: TimerMessage;
publicEventNext: OntimeEvent | null; publicEventNext: OntimeEvent | null;
publicEventNow: OntimeEvent | null; publicEventNow: OntimeEvent | null;
publicSelectedId: string | null; publicSelectedId: string | null;
@@ -68,7 +68,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
}, [rundownData]); }, [rundownData]);
// websocket data // websocket data
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime, auxtimer1 } = const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime } =
useStore(runtimeStore); useStore(runtimeStore);
const publicSelectedId = publicEventNow?.id ?? null; const publicSelectedId = publicEventNow?.id ?? null;
const selectedId = eventNow?.id ?? null; const selectedId = eventNow?.id ?? null;
@@ -86,22 +86,27 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
timerType: eventNow?.timerType ?? null, timerType: eventNow?.timerType ?? null,
}; };
// prevent render until we get all the data we need
if (!viewSettings) {
return null;
}
return ( return (
<> <>
<ViewNavigationMenu /> <ViewNavigationMenu />
<Component <Component
{...props} {...props}
auxTimer={auxtimer1}
backstageEvents={rundownData} backstageEvents={rundownData}
customFields={customFields} customFields={customFields}
eventNext={eventNext} eventNext={eventNext}
eventNow={eventNow} eventNow={eventNow}
events={publicEvents} events={publicEvents}
external={message.external}
general={project} general={project}
isMirrored={isMirrored} isMirrored={isMirrored}
message={message}
nextId={nextId} nextId={nextId}
onAir={onAir} onAir={onAir}
pres={message.timer}
publicEventNext={publicEventNext} publicEventNext={publicEventNext}
publicEventNow={publicEventNow} publicEventNow={publicEventNow}
publicSelectedId={publicSelectedId} publicSelectedId={publicSelectedId}
@@ -2,15 +2,17 @@ import { useEffect, useState } from 'react';
import QRCode from 'react-qr-code'; import QRCode from 'react-qr-code';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { CustomFields, OntimeEvent, ProjectData, Settings, SupportedEvent } from 'ontime-types'; import { CustomFields, OntimeEvent, ProjectData, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
import { millisToString, removeLeadingZero } from 'ontime-utils'; import { millisToString, removeLeadingZero } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/constants';
import ProgressBar from '../../../common/components/progress-bar/ProgressBar'; import ProgressBar from '../../../common/components/progress-bar/ProgressBar';
import Schedule from '../../../common/components/schedule/Schedule'; import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext'; import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav'; import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
import TitleCard from '../../../common/components/title-card/TitleCard'; import TitleCard from '../../../common/components/title-card/TitleCard';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { timerPlaceholderMin } from '../../../common/utils/styleUtils'; import { timerPlaceholderMin } from '../../../common/utils/styleUtils';
@@ -35,12 +37,25 @@ interface BackstageProps {
backstageEvents: OntimeEvent[]; backstageEvents: OntimeEvent[];
selectedId: string | null; selectedId: string | null;
general: ProjectData; general: ProjectData;
viewSettings: ViewSettings;
settings: Settings | undefined; settings: Settings | undefined;
} }
export default function Backstage(props: BackstageProps) { export default function Backstage(props: BackstageProps) {
const { customFields, isMirrored, eventNow, eventNext, time, backstageEvents, selectedId, general, settings } = props; const {
customFields,
isMirrored,
eventNow,
eventNext,
time,
backstageEvents,
selectedId,
general,
viewSettings,
settings,
} = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const [blinkClass, setBlinkClass] = useState(false); const [blinkClass, setBlinkClass] = useState(false);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -58,6 +73,11 @@ export default function Backstage(props: BackstageProps) {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [selectedId]); }, [selectedId]);
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
const clock = formatTime(time.clock); const clock = formatTime(time.clock);
const startedAt = formatTime(time.startedAt); const startedAt = formatTime(time.startedAt);
const isNegative = (time.current ?? 0) < 0; const isNegative = (time.current ?? 0) < 0;
@@ -1,7 +1,9 @@
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Settings } from 'ontime-types'; import { Settings, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { OverridableOptions } from '../../../common/models/View.types'; import { OverridableOptions } from '../../../common/models/View.types';
@@ -15,15 +17,22 @@ import './Clock.scss';
interface ClockProps { interface ClockProps {
isMirrored: boolean; isMirrored: boolean;
time: ViewExtendedTimer; time: ViewExtendedTimer;
viewSettings: ViewSettings;
settings: Settings | undefined; settings: Settings | undefined;
} }
export default function Clock(props: ClockProps) { export default function Clock(props: ClockProps) {
const { isMirrored, time, settings } = props; const { isMirrored, time, viewSettings, settings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
useWindowTitle('Clock'); useWindowTitle('Clock');
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
// get config from url: key, text, font, size, hidenav // get config from url: key, text, font, size, hidenav
// eg. http://localhost:3000/clock?key=f00&text=fff // eg. http://localhost:3000/clock?key=f00&text=fff
// Check for user options // Check for user options
@@ -8,23 +8,26 @@ export const getClockOptions = (timeFormat: string): ViewOption[] => [
{ {
id: 'key', id: 'key',
title: 'Key Colour', title: 'Key Colour',
description: 'Background or key colour for entire view. Default: #000000', description: 'Background colour in hexadecimal',
type: 'colour', prefix: '#',
defaultValue: '000000', type: 'string',
placeholder: '00000000 (default)',
}, },
{ {
id: 'text', id: 'text',
title: 'Text Colour', title: 'Text Colour',
description: 'Text colour. Default: #FFFFFF', description: 'Text colour in hexadecimal',
type: 'colour', prefix: '#',
defaultValue: 'FFFFFF', type: 'string',
placeholder: 'fffff (default)',
}, },
{ {
id: 'textbg', id: 'textbg',
title: 'Text Background', title: 'Text Background',
description: 'Background colour for timer text. Default: #FFF0 (transparent)', description: 'Colour of text background in hexadecimal',
type: 'colour', prefix: '#',
defaultValue: 'FFF0', type: 'string',
placeholder: '00000000 (default)',
}, },
{ {
id: 'font', id: 'font',
@@ -1,8 +1,19 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { OntimeEvent, OntimeRundownEntry, Playback, Runtime, Settings, SupportedEvent, TimerPhase } from 'ontime-types'; import {
OntimeEvent,
OntimeRundownEntry,
Playback,
Runtime,
Settings,
SupportedEvent,
TimerPhase,
ViewSettings,
} from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
@@ -23,10 +34,12 @@ interface CountdownProps {
selectedId: string | null; selectedId: string | null;
settings: Settings | undefined; settings: Settings | undefined;
time: ViewExtendedTimer; time: ViewExtendedTimer;
viewSettings: ViewSettings;
} }
export default function Countdown(props: CountdownProps) { export default function Countdown(props: CountdownProps) {
const { isMirrored, backstageEvents, runtime, selectedId, settings, time } = props; const { isMirrored, backstageEvents, runtime, selectedId, settings, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
@@ -67,6 +80,11 @@ export default function Countdown(props: CountdownProps) {
} }
}, [backstageEvents, searchParams]); }, [backstageEvents, searchParams]);
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
const { message: runningMessage, timer: runningTimer } = fetchTimerData(time, follow, selectedId, runtime.offset); const { message: runningMessage, timer: runningTimer } = fetchTimerData(time, follow, selectedId, runtime.offset);
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id; const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
@@ -39,16 +39,16 @@ const defaultOptions: Readonly<LowerOptions> = {
width: 45, width: 45,
topSrc: 'title', topSrc: 'title',
bottomSrc: 'lowerMsg', bottomSrc: 'lowerMsg',
topColour: '000000', topColour: '000000ff',
bottomColour: '000000', bottomColour: '000000ff',
topBg: 'FFF0', topBg: '00000000',
bottomBg: 'FFF0', bottomBg: '00000000',
topSize: '65px', topSize: '65px',
bottomSize: '40px', bottomSize: '40px',
transition: 3, transition: 3,
delay: 3, delay: 3,
key: 'FFF0', key: 'ffffffff',
lineColour: 'FF0000', lineColour: 'ff0000ff',
lineHeight: '0.4em', lineHeight: '0.4em',
}; };
@@ -6,12 +6,10 @@ import { ViewOption } from '../../../common/components/view-params-editor/types'
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => { export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
const topSourceOptions = makeOptionsFromCustomFields(customFields, { const topSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title', title: 'Title',
note: 'Note',
}); });
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, { const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title', title: 'Title',
note: 'Note',
none: 'None', none: 'None',
}); });
@@ -33,22 +31,39 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
values: bottomSourceOptions, values: bottomSourceOptions,
defaultValue: 'none', 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' }, { 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', id: 'top-size',
title: 'Top Text Size', title: 'Top Text Size',
@@ -71,47 +86,35 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
prefix: '%', prefix: '%',
placeholder: '45 (default)', 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', id: 'key',
title: 'Key Colour', title: 'Key Colour',
description: 'Colour of the background. Default: #FFF0 (transparent)', description: 'Colour of the background',
type: 'colour', prefix: '#',
defaultValue: 'FFF0', type: 'string',
}, placeholder: 'ffffffff (default)',
{
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', id: 'line-colour',
title: 'Line Colour', title: 'Line Colour',
description: 'Colour of the line. Default: #FF0000', description: 'Colour of the line',
type: 'colour', prefix: '#',
defaultValue: 'FF0000', type: 'string',
placeholder: 'ff0000ff (default)',
}, },
]; ];
}; };
@@ -1,7 +1,9 @@
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types'; import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { OverridableOptions } from '../../../common/models/View.types'; import { OverridableOptions } from '../../../common/models/View.types';
@@ -20,11 +22,17 @@ interface MinimalTimerProps {
export default function MinimalTimer(props: MinimalTimerProps) { export default function MinimalTimer(props: MinimalTimerProps) {
const { isMirrored, time, viewSettings } = props; const { isMirrored, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
useWindowTitle('Minimal Timer'); useWindowTitle('Minimal Timer');
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
// TODO: this should be tied to the params // TODO: this should be tied to the params
// USER OPTIONS // USER OPTIONS
const userOptions: OverridableOptions = { const userOptions: OverridableOptions = {
@@ -161,7 +169,6 @@ export default function MinimalTimer(props: MinimalTimerProps) {
<div <div
className={timerClasses} className={timerClasses}
style={{ style={{
color: userOptions.textColour,
fontSize: `${timerFontSize}vw`, fontSize: `${timerFontSize}vw`,
fontFamily: userOptions.font, fontFamily: userOptions.font,
top: userOptions.top, top: userOptions.top,
@@ -23,23 +23,26 @@ export const MINIMAL_TIMER_OPTIONS: ViewOption[] = [
{ {
id: 'key', id: 'key',
title: 'Key Colour', title: 'Key Colour',
description: 'Background or key colour for entire view. Default: #000000', description: 'Background colour in hexadecimal',
type: 'colour', prefix: '#',
defaultValue: '000000', type: 'string',
placeholder: '00000000 (default)',
}, },
{ {
id: 'text', id: 'text',
title: 'Text Colour', title: 'Text Colour',
description: 'Text colour. Default: #FFFFFF', description: 'Text colour in hexadecimal',
type: 'colour', prefix: '#',
defaultValue: 'FFFFFF', type: 'string',
placeholder: 'fffff (default)',
}, },
{ {
id: 'textbg', id: 'textbg',
title: 'Text Background', title: 'Text Background',
description: 'Background colour for timer text. Default: #FFF0 (transparent)', description: 'Colour of text background in hexadecimal',
type: 'colour', prefix: '#',
defaultValue: 'FFF0', type: 'string',
placeholder: '00000000 (default)',
}, },
{ {
id: 'font', id: 'font',
@@ -1,13 +1,15 @@
import QRCode from 'react-qr-code'; import QRCode from 'react-qr-code';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { CustomFields, OntimeEvent, ProjectData, Settings } from 'ontime-types'; import { CustomFields, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants';
import Schedule from '../../../common/components/schedule/Schedule'; import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext'; import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav'; import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
import TitleCard from '../../../common/components/title-card/TitleCard'; import TitleCard from '../../../common/components/title-card/TitleCard';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
@@ -31,6 +33,7 @@ interface BackstageProps {
events: OntimeEvent[]; events: OntimeEvent[];
publicSelectedId: string | null; publicSelectedId: string | null;
general: ProjectData; general: ProjectData;
viewSettings: ViewSettings;
settings: Settings | undefined; settings: Settings | undefined;
} }
@@ -44,14 +47,21 @@ export default function Public(props: BackstageProps) {
events, events,
publicSelectedId, publicSelectedId,
general, general,
viewSettings,
settings, settings,
} = props; } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
useWindowTitle('Public Schedule'); useWindowTitle('Public Schedule');
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
const clock = formatTime(time.clock); const clock = formatTime(time.clock);
const qrSize = Math.max(window.innerWidth / 15, 128); 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'; import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => { export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' }); const secondaryOptions = makeOptionsFromCustomFields(customFields);
return [ return [
{ section: 'Clock Options' }, { section: 'Clock Options' },
@@ -120,9 +120,6 @@ $orange-active: #f60;
} }
.next-title { .next-title {
height: 12.5vh;
width: 70%;
font-family: monospace; font-family: monospace;
font-weight: 400; font-weight: 400;
color: var(--studio-active-label, $cyan-active); color: var(--studio-active-label, $cyan-active);
@@ -1,10 +1,12 @@
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import type { MaybeString, OntimeEvent, OntimeRundown, Settings } from 'ontime-types'; import type { MaybeString, OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types';
import { Playback } from 'ontime-types'; import { Playback } from 'ontime-types';
import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils'; import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
import { FitText } from '../../../common/components/fit-text/FitText'; import { overrideStylesURL } from '../../../common/api/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; 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 { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
@@ -23,11 +25,17 @@ interface StudioClockProps {
selectedId: MaybeString; selectedId: MaybeString;
nextId: MaybeString; nextId: MaybeString;
onAir: boolean; onAir: boolean;
viewSettings: ViewSettings;
settings: Settings | undefined; settings: Settings | undefined;
} }
export default function StudioClock(props: StudioClockProps) { export default function StudioClock(props: StudioClockProps) {
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, settings } = props; 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 [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -69,7 +77,13 @@ export default function StudioClock(props: StudioClockProps) {
<div className='clock-container'> <div className='clock-container'>
{hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>} {hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>}
<div className={`studio-timer ${!hideSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div> <div className={`studio-timer ${!hideSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
<FitText className='next-title'>{eventNext?.title}</FitText> <div
ref={titleRef}
className='next-title'
style={{ fontSize: titleFontSize, height: '12.5vh', width: '100%', maxWidth: '80%' }}
>
{eventNext?.title}
</div>
<div <div
className={` className={`
next-countdown ${isNegative ? ' next-countdown--overtime' : ''} ${isPaused ? ' next-countdown--paused' : ''} next-countdown ${isNegative ? ' next-countdown--overtime' : ''} ${isPaused ? ' next-countdown--paused' : ''}
@@ -1,40 +1,39 @@
@use '../../theme/viewerDefs' as *; @use '../../../theme/viewerDefs' as *;
$timeline-entry-height: 20px;
$lane-height: 120px;
$timeline-height: 1rem; $timeline-height: 1rem;
$timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-override, $viewer-background-color));
.timeline { .timeline {
flex: 1; flex: 1;
font-weight: 600; font-weight: 600;
color: var(--color-override, $viewer-color); color: $ui-white;
background: var(--background-color-override, $viewer-background-color); background-color: $ui-black;
box-sizing: content-box; }
// create progress background
box-shadow: inset 0 1rem 0 0 var(--card-background-color-override, $viewer-card-bg-color); .timelineEvents {
position: relative; position: relative;
height: 100%;
} }
.column { .column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: absolute; position: absolute;
border-left: 1px solid var(--background-color-override, $viewer-background-color); border-left: 1px solid $ui-black;
// avoiding content being larger than the view // avoiding content being larger than the view
height: calc(100% - 3rem); height: calc(100% - 3rem);
}
// generate combined timeline // decorate timeline element
.timelineBlock { &::before {
height: $timeline-height; content: '';
background: $timeline-color; position: absolute;
width: 100%; box-sizing: content-box;
top: -$timeline-height;
&[data-status='done'] { left: 0;
background: $active-red; right: 0;
} height: $timeline-height;
background-color: $white-40;
&[data-status='live'] {
background: linear-gradient(to right, $active-red var(--progress, 0%), $timeline-color var(--progress, 0%));
} }
} }
@@ -66,9 +65,8 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
overflow: hidden; overflow: hidden;
line-height: 1rem; line-height: 1rem;
background-color: var(--lighter, var(--card-background-color-override, $viewer-card-bg-color)); background-color: var(--lighter, $viewer-card-bg-color);
border-bottom: 2px solid var(--background-color-override, $viewer-background-color); border-bottom: 2px solid $ui-black;
border-top: 2px solid var(--background-color-override, $viewer-background-color);
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300); box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
&[data-status='done'] { &[data-status='done'] {
@@ -0,0 +1,96 @@
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>
);
}
@@ -1,10 +1,9 @@
import { useTimelineStatus, useTimer } from '../../common/hooks/useSocket'; import { useTimelineStatus } from '../../../common/hooks/useSocket';
import { getProgress } from '../../common/utils/getProgress'; import { alpha, cx } from '../../../common/utils/styleUtils';
import { alpha, cx } from '../../common/utils/styleUtils'; import { formatDuration, formatTime } from '../../../common/utils/time';
import { formatDuration, formatTime } from '../../common/utils/time'; import { useTranslation } from '../../../translation/TranslationProvider';
import { useTranslation } from '../../translation/TranslationProvider';
import { getStatusLabel, getTimeToStart } from './timeline.utils'; import { getStatusLabel } from './timeline.utils';
import style from './Timeline.module.scss'; import style from './Timeline.module.scss';
@@ -49,7 +48,6 @@ export function TimelineEntry(props: TimelineEntryProps) {
width: `${width}px`, width: `${width}px`,
}} }}
> >
{status === 'live' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />}
<div <div
className={contentClasses} className={contentClasses}
data-status={status} data-status={status}
@@ -65,7 +63,7 @@ export function TimelineEntry(props: TimelineEntryProps) {
{status !== 'done' && ( {status !== 'done' && (
<> <>
<div className={style.duration}>{formattedDuration}</div> <div className={style.duration}>{formattedDuration}</div>
<TimelineEntryStatus delay={delay} start={start} status={status} /> <TimelineEntryStatus status={status} start={delayedStart} />
</> </>
)} )}
</div> </div>
@@ -74,19 +72,18 @@ export function TimelineEntry(props: TimelineEntryProps) {
} }
interface TimelineEntryStatusProps { interface TimelineEntryStatusProps {
delay: number;
start: number;
status: ProgressStatus; status: ProgressStatus;
start: number;
} }
// extract component to isolate re-renders provoked by the clock changes // we isolate this component to avoid isolate re-renders provoked by the clock changes
function TimelineEntryStatus(props: TimelineEntryStatusProps) { function TimelineEntryStatus(props: TimelineEntryStatusProps) {
const { delay, start, status } = props; const { status, start } = props;
const { clock, offset } = useTimelineStatus(); const { clock, offset } = useTimelineStatus();
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
// start times need to be normalised in a rundown that crosses midnight // start times need to be normalised in a rundown that crosses midnight
let statusText = getStatusLabel(getTimeToStart(clock, start, delay, offset), status); let statusText = getStatusLabel(start - clock + offset, status);
if (statusText === 'live') { if (statusText === 'live') {
statusText = getLocalizedString('timeline.live'); statusText = getLocalizedString('timeline.live');
} else if (statusText === 'pending') { } else if (statusText === 'pending') {
@@ -95,10 +92,3 @@ function TimelineEntryStatus(props: TimelineEntryStatusProps) {
return <div className={style.status}>{statusText}</div>; 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}%` }} />;
}
@@ -1,4 +1,4 @@
@use '../../theme/viewerDefs' as *; @use '../../../theme/viewerDefs' as *;
.timeline { .timeline {
width: 100vw; width: 100vw;
@@ -85,14 +85,6 @@
font-size: 3rem; font-size: 3rem;
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; 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 { .section-content--now {
@@ -1,27 +1,29 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { MaybeString, OntimeEvent, ProjectData, Runtime, Settings } from 'ontime-types'; import { MaybeString, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import { overrideStylesURL } from '../../../common/api/constants';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { ViewExtendedTimer } from '../../common/models/TimeManager.type'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { formatDuration, formatTime, getDefaultFormat } from '../../common/utils/time'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { useTranslation } from '../../translation/TranslationProvider'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import Section from './timeline-section/TimelineSection'; import Section from './timeline-section/TimelineSection';
import Timeline from './Timeline'; import Timeline from './Timeline';
import { getTimelineOptions } from './timeline.options'; import { getTimelineOptions } from './timeline.options';
import { getTimeToStart, getUpcomingEvents, useScopedRundown } from './timeline.utils'; import { getFormattedTimeToStart, getScopedRundown, getUpcomingEvents } from './timeline.utils';
import './TimelinePage.scss'; import './TimelinePage.scss';
interface TimelinePageProps { interface TimelinePageProps {
backstageEvents: OntimeEvent[]; backstageEvents: OntimeEvent[];
general: ProjectData; general: ProjectData;
runtime: Runtime;
selectedId: MaybeString; selectedId: MaybeString;
settings: Settings | undefined; settings: Settings | undefined;
time: ViewExtendedTimer; time: ViewExtendedTimer;
viewSettings: ViewSettings;
} }
/** /**
@@ -30,18 +32,27 @@ interface TimelinePageProps {
* There is little point splitting or memoising top level elements * There is little point splitting or memoising top level elements
*/ */
export default function TimelinePage(props: TimelinePageProps) { export default function TimelinePage(props: TimelinePageProps) {
const { backstageEvents, general, runtime, selectedId, settings, time } = props; const { backstageEvents, general, selectedId, settings, time, viewSettings } = props;
// holds copy of the rundown with only relevant events const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(backstageEvents, selectedId);
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const clock = formatTime(time.clock); 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(() => { const { now, next, followedBy } = useMemo(() => {
return getUpcomingEvents(scopedRundown, selectedId); return getUpcomingEvents(scopedRundown, selectedId);
}, [scopedRundown, selectedId]); }, [scopedRundown, selectedId]);
useWindowTitle('Timeline'); useWindowTitle('Timeline');
if (!shouldRender) {
return null;
}
// populate options // populate options
const defaultFormat = getDefaultFormat(settings?.timeFormat); const defaultFormat = getDefaultFormat(settings?.timeFormat);
const progressOptions = getTimelineOptions(defaultFormat); const progressOptions = getTimelineOptions(defaultFormat);
@@ -50,26 +61,10 @@ export default function TimelinePage(props: TimelinePageProps) {
const dueText = getLocalizedString('timeline.due').toUpperCase(); const dueText = getLocalizedString('timeline.due').toUpperCase();
const nextText = next !== null ? next.title : '-'; const nextText = next !== null ? next.title : '-';
const followedByText = followedBy !== null ? followedBy.title : '-'; const followedByText = followedBy !== null ? followedBy.title : '-';
let nextStatus: string | undefined;
let followedByStatus: string | undefined;
if (next !== null) { const nextStatus = next !== null ? getFormattedTimeToStart(next, time.clock, dueText) : undefined;
const timeToStart = getTimeToStart(time.clock, next.timeStart, next?.delay ?? 0, runtime.offset); const followedByStatus = followedBy !== null ? getFormattedTimeToStart(followedBy, time.clock, dueText) : undefined;
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 ( return (
<div className='timeline'> <div className='timeline'>
<ViewParamsEditor viewOptions={progressOptions} /> <ViewParamsEditor viewOptions={progressOptions} />
@@ -91,12 +86,7 @@ export default function TimelinePage(props: TimelinePageProps) {
category='next' category='next'
/> />
</div> </div>
<Timeline <Timeline selectedEventId={selectedId} rundown={scopedRundown} />
firstStart={firstStart}
rundown={scopedRundown}
selectedEventId={selectedId}
totalDuration={totalDuration}
/>
</div> </div>
); );
} }
@@ -1,6 +1,6 @@
import { dayInMs } from 'ontime-utils'; import { dayInMs } from 'ontime-utils';
import { getElementPosition, getTimeToStart, makeTimelineSections } from '../timeline.utils'; import { getElementPosition, makeTimelineSections } from '../timeline.utils';
describe('getCSSPosition()', () => { describe('getCSSPosition()', () => {
it('accounts for rundown with one event', () => { it('accounts for rundown with one event', () => {
@@ -53,40 +53,14 @@ describe('getCSSPosition()', () => {
}); });
}); });
describe('makeTimelineSections', () => { describe('makeTmelineSections', () => {
it('creates an array between the hours given, end excluded', () => { it('creates an array between the hours given, end excluded', () => {
const result = makeTimelineSections(11, 17); const result = makeTimelineSections(11, 17);
expect(result).toEqual([11, 12, 13, 14, 15, 16]); expect(result).toEqual(['11:00', '12:00', '13:00', '14:00', '15:00', '16:00']);
}); });
});
it('wraps around midnight', () => {
describe('getTimeToStart()', () => { const result = makeTimelineSections(22, 26);
it("is the gap between now and the event's start time accounted for delays", () => { expect(result).toEqual(['22:00', '23:00', '00:00', '01:00']);
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);
}); });
}); });
@@ -0,0 +1,16 @@
.markers {
width: 100%;
color: $ui-white;
display: flex;
height: 1rem;
line-height: 1rem;
margin-bottom: 0.25rem;
font-size: calc(1rem - 2px);
justify-content: space-evenly;
& > span {
flex-grow: 1;
border-left: 1px solid $white-7;
height: 100vh;
}
}
@@ -7,7 +7,6 @@ interface TimelineMarkersProps {
endHour: number; endHour: number;
} }
/** Creates a line for every hour in the timeline */
export default function TimelineMarkers(props: TimelineMarkersProps) { export default function TimelineMarkers(props: TimelineMarkersProps) {
const { startHour, endHour } = props; const { startHour, endHour } = props;
@@ -15,9 +14,9 @@ export default function TimelineMarkers(props: TimelineMarkersProps) {
return ( return (
<div className={style.markers}> <div className={style.markers}>
{elements.map((tag) => ( {elements.map((tag, index) => {
<span key={tag} /> return <span key={`${index}-${tag}`}>{tag}</span>;
))} })}
</div> </div>
); );
} }
@@ -0,0 +1,19 @@
.progressBar {
width: 100%;
height: 1rem;
position: relative;
background-color: $gray-1000;
}
.progress {
height: 100%;
position: absolute;
left: 0;
top: 0;
z-index: 2;
background-color: $active-red;
transition-duration: 0.3s;
transition-property: width;
}
@@ -0,0 +1,25 @@
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { useClock } from '../../../../common/hooks/useSocket';
import { getRelativePositionX } from '../timeline.utils';
import style from './TimelineProgressBar.module.scss';
interface ProgressBarProps {
startHour: number;
endHour: number;
}
export default function ProgressBar(props: ProgressBarProps) {
const { startHour, endHour } = props;
// TODO: how to account for days?
const { clock } = useClock();
const width = getRelativePositionX(startHour * MILLIS_PER_HOUR, endHour * MILLIS_PER_HOUR, clock);
return (
<div className={style.progressBar}>
<div className={style.progress} style={{ width: `${width}%` }} />
</div>
);
}
@@ -1,7 +1,7 @@
import { memo } from 'react'; import { memo } from 'react';
import { MaybeString } from 'ontime-types'; import { MaybeString } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
interface SectionProps { interface SectionProps {
category: 'now' | 'next'; category: 'now' | 'next';
@@ -1,5 +1,5 @@
import { getTimeOption } from '../../common/components/view-params-editor/constants'; import { getTimeOption } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/types'; import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getTimelineOptions = (timeFormat: string): ViewOption[] => { export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
return [ return [
@@ -0,0 +1,154 @@
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,22 +129,20 @@
} }
} }
.secondary { .external {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
margin-top: 0.25em;
padding-block: 0.25em;
font-weight: 600; font-weight: 600;
text-align: center; text-align: center;
color: var(--external-color-override, $external-color); color: var(--external-color-override, $external-color);
letter-spacing: 0.5px; letter-spacing: 0.5px;
line-height: 0.9em; line-height: 0.9em;
padding-bottom: 0.2em;
transition-property: opacity, height; transition-property: opacity, height;
transition-duration: $viewer-transition-time; transition-duration: $viewer-transition-time;
border-top: 1px solid color-mix(in srgb, var(--external-color-override, $external-color) 10%, transparent); border-top: 1px solid rgba(white, 0.1);
&--hidden { &--hidden {
opacity: 0; opacity: 0;
@@ -169,28 +167,36 @@
.message-overlay { .message-overlay {
position: fixed; position: fixed;
width: 100%;
height: 100%;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
padding: 2vw; background-color: $viewer-overlay-bg-color;
background: var(--background-color-override, $viewer-background-color); z-index: -1;
opacity: 0; opacity: 0;
transition: opacity $viewer-transition-time; transition: $viewer-transition-time;
z-index: 2;
&--active { &--active {
opacity: 1; opacity: 1;
transition: $viewer-transition-time;
transition-property: opacity;
z-index: 2;
} }
} }
.message { .message {
display: grid; width: inherit;
place-content: center; padding: 2vw;
height: 100%; position: absolute;
width: 100%; top: 50%;
left: 50%;
color: var(--color-override, $viewer-color); color: $viewer-color;
transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
font-size: 15vw;
line-height: 30vh;
text-align: center; text-align: center;
font-weight: 600; font-weight: 600;
} }
@@ -2,20 +2,21 @@ import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { import {
CustomFields, CustomFields,
MessageState, Message,
OntimeEvent, OntimeEvent,
Playback, Playback,
Settings, Settings,
SimpleTimerState, TimerMessage,
TimerPhase, TimerPhase,
TimerType, TimerType,
ViewSettings, ViewSettings,
} from 'ontime-types'; } from 'ontime-types';
import { FitText } from '../../../common/components/fit-text/FitText'; import { overrideStylesURL } from '../../../common/api/constants';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar'; import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import TitleCard from '../../../common/components/title-card/TitleCard'; import TitleCard from '../../../common/components/title-card/TitleCard';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
@@ -46,25 +47,31 @@ const titleVariants = {
export const MotionTitleCard = motion(TitleCard); export const MotionTitleCard = motion(TitleCard);
interface TimerProps { interface TimerProps {
auxTimer: SimpleTimerState;
customFields: CustomFields; customFields: CustomFields;
eventNext: OntimeEvent | null; eventNext: OntimeEvent | null;
eventNow: OntimeEvent | null; eventNow: OntimeEvent | null;
external: Message;
isMirrored: boolean; isMirrored: boolean;
message: MessageState; pres: TimerMessage;
settings: Settings | undefined; settings: Settings | undefined;
time: ViewExtendedTimer; time: ViewExtendedTimer;
viewSettings: ViewSettings; viewSettings: ViewSettings;
} }
export default function Timer(props: TimerProps) { export default function Timer(props: TimerProps) {
const { auxTimer, customFields, eventNow, eventNext, isMirrored, message, settings, time, viewSettings } = props; const { customFields, isMirrored, pres, eventNow, eventNext, time, viewSettings, external, settings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
useWindowTitle('Timer'); useWindowTitle('Timer');
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
// USER OPTIONS // USER OPTIONS
const userOptions = { const userOptions = {
hideClock: false, hideClock: false,
@@ -106,7 +113,7 @@ export default function Timer(props: TimerProps) {
const mainFieldNow = (main ? getPropertyValue(eventNow, main) : eventNow?.title) ?? ''; const mainFieldNow = (main ? getPropertyValue(eventNow, main) : eventNow?.title) ?? '';
const mainFieldNext = (main ? getPropertyValue(eventNext, main) : eventNext?.title) ?? ''; const mainFieldNext = (main ? getPropertyValue(eventNext, main) : eventNext?.title) ?? '';
const showOverlay = message.timer.text !== '' && message.timer.visible; const showOverlay = pres.text !== '' && pres.visible;
const isPlaying = time.playback !== Playback.Pause; const isPlaying = time.playback !== Playback.Pause;
const timerIsTimeOfDay = time.timerType === TimerType.Clock; const timerIsTimeOfDay = time.timerType === TimerType.Clock;
@@ -120,20 +127,10 @@ export default function Timer(props: TimerProps) {
const showFinished = finished && (shouldShowModifiers || showEndMessage); const showFinished = finished && (shouldShowModifiers || showEndMessage);
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning; const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger; const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
const showBlinking = pres.blink;
const showBlackout = pres.blackout;
const showClock = time.timerType !== TimerType.Clock; 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; let timerColor = viewSettings.normalColor;
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor; if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
@@ -148,14 +145,13 @@ export default function Timer(props: TimerProps) {
const stageTimerCharacters = display.replace('/:/g', '').length; const stageTimerCharacters = display.replace('/:/g', '').length;
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`; const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`;
let timerFontSize = 89 / (stageTimerCharacters - 1); let timerFontSize = 89 / (stageTimerCharacters - 1);
// we need to shrink the timer if the external is going to be there // we need to shrink the timer if the external is going to be there
if (secondaryContent) { if (showExternal) {
timerFontSize *= 0.8; timerFontSize *= 0.8;
} }
const externalFontSize = timerFontSize * 0.4; const externalFontSize = timerFontSize * 0.4;
const timerContainerClasses = `timer-container ${message.timer.blink ? (showOverlay ? '' : 'blink') : ''}`; const timerContainerClasses = `timer-container ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`;
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`; const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
const defaultFormat = getDefaultFormat(settings?.timeFormat); const defaultFormat = getDefaultFormat(settings?.timeFormat);
@@ -164,12 +160,10 @@ export default function Timer(props: TimerProps) {
return ( return (
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'> <div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
<ViewParamsEditor viewOptions={timerOptions} /> <ViewParamsEditor viewOptions={timerOptions} />
<div className={message.timer.blackout ? 'blackout blackout--active' : 'blackout'} /> <div className={showBlackout ? 'blackout blackout--active' : 'blackout'} />
{!userOptions.hideMessage && ( {!userOptions.hideMessage && (
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}> <div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
<FitText mode='multi' min={32} max={256} className={`message ${message.timer.blink ? 'blink' : ''}`}> <div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
{message.timer.text}
</FitText>
</div> </div>
)} )}
@@ -195,10 +189,10 @@ export default function Timer(props: TimerProps) {
</div> </div>
)} )}
<div <div
className={`secondary${secondaryContent ? '' : ' secondary--hidden'}`} className={`external${showExternal ? '' : ' external--hidden'}`}
style={{ fontSize: `${externalFontSize}vw` }} style={{ fontSize: `${externalFontSize}vw` }}
> >
{secondaryContent} {external.text}
</div> </div>
</div> </div>
@@ -9,9 +9,8 @@ import {
import { ViewOption } from '../../../common/components/view-params-editor/types'; import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => { export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' }); const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title' });
const secondaryOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' }); const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [ return [
{ section: 'Clock Options' }, { section: 'Clock Options' },
getTimeOption(timeFormat), getTimeOption(timeFormat),
+1 -1
View File
@@ -1,7 +1,7 @@
export const ontimeAlertOnDark = { export const ontimeAlertOnDark = {
container: { container: {
fontSize: 'calc(1rem - 1px)', fontSize: 'calc(1rem - 1px)',
backgroundColor: '#202020', // $gray-1200 backgroundColor: '#1a1a1a', // $gray-1300
color: '#e2e2e2', // $gray-200 color: '#e2e2e2', // $gray-200
borderRadius: '3px', borderRadius: '3px',
}, },
-8
View File
@@ -21,11 +21,3 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
@mixin rotate-fourty-five {
transform: rotate(45deg);
}
.fourtyfive {
@include rotate-fourty-five;
}
+3 -4
View File
@@ -3,6 +3,8 @@
$transition-time-action: 0.1s; $transition-time-action: 0.1s;
$transition-time-feedback: 0.3s; $transition-time-feedback: 0.3s;
$blinking-time: 1.2s;
$component-border-radius-md: 3px; $component-border-radius-md: 3px;
$component-border-radius-sm: 2px; $component-border-radius-sm: 2px;
@@ -59,13 +61,10 @@ $text-body-size: calc(1rem - 1px);
$min-tablet: 500px; $min-tablet: 500px;
.blink { .blink {
animation: blink 1s step-start infinite; animation: blink $blinking-time linear infinite;
} }
@keyframes blink { @keyframes blink {
0% {
opacity: 100%;
}
50% { 50% {
opacity: 20%; opacity: 20%;
} }
+3 -2
View File
@@ -19,6 +19,7 @@ $viewer-background-color: $ui-black; // --background-color-override
$viewer-color: rgba(white, 80%); // --color-override $viewer-color: rgba(white, 80%); // --color-override
$viewer-secondary-color: rgba(white, 45%); // --secondary-color-override $viewer-secondary-color: rgba(white, 45%); // --secondary-color-override
$viewer-card-bg-color: rgba(white, 7%); // --card-background-color-override $viewer-card-bg-color: rgba(white, 7%); // --card-background-color-override
$viewer-overlay-bg-color: rgba(black, 90%);
$element-border-radius: 8px; $element-border-radius: 8px;
// Properties related to timer // Properties related to timer
@@ -26,8 +27,8 @@ $timer-color: rgba(white, 80%); // --timer-color-override
$timer-finished-color: $playback-negative; $timer-finished-color: $playback-negative;
$timer-bold-font-family: 'Arial Black', sans-serif; // --card-background-color-override $timer-bold-font-family: 'Arial Black', sans-serif; // --card-background-color-override
$external-color: rgba(white, 85%); // --external-color-override $external-color: rgba(white, 70%); // --external-color-override
// properties of other timers (clock and countdown) // properties of other timers (clock and countdown)
$timer-label-size: clamp(16px, 1.5vw, 24px); $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);
-2
View File
@@ -4,8 +4,6 @@ export const ontimeDrawer = {
backgroundColor: '#202020', // $gray-1250 backgroundColor: '#202020', // $gray-1250
}, },
body: { body: {
display: 'flex',
flexDirection: 'column',
color: '#fefefe', // $gray-50 color: '#fefefe', // $gray-50
backgroundColor: '#202020', // $gray-1250 backgroundColor: '#202020', // $gray-1250
}, },
+9 -9
View File
@@ -1,13 +1,13 @@
export const navigatorConstants = [ export const navigatorConstants = [
{ url: 'timer', label: 'Timer' }, { url: '/timer', label: 'Timer' },
{ url: 'minimal', label: 'Minimal Timer' }, { url: '/clock', label: 'Clock' },
{ url: 'clock', label: 'Wall Clock' }, { url: '/minimal', label: 'Minimal Timer' },
{ url: 'backstage', label: 'Backstage' }, { url: '/backstage', label: 'Backstage' },
{ url: 'timeline', label: 'Timeline (beta)' }, { url: '/timeline', label: 'Timeline (beta)' },
{ url: 'public', label: 'Public' }, { url: '/public', label: 'Public' },
{ url: 'lower', label: 'Lower Thirds' }, { url: '/lower', label: 'Lower Thirds' },
{ url: 'studio', label: 'Studio Clock' }, { url: '/studio', label: 'Studio Clock' },
{ url: 'countdown', label: 'Countdown' }, { url: '/countdown', label: 'Countdown' },
]; ];
// default time format to use for users in 12 hour clocks // default time format to use for users in 12 hour clocks

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