Compare commits

..

1 Commits

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