Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 281b1649fe | |||
| 652fe25fd1 |
@@ -40,6 +40,9 @@ dist/
|
|||||||
ontime-db
|
ontime-db
|
||||||
ontime-external/
|
ontime-external/
|
||||||
|
|
||||||
|
# working database
|
||||||
|
apps/server/src/preloaded-db/db.json
|
||||||
|
|
||||||
# versioning file
|
# versioning file
|
||||||
**/ONTIME_VERSION.js
|
**/ONTIME_VERSION.js
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,8 @@
|
|||||||
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
|
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
|
||||||
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
|
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
|
||||||
|
|
||||||
## Need help?
|
|
||||||
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
|
|
||||||
|
|
||||||
## Using Ontime?
|
## Using Ontime?
|
||||||
|
|
||||||
Let us know!
|
Let us know!
|
||||||
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
|
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
|
||||||
|
|
||||||
@@ -37,16 +35,6 @@ Ontime is made by entertainment and broadcast engineers and used by
|
|||||||
- Theatres and opera houses
|
- Theatres and opera houses
|
||||||
- Houses of worship
|
- Houses of worship
|
||||||
|
|
||||||
## Main features
|
|
||||||
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
|
|
||||||
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
|
|
||||||
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
|
|
||||||
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
|
|
||||||
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
|
|
||||||
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
|
|
||||||
|
|
||||||
... and a lot more ...
|
|
||||||
|
|
||||||
### For live environments
|
### For live environments
|
||||||
|
|
||||||
Ontime is designed for use in live environments. \
|
Ontime is designed for use in live environments. \
|
||||||
@@ -95,7 +83,6 @@ IP.ADDRESS:4001/clock > Simple clock view
|
|||||||
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
|
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
|
||||||
IP.ADDRESS:4001/countdown > Countdown to anything
|
IP.ADDRESS:4001/countdown > Countdown to anything
|
||||||
IP.ADDRESS:4001/studio > Studio Clock
|
IP.ADDRESS:4001/studio > Studio Clock
|
||||||
IP.ADDRESS:4001/timeline > Timeline
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -113,13 +100,26 @@ IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
|||||||
IP.ADDRESS:4001/operator > automated views for operators
|
IP.ADDRESS:4001/operator > automated views for operators
|
||||||
```
|
```
|
||||||
|
|
||||||
More information is available [in our docs](https://docs.getontime.no)
|
More documentation is available [in our docs](https://docs.getontime.no)
|
||||||
|
|
||||||
|
## Main features
|
||||||
|
|
||||||
|
- [x] Distribute data over network and render it in the browser
|
||||||
|
- [x] Collaborative
|
||||||
|
- [x] Extendable
|
||||||
|
- [x] Send messages to different screen types
|
||||||
|
- [x] Differentiate between backstage and public data
|
||||||
|
- [x] Workflow for managing delays
|
||||||
|
- [x] Rich protocol integrations for Control and Feedback
|
||||||
|
- [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
|
||||||
|
- [x] Multi-platform (available on Windows, MacOS and Linux)
|
||||||
|
- [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### Continued development
|
### Continued development
|
||||||
|
|
||||||
Ontime is under active development. We continue adding and improving features in collaboration with users.
|
Ontime is under active development. We continue adding and tweaking features in collaboration with users.
|
||||||
|
|
||||||
Have an idea? Reach out via [email](mail@getontime.no)
|
Have an idea? Reach out via [email](mail@getontime.no)
|
||||||
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||||
@@ -161,9 +161,8 @@ If you are a developer and would like to contribute with code, please open an is
|
|||||||
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
|
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
|
||||||
|
|
||||||
## Links
|
## Links
|
||||||
- [Ontime website](https://getontime.no)
|
|
||||||
- [Documentation](https://docs.getontime.no)
|
See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no)
|
||||||
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "3.6.0",
|
"version": "3.5.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.6.0",
|
"version": "3.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const location = window.location;
|
|||||||
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
export const isProduction = import.meta.env.MODE === 'production';
|
export const isProduction = import.meta.env.MODE === 'production';
|
||||||
export const isDev = !isProduction;
|
export const isDev = !isProduction;
|
||||||
export const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
|
||||||
|
|
||||||
// resolve port
|
// resolve port
|
||||||
const STATIC_PORT = 4001;
|
const STATIC_PORT = 4001;
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copied from
|
|
||||||
* https://github.com/namhong2001/react-textfit/blob/master/index.tsx
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { HTMLAttributes, PropsWithChildren, useCallback, useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
import { bsearch } from './fitText.utils';
|
|
||||||
|
|
||||||
interface FitTextProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
mode?: 'single' | 'multi';
|
|
||||||
min?: number; // inclusive
|
|
||||||
max?: number; // inclusive
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FitText(props: PropsWithChildren<FitTextProps>) {
|
|
||||||
const { children, mode = 'multi', min = 16, max = 256, ...elementProps } = props;
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const isOverflown = useCallback(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return false;
|
|
||||||
return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setFontSize = useCallback(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
const originVisibility = el.style.visibility;
|
|
||||||
|
|
||||||
el.style.visibility = 'hidden';
|
|
||||||
const fontSize = bsearch(min, max + 1, (mid) => {
|
|
||||||
el.style.fontSize = `${mid}px`;
|
|
||||||
return !isOverflown();
|
|
||||||
});
|
|
||||||
el.style.fontSize = `${fontSize}px`;
|
|
||||||
el.style.visibility = originVisibility;
|
|
||||||
}, [isOverflown, min, max]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
setFontSize();
|
|
||||||
const observer = new ResizeObserver(setFontSize);
|
|
||||||
observer.observe(el);
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [children, mode, setFontSize]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
style={{
|
|
||||||
whiteSpace: mode === 'single' ? 'nowrap' : 'normal',
|
|
||||||
}}
|
|
||||||
{...elementProps}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
/**
|
|
||||||
* @param low inclusive, must be true on predicate function
|
|
||||||
* @param high exclusive,
|
|
||||||
* @param predicate predicate function
|
|
||||||
*/
|
|
||||||
export const bsearch = (low: number, high: number, predicate: (mid: number) => boolean): number => {
|
|
||||||
while (low < high) {
|
|
||||||
const mid = Math.floor((low + high) / 2);
|
|
||||||
if (mid === low) break;
|
|
||||||
|
|
||||||
if (predicate(mid)) {
|
|
||||||
low = mid;
|
|
||||||
} else {
|
|
||||||
high = mid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return low;
|
|
||||||
};
|
|
||||||
@@ -47,8 +47,6 @@ $button-size: 3rem;
|
|||||||
@include action-link;
|
@include action-link;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $menu-hover-bg;
|
background-color: $menu-hover-bg;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { memo, PropsWithChildren, useRef } from 'react';
|
import { memo, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerBody,
|
DrawerBody,
|
||||||
@@ -19,12 +19,9 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
|||||||
|
|
||||||
import { navigatorConstants } from '../../../viewerConfig';
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
import useClickOutside from '../../hooks/useClickOutside';
|
import useClickOutside from '../../hooks/useClickOutside';
|
||||||
import useElectronEvent from '../../hooks/useElectronEvent';
|
|
||||||
import { useClientStore } from '../../stores/clientStore';
|
import { useClientStore } from '../../stores/clientStore';
|
||||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||||
import { isKeyEnter } from '../../utils/keyEvent';
|
import { isKeyEnter } from '../../utils/keyEvent';
|
||||||
import { handleLinks } from '../../utils/linkUtils';
|
|
||||||
import { cx } from '../../utils/styleUtils';
|
|
||||||
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||||
|
|
||||||
import style from './NavigationMenu.module.scss';
|
import style from './NavigationMenu.module.scss';
|
||||||
@@ -43,7 +40,6 @@ function NavigationMenu(props: NavigationMenuProps) {
|
|||||||
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
|
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
|
||||||
const { fullscreen, toggle } = useFullscreen();
|
const { fullscreen, toggle } = useFullscreen();
|
||||||
const { toggleMirror } = useViewOptionsStore();
|
const { toggleMirror } = useViewOptionsStore();
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -100,29 +96,38 @@ function NavigationMenu(props: NavigationMenuProps) {
|
|||||||
<hr className={style.separator} />
|
<hr className={style.separator} />
|
||||||
<Link
|
<Link
|
||||||
to='/editor'
|
to='/editor'
|
||||||
|
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={`${style.link} ${location.pathname === '/editor' && style.current}`}
|
|
||||||
>
|
>
|
||||||
<IoLockClosedOutline />
|
<IoLockClosedOutline />
|
||||||
Editor
|
Editor
|
||||||
<IoArrowUp className={style.linkIcon} />
|
<IoArrowUp className={style.linkIcon} />
|
||||||
</Link>
|
</Link>
|
||||||
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
|
<Link
|
||||||
|
to='/cuesheet'
|
||||||
|
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
<IoLockClosedOutline />
|
<IoLockClosedOutline />
|
||||||
Cuesheet
|
Cuesheet
|
||||||
<IoArrowUp className={style.linkIcon} />
|
<IoArrowUp className={style.linkIcon} />
|
||||||
</ClientLink>
|
</Link>
|
||||||
<ClientLink to='op' current={location.pathname === '/op'}>
|
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
|
||||||
<IoLockClosedOutline />
|
<IoLockClosedOutline />
|
||||||
Operator
|
Operator
|
||||||
<IoArrowUp className={style.linkIcon} />
|
<IoArrowUp className={style.linkIcon} />
|
||||||
</ClientLink>
|
</Link>
|
||||||
<hr className={style.separator} />
|
<hr className={style.separator} />
|
||||||
{navigatorConstants.map((route) => (
|
{navigatorConstants.map((route) => (
|
||||||
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
|
<Link
|
||||||
|
key={route.url}
|
||||||
|
to={route.url}
|
||||||
|
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
{route.label}
|
{route.label}
|
||||||
<IoArrowUp className={style.linkIcon} />
|
<IoArrowUp className={style.linkIcon} />
|
||||||
</ClientLink>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</DrawerBody>
|
</DrawerBody>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
@@ -132,30 +137,4 @@ function NavigationMenu(props: NavigationMenuProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClientLinkProps {
|
|
||||||
current: boolean;
|
|
||||||
to: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClientLink(props: PropsWithChildren<ClientLinkProps>) {
|
|
||||||
const { current, to, children } = props;
|
|
||||||
const { isElectron } = useElectronEvent();
|
|
||||||
|
|
||||||
const classes = cx([style.link, current && style.current]);
|
|
||||||
|
|
||||||
if (isElectron) {
|
|
||||||
return (
|
|
||||||
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link to={`/${to}`} className={classes} tabIndex={0}>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(NavigationMenu);
|
export default memo(NavigationMenu);
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import {
|
|||||||
parseUserTime,
|
parseUserTime,
|
||||||
reorderArray,
|
reorderArray,
|
||||||
swapEventData,
|
swapEventData,
|
||||||
validateEndAction,
|
|
||||||
validateTimerType,
|
|
||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
|
|
||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
@@ -34,15 +32,7 @@ import { useEditorSettings } from '../stores/editorSettings';
|
|||||||
*/
|
*/
|
||||||
export const useEventAction = () => {
|
export const useEventAction = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const {
|
const { defaultPublic, linkPrevious, defaultDuration, defaultWarnTime, defaultDangerTime } = useEditorSettings();
|
||||||
defaultPublic,
|
|
||||||
linkPrevious,
|
|
||||||
defaultDuration,
|
|
||||||
defaultWarnTime,
|
|
||||||
defaultDangerTime,
|
|
||||||
defaultTimerType,
|
|
||||||
defaultEndAction,
|
|
||||||
} = useEditorSettings();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls mutation to add new event
|
* Calls mutation to add new event
|
||||||
@@ -56,17 +46,17 @@ export const useEventAction = () => {
|
|||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
|
|
||||||
// options to any new block (event / delay / block)
|
|
||||||
type BaseOptions = {
|
type BaseOptions = {
|
||||||
after?: string;
|
after?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// options to blocks of type OntimeEvent
|
|
||||||
type EventOptions = BaseOptions &
|
type EventOptions = BaseOptions &
|
||||||
Partial<{
|
Partial<{
|
||||||
defaultPublic: boolean;
|
defaultPublic: boolean;
|
||||||
linkPrevious: boolean;
|
linkPrevious: boolean;
|
||||||
lastEventId: string;
|
lastEventId: string;
|
||||||
|
defaultWarnTime: number;
|
||||||
|
defaultDangerTime: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,12 +68,13 @@ export const useEventAction = () => {
|
|||||||
|
|
||||||
// ************* CHECK OPTIONS specific to events
|
// ************* CHECK OPTIONS specific to events
|
||||||
if (isOntimeEvent(newEvent)) {
|
if (isOntimeEvent(newEvent)) {
|
||||||
// merge creation time options with event settings
|
|
||||||
const applicationOptions = {
|
const applicationOptions = {
|
||||||
after: options?.after,
|
after: options?.after,
|
||||||
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
||||||
lastEventId: options?.lastEventId,
|
lastEventId: options?.lastEventId,
|
||||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
||||||
|
defaultWarnTime,
|
||||||
|
defaultDangerTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
|
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
|
||||||
@@ -98,7 +89,6 @@ export const useEventAction = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override event with options from editor settings
|
|
||||||
if (applicationOptions.defaultPublic) {
|
if (applicationOptions.defaultPublic) {
|
||||||
newEvent.isPublic = true;
|
newEvent.isPublic = true;
|
||||||
}
|
}
|
||||||
@@ -114,14 +104,6 @@ export const useEventAction = () => {
|
|||||||
if (newEvent.timeWarning === undefined) {
|
if (newEvent.timeWarning === undefined) {
|
||||||
newEvent.timeWarning = parseUserTime(defaultWarnTime);
|
newEvent.timeWarning = parseUserTime(defaultWarnTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newEvent.timerType === undefined) {
|
|
||||||
newEvent.timerType = validateTimerType(defaultTimerType);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newEvent.endAction === undefined) {
|
|
||||||
newEvent.endAction = validateEndAction(defaultEndAction);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle adding options that concern all event type
|
// handle adding options that concern all event type
|
||||||
@@ -135,17 +117,7 @@ export const useEventAction = () => {
|
|||||||
logAxiosError('Failed adding event', error);
|
logAxiosError('Failed adding event', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[_addEventMutation, defaultDangerTime, defaultDuration, defaultPublic, defaultWarnTime, linkPrevious, queryClient],
|
||||||
_addEventMutation,
|
|
||||||
defaultDangerTime,
|
|
||||||
defaultDuration,
|
|
||||||
defaultEndAction,
|
|
||||||
defaultPublic,
|
|
||||||
defaultTimerType,
|
|
||||||
defaultWarnTime,
|
|
||||||
linkPrevious,
|
|
||||||
queryClient,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||||
|
|
||||||
import { useRuntimeStore } from '../stores/runtime';
|
import { useRuntimeStore } from '../stores/runtime';
|
||||||
import { socketSendJson } from '../utils/socket';
|
import { socketSendJson } from '../utils/socket';
|
||||||
@@ -28,43 +28,11 @@ export const useOperator = () => {
|
|||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useTimerViewControl = () => {
|
export const useMessageControl = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
blackout: state.message.timer.blackout,
|
timer: state.message.timer,
|
||||||
blink: state.message.timer.blink,
|
external: state.message.external,
|
||||||
secondarySource: state.message.timer.secondarySource,
|
onAir: state.onAir,
|
||||||
});
|
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useTimerMessageInput = () => {
|
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
|
||||||
text: state.message.timer.text,
|
|
||||||
visible: state.message.timer.visible,
|
|
||||||
});
|
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useExternalMessageInput = () => {
|
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
|
||||||
text: state.message.external,
|
|
||||||
visible: state.message.timer.secondarySource === 'external',
|
|
||||||
});
|
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useMessagePreview = () => {
|
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
|
||||||
blink: state.message.timer.blink,
|
|
||||||
blackout: state.message.timer.blackout,
|
|
||||||
phase: state.timer.phase,
|
|
||||||
showAuxTimer: state.message.timer.secondarySource === 'aux',
|
|
||||||
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
|
|
||||||
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
|
||||||
timerType: state.eventNow?.timerType ?? null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
@@ -73,11 +41,8 @@ export const useMessagePreview = () => {
|
|||||||
export const setMessage = {
|
export const setMessage = {
|
||||||
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
|
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
|
||||||
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
|
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
|
||||||
externalText: (payload: string) => socketSendJson('message', { external: payload }),
|
|
||||||
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
|
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
|
||||||
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
|
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
|
||||||
timerSecondary: (payload: TimerMessage['secondarySource']) =>
|
|
||||||
socketSendJson('message', { timer: { secondarySource: payload } }),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePlaybackControl = () => {
|
export const usePlaybackControl = () => {
|
||||||
@@ -222,6 +187,15 @@ export const useRuntimePlaybackOverview = () => {
|
|||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useTimelineOverview = () => {
|
||||||
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
|
plannedStart: state.runtime.plannedStart,
|
||||||
|
plannedEnd: state.runtime.plannedEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useRuntimeStore(featureSelector);
|
||||||
|
};
|
||||||
|
|
||||||
export const useTimelineStatus = () => {
|
export const useTimelineStatus = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
clock: state.clock,
|
clock: state.clock,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { EndAction, TimerType } from 'ontime-types';
|
|
||||||
import { validateEndAction, validateTimerType } from 'ontime-utils';
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
import { booleanFromLocalStorage } from '../utils/localStorage';
|
import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||||
@@ -10,15 +8,11 @@ type EditorSettingsStore = {
|
|||||||
defaultWarnTime: string;
|
defaultWarnTime: string;
|
||||||
defaultDangerTime: string;
|
defaultDangerTime: string;
|
||||||
defaultPublic: boolean;
|
defaultPublic: boolean;
|
||||||
defaultTimerType: TimerType;
|
|
||||||
defaultEndAction: EndAction;
|
|
||||||
setDefaultDuration: (defaultDuration: string) => void;
|
setDefaultDuration: (defaultDuration: string) => void;
|
||||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||||
setWarnTime: (warnTime: string) => void;
|
setWarnTime: (warnTime: string) => void;
|
||||||
setDangerTime: (dangerTime: string) => void;
|
setDangerTime: (dangerTime: string) => void;
|
||||||
setDefaultPublic: (defaultPublic: boolean) => void;
|
setDefaultPublic: (defaultPublic: boolean) => void;
|
||||||
setDefaultTimerType: (defaultTimerType: TimerType) => void;
|
|
||||||
setDefaultEndAction: (defaultEndAction: EndAction) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const editorSettingsDefaults = {
|
export const editorSettingsDefaults = {
|
||||||
@@ -27,8 +21,6 @@ export const editorSettingsDefaults = {
|
|||||||
warnTime: '00:02:00', // 120000 same as backend
|
warnTime: '00:02:00', // 120000 same as backend
|
||||||
dangerTime: '00:01:00', // 60000 same as backend
|
dangerTime: '00:01:00', // 60000 same as backend
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
timerType: TimerType.CountDown,
|
|
||||||
endAction: EndAction.None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
enum EditorSettingsKeys {
|
enum EditorSettingsKeys {
|
||||||
@@ -37,8 +29,6 @@ enum EditorSettingsKeys {
|
|||||||
DefaultWarnTime = 'ontime-default-warn-time',
|
DefaultWarnTime = 'ontime-default-warn-time',
|
||||||
DefaultDangerTime = 'ontime-default-danger-time',
|
DefaultDangerTime = 'ontime-default-danger-time',
|
||||||
DefaultPublic = 'ontime-default-public',
|
DefaultPublic = 'ontime-default-public',
|
||||||
DefaultTimerType = 'ontime-default-timer-type',
|
|
||||||
DefaultEndAction = 'ontime-default-end-action',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||||
@@ -48,14 +38,6 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
|||||||
defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime,
|
defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime,
|
||||||
defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime,
|
defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime,
|
||||||
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
|
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
|
||||||
defaultTimerType: validateTimerType(
|
|
||||||
localStorage.getItem(EditorSettingsKeys.DefaultTimerType),
|
|
||||||
editorSettingsDefaults.timerType,
|
|
||||||
),
|
|
||||||
defaultEndAction: validateEndAction(
|
|
||||||
localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
|
|
||||||
editorSettingsDefaults.endAction,
|
|
||||||
),
|
|
||||||
|
|
||||||
setDefaultDuration: (defaultDuration) =>
|
setDefaultDuration: (defaultDuration) =>
|
||||||
set(() => {
|
set(() => {
|
||||||
@@ -83,15 +65,5 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
|||||||
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
|
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
|
||||||
return { defaultPublic };
|
return { defaultPublic };
|
||||||
}),
|
}),
|
||||||
setDefaultTimerType: (defaultTimerType) =>
|
|
||||||
set(() => {
|
|
||||||
localStorage.setItem(EditorSettingsKeys.DefaultTimerType, String(defaultTimerType));
|
|
||||||
return { defaultTimerType };
|
|
||||||
}),
|
|
||||||
setDefaultEndAction: (defaultEndAction) =>
|
|
||||||
set(() => {
|
|
||||||
localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction));
|
|
||||||
return { defaultEndAction };
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
|||||||
visible: false,
|
visible: false,
|
||||||
blink: false,
|
blink: false,
|
||||||
blackout: false,
|
blackout: false,
|
||||||
secondarySource: null,
|
|
||||||
},
|
},
|
||||||
external: '',
|
external: {
|
||||||
|
text: '',
|
||||||
|
visible: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
selectedEventIndex: null,
|
selectedEventIndex: null,
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export default function ViewSettingsForm() {
|
|||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
maxLength={150}
|
maxLength={150}
|
||||||
width='275px'
|
width='275px'
|
||||||
placeholder='Shown when timer reaches end'
|
placeholder='Message shown when timer reaches end'
|
||||||
{...register('endMessage')}
|
{...register('endMessage')}
|
||||||
/>
|
/>
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
|
|||||||
@@ -7,26 +7,17 @@ import { editorSettingsDefaults, useEditorSettings } from '../../../../common/st
|
|||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
export default function EditorSettingsForm() {
|
export default function EditorSettingsForm() {
|
||||||
const {
|
const eventSettings = useEditorSettings((state) => state);
|
||||||
defaultDuration,
|
|
||||||
linkPrevious,
|
|
||||||
defaultWarnTime,
|
|
||||||
defaultDangerTime,
|
|
||||||
defaultPublic,
|
|
||||||
defaultTimerType,
|
|
||||||
defaultEndAction,
|
|
||||||
setDefaultDuration,
|
|
||||||
setLinkPrevious,
|
|
||||||
setWarnTime,
|
|
||||||
setDangerTime,
|
|
||||||
setDefaultPublic,
|
|
||||||
setDefaultTimerType,
|
|
||||||
setDefaultEndAction,
|
|
||||||
} = useEditorSettings((state) => state);
|
|
||||||
|
|
||||||
const durationInMs = parseUserTime(defaultDuration);
|
const setDefaultDuration = eventSettings.setDefaultDuration;
|
||||||
const warnTimeInMs = parseUserTime(defaultWarnTime);
|
const setLinkPrevious = eventSettings.setLinkPrevious;
|
||||||
const dangerTimeInMs = parseUserTime(defaultDangerTime);
|
const setWarnTime = eventSettings.setWarnTime;
|
||||||
|
const setDangerTime = eventSettings.setDangerTime;
|
||||||
|
const setDefaultPublic = eventSettings.setDefaultPublic;
|
||||||
|
|
||||||
|
const durationInMs = parseUserTime(eventSettings.defaultDuration);
|
||||||
|
const warnTimeInMs = parseUserTime(eventSettings.defaultWarnTime);
|
||||||
|
const dangerTimeInMs = parseUserTime(eventSettings.defaultDangerTime);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
@@ -53,19 +44,13 @@ export default function EditorSettingsForm() {
|
|||||||
<Switch
|
<Switch
|
||||||
variant='ontime'
|
variant='ontime'
|
||||||
size='lg'
|
size='lg'
|
||||||
defaultChecked={linkPrevious}
|
defaultChecked={eventSettings.linkPrevious}
|
||||||
onChange={(event) => setLinkPrevious(event.target.checked)}
|
onChange={(event) => setLinkPrevious(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field title='Timer type' description='Default type of timer for new events' />
|
<Panel.Field title='Timer type' description='Default type of timer for new events' />
|
||||||
<Select
|
<Select variant='ontime' size='sm' width='auto' isDisabled>
|
||||||
variant='ontime'
|
|
||||||
size='sm'
|
|
||||||
width='auto'
|
|
||||||
value={defaultTimerType}
|
|
||||||
onChange={(event) => setDefaultTimerType(event.target.value as TimerType)}
|
|
||||||
>
|
|
||||||
<option value={TimerType.CountDown}>Count down</option>
|
<option value={TimerType.CountDown}>Count down</option>
|
||||||
<option value={TimerType.CountUp}>Count up</option>
|
<option value={TimerType.CountUp}>Count up</option>
|
||||||
<option value={TimerType.TimeToEnd}>Time to end</option>
|
<option value={TimerType.TimeToEnd}>Time to end</option>
|
||||||
@@ -74,13 +59,7 @@ export default function EditorSettingsForm() {
|
|||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field title='End Action' description='Default end action for new events' />
|
<Panel.Field title='End Action' description='Default end action for new events' />
|
||||||
<Select
|
<Select variant='ontime' size='sm' width='auto' isDisabled>
|
||||||
variant='ontime'
|
|
||||||
size='sm'
|
|
||||||
width='auto'
|
|
||||||
value={defaultEndAction}
|
|
||||||
onChange={(event) => setDefaultEndAction(event.target.value as EndAction)}
|
|
||||||
>
|
|
||||||
<option value={EndAction.None}>None</option>
|
<option value={EndAction.None}>None</option>
|
||||||
<option value={EndAction.Stop}>Stop</option>
|
<option value={EndAction.Stop}>Stop</option>
|
||||||
<option value={EndAction.LoadNext}>Load next</option>
|
<option value={EndAction.LoadNext}>Load next</option>
|
||||||
@@ -114,7 +93,7 @@ export default function EditorSettingsForm() {
|
|||||||
<Switch
|
<Switch
|
||||||
variant='ontime'
|
variant='ontime'
|
||||||
size='lg'
|
size='lg'
|
||||||
defaultChecked={defaultPublic}
|
defaultChecked={eventSettings.defaultPublic}
|
||||||
onChange={(event) => setDefaultPublic(event.target.checked)}
|
onChange={(event) => setDefaultPublic(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ChangeEvent, useRef, useState } from 'react';
|
import { ChangeEvent, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { Button, Input } from '@chakra-ui/react';
|
import { Button, Input } from '@chakra-ui/react';
|
||||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||||
|
|
||||||
@@ -14,17 +13,14 @@ import ProjectList from './ProjectList';
|
|||||||
import style from './ProjectPanel.module.scss';
|
import style from './ProjectPanel.module.scss';
|
||||||
|
|
||||||
export default function ManageProjects() {
|
export default function ManageProjects() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState<'import' | null>(null);
|
const [loading, setLoading] = useState<'import' | null>(null);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const isCreatingProject = searchParams.get('create') === 'true';
|
|
||||||
|
|
||||||
const handleToggleCreate = () => {
|
const handleToggleCreate = () => {
|
||||||
searchParams.set('create', isCreatingProject ? 'false' : 'true');
|
setIsCreatingProject((prev) => !prev);
|
||||||
setSearchParams(searchParams);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectFile = () => {
|
const handleSelectFile = () => {
|
||||||
@@ -53,8 +49,7 @@ export default function ManageProjects() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseForm = () => {
|
const handleCloseForm = () => {
|
||||||
searchParams.delete('create');
|
setIsCreatingProject(false);
|
||||||
setSearchParams(searchParams);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Panel.Title>
|
</Panel.Title>
|
||||||
{error && <Panel.Error>{error}</Panel.Error>}
|
{error && <Panel.Error>{error}</Panel.Error>}
|
||||||
<Panel.Section className={style.innerColumn}>
|
<div className={style.innerColumn}>
|
||||||
<label>
|
<label>
|
||||||
Project title
|
Project title
|
||||||
<Input
|
<Input
|
||||||
@@ -145,7 +145,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
|||||||
{...register('backstageUrl')}
|
{...register('backstageUrl')}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</Panel.Section>
|
</div>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||||||
import { PROJECT_DATA } from '../../../../common/api/constants';
|
import { PROJECT_DATA } from '../../../../common/api/constants';
|
||||||
import { getDb, patchData } from '../../../../common/api/db';
|
import { getDb, patchData } from '../../../../common/api/db';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
import { makeProjectPatch } from './project.utils';
|
import { makeProjectPatch } from './project.utils';
|
||||||
@@ -93,7 +92,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Panel.Title>
|
</Panel.Title>
|
||||||
{error && <Panel.Error>{error}</Panel.Error>}
|
{error && <Panel.Error>{error}</Panel.Error>}
|
||||||
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
|
<div className={style.innerColumn}>
|
||||||
<Panel.Description>
|
<Panel.Description>
|
||||||
Select partial data from {`"${fileName}"`} to merge into the current project.
|
Select partial data from {`"${fileName}"`} to merge into the current project.
|
||||||
<br /> This process is irreversible.
|
<br /> This process is irreversible.
|
||||||
@@ -122,7 +121,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
|
|||||||
<Switch variant='ontime' {...register('http')} />
|
<Switch variant='ontime' {...register('http')} />
|
||||||
HTTP Integration
|
HTTP Integration
|
||||||
</label>
|
</label>
|
||||||
</Panel.Section>
|
</div>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,11 +53,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1em;
|
gap: 1em;
|
||||||
}
|
|
||||||
|
|
||||||
.inlineLabels {
|
|
||||||
label {
|
label {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
import { isLocalhost } from '../../../../common/api/constants';
|
|
||||||
import useElectronEvent from '../../../../common/hooks/useElectronEvent';
|
import useElectronEvent from '../../../../common/hooks/useElectronEvent';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
@@ -32,10 +31,9 @@ export default function ShutdownPanel() {
|
|||||||
This will shutdown the Ontime server. <br />
|
This will shutdown the Ontime server. <br />
|
||||||
The runtime state will be lost, but your project is kept for next time.
|
The runtime state will be lost, but your project is kept for next time.
|
||||||
</Panel.Paragraph>
|
</Panel.Paragraph>
|
||||||
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!(isElectron || isLocalhost)}>
|
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!isElectron}>
|
||||||
Shutdown ontime
|
Shutdown ontime
|
||||||
</Button>
|
</Button>
|
||||||
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
|
|
||||||
<AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
|
<AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
|
||||||
<AlertDialogOverlay>
|
<AlertDialogOverlay>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Input } from '@chakra-ui/react';
|
import { IconButton, Input } from '@chakra-ui/react';
|
||||||
import { IoEye } from '@react-icons/all-files/io5/IoEye';
|
import { IoEye } from '@react-icons/all-files/io5/IoEye';
|
||||||
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
||||||
|
|
||||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||||
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||||
|
|
||||||
import style from './InputRow.module.scss';
|
import style from './InputRow.module.scss';
|
||||||
@@ -12,13 +13,15 @@ interface InputRowProps {
|
|||||||
label: string;
|
label: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
text: string;
|
text: string;
|
||||||
visible: boolean;
|
visible?: boolean;
|
||||||
actionHandler: () => void;
|
readonly?: boolean;
|
||||||
|
actionHandler: (action: string, payload: object) => void;
|
||||||
changeHandler: (newValue: string) => void;
|
changeHandler: (newValue: string) => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InputRow(props: InputRowProps) {
|
export default function InputRow(props: InputRowProps) {
|
||||||
const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
|
const { label, placeholder, text, visible, actionHandler, changeHandler, className, readonly } = props;
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const cursorPositionRef = useRef(0);
|
const cursorPositionRef = useRef(0);
|
||||||
@@ -36,20 +39,33 @@ export default function InputRow(props: InputRowProps) {
|
|||||||
changeHandler(event.target.value);
|
changeHandler(event.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const classes = cx([style.inputRow, className]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.inputRow}>
|
<div className={classes}>
|
||||||
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
|
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
|
||||||
<div className={style.inputItems}>
|
<div className={style.inputItems}>
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
size='sm'
|
size='sm'
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={readonly}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
|
{readonly ? (
|
||||||
|
<IconButton
|
||||||
|
size='sm'
|
||||||
|
isDisabled
|
||||||
|
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||||
|
aria-label={`Toggle ${label}`}
|
||||||
|
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<TooltipActionBtn
|
<TooltipActionBtn
|
||||||
clickHandler={actionHandler}
|
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
|
||||||
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
||||||
aria-label={`Toggle ${label}`}
|
aria-label={`Toggle ${label}`}
|
||||||
openDelay={tooltipDelayMid}
|
openDelay={tooltipDelayMid}
|
||||||
@@ -57,6 +73,7 @@ export default function InputRow(props: InputRowProps) {
|
|||||||
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
size='sm'
|
size='sm'
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,23 @@
|
|||||||
|
.messageContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $section-spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonSection {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: $element-spacing;
|
||||||
|
margin-top: -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.singleAction {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $element-spacing;
|
||||||
|
margin-top: $element-inner-spacing;
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
color: $label-gray;
|
color: $label-gray;
|
||||||
@@ -6,89 +26,3 @@
|
|||||||
color: $action-text-color;
|
color: $action-text-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewContainer {
|
|
||||||
display: grid;
|
|
||||||
gap: $element-spacing;
|
|
||||||
grid-template-columns: 2fr 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview {
|
|
||||||
background-color: $ui-black;
|
|
||||||
display: grid;
|
|
||||||
place-content: center;
|
|
||||||
text-align: center;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.corner {
|
|
||||||
transform: rotate(45deg);
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
top: 0.5rem;
|
|
||||||
right: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
color: $ui-white;
|
|
||||||
transition-property: color;
|
|
||||||
transition-duration: $transition-time-action;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $ontime-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.options {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $element-spacing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eventStatus {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
margin: 0.5rem 0.25rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mainContent {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--override-colour, $ui-white);
|
|
||||||
|
|
||||||
&[data-phase='pending'] {
|
|
||||||
color: $ontime-roll;
|
|
||||||
}
|
|
||||||
&[data-phase='overtime'] {
|
|
||||||
color: $playback-negative;
|
|
||||||
}
|
|
||||||
&[data-phase='none'] {
|
|
||||||
opacity: $opacity-disabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondaryContent {
|
|
||||||
border-top: 1px solid $white-7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blackout {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timerIndicators {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusIcon {
|
|
||||||
color: $gray-1000;
|
|
||||||
|
|
||||||
&[data-active='true'] {
|
|
||||||
color: $active-indicator;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
border-top: 1px solid $gray-1000;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,52 +1,64 @@
|
|||||||
import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
|
import { Button } from '@chakra-ui/react';
|
||||||
|
import { IoEye } from '@react-icons/all-files/io5/IoEye';
|
||||||
|
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
||||||
|
import { IoSunny } from '@react-icons/all-files/io5/IoSunny';
|
||||||
|
import { IoSunnyOutline } from '@react-icons/all-files/io5/IoSunnyOutline';
|
||||||
|
|
||||||
|
import { setMessage, useMessageControl } from '../../../common/hooks/useSocket';
|
||||||
|
import { enDash } from '../../../common/utils/styleUtils';
|
||||||
|
|
||||||
import InputRow from './InputRow';
|
import InputRow from './InputRow';
|
||||||
import TimerControlsPreview from './TimerViewControl';
|
|
||||||
|
import style from './MessageControl.module.scss';
|
||||||
|
|
||||||
|
const noop = () => undefined;
|
||||||
|
|
||||||
export default function MessageControl() {
|
export default function MessageControl() {
|
||||||
return (
|
const message = useMessageControl();
|
||||||
<>
|
const blink = message.timer.blink;
|
||||||
<TimerControlsPreview />
|
const blackout = message.timer.blackout;
|
||||||
<TimerMessageInput />
|
|
||||||
<ExternalInput />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TimerMessageInput() {
|
|
||||||
const { text, visible } = useTimerMessageInput();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className={style.messageContainer}>
|
||||||
<InputRow
|
<InputRow
|
||||||
label='Timer Message'
|
label='Timer'
|
||||||
placeholder='Message shown fullscreen in stage timer'
|
placeholder='Message shown in stage timer'
|
||||||
text={text}
|
text={message.timer.text}
|
||||||
visible={visible}
|
visible={message.timer.visible}
|
||||||
changeHandler={(newValue) => setMessage.timerText(newValue)}
|
changeHandler={(newValue) => setMessage.timerText(newValue)}
|
||||||
actionHandler={() => setMessage.timerVisible(!visible)}
|
actionHandler={() => setMessage.timerVisible(!message.timer.visible)}
|
||||||
/>
|
/>
|
||||||
);
|
<div className={style.buttonSection}>
|
||||||
}
|
<Button
|
||||||
|
size='sm'
|
||||||
function ExternalInput() {
|
className={`${blink ? style.blink : ''}`}
|
||||||
const { text, visible } = useExternalMessageInput();
|
variant={blink ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
leftIcon={blink ? <IoSunny size='1rem' /> : <IoSunnyOutline size='1rem' />}
|
||||||
const toggleExternal = () => {
|
onClick={() => setMessage.timerBlink(!blink)}
|
||||||
if (visible) {
|
data-testid='toggle timer blink'
|
||||||
setMessage.timerSecondary(null);
|
>
|
||||||
} else {
|
Blink
|
||||||
setMessage.timerSecondary('external');
|
</Button>
|
||||||
}
|
<Button
|
||||||
};
|
size='sm'
|
||||||
|
className={style.blackoutButton}
|
||||||
return (
|
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
|
<InputRow
|
||||||
label='External Message'
|
label='External Message (read only)'
|
||||||
placeholder='Message shown as secondary text in stage timer'
|
placeholder={enDash}
|
||||||
text={text}
|
readonly
|
||||||
visible={visible}
|
text={message.external.text}
|
||||||
changeHandler={(newValue) => setMessage.externalText(newValue)}
|
visible={message.external.visible}
|
||||||
actionHandler={toggleExternal}
|
changeHandler={noop}
|
||||||
|
actionHandler={noop}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,16 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
|||||||
|
|
||||||
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
|
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
|
||||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
|
||||||
|
|
||||||
import MessageControl from './MessageControl';
|
import MessageControl from './MessageControl';
|
||||||
|
|
||||||
import style from '../../editors/Editor.module.scss';
|
import style from '../../editors/Editor.module.scss';
|
||||||
|
|
||||||
const MessageControlExport = () => {
|
const MessageControlExport = () => {
|
||||||
const classes = cx([style.content, style.contentColumnLayout]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.messages} data-testid='panel-messages-control'>
|
<div className={style.messages} data-testid='panel-messages-control'>
|
||||||
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'messagecontrol')} />
|
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'messagecontrol')} />
|
||||||
<div className={classes}>
|
<div className={style.content}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<MessageControl />
|
<MessageControl />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import { Tooltip } from '@chakra-ui/react';
|
|
||||||
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
|
|
||||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
|
||||||
import { IoFlag } from '@react-icons/all-files/io5/IoFlag';
|
|
||||||
import { IoTime } from '@react-icons/all-files/io5/IoTime';
|
|
||||||
import { TimerPhase, TimerType } from 'ontime-types';
|
|
||||||
|
|
||||||
import { useMessagePreview } from '../../../common/hooks/useSocket';
|
|
||||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
|
||||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
|
||||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
|
||||||
|
|
||||||
import style from './MessageControl.module.scss';
|
|
||||||
|
|
||||||
export default function TimerPreview() {
|
|
||||||
const { blink, blackout, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } =
|
|
||||||
useMessagePreview();
|
|
||||||
const { data } = useViewSettings();
|
|
||||||
|
|
||||||
const contentClasses = cx([style.previewContent, blink && style.blink, blackout && style.blackout]);
|
|
||||||
|
|
||||||
const main = (() => {
|
|
||||||
if (showTimerMessage) return 'Message';
|
|
||||||
if (phase === TimerPhase.Pending) return 'Standby to start';
|
|
||||||
if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message';
|
|
||||||
return 'Timer';
|
|
||||||
})();
|
|
||||||
|
|
||||||
const secondary = (() => {
|
|
||||||
// message is a fullscreen overlay
|
|
||||||
if (showTimerMessage) return null;
|
|
||||||
|
|
||||||
// we need to check aux first since it takes priority
|
|
||||||
if (showAuxTimer) return 'Aux Timer';
|
|
||||||
if (showExternalMessage) return 'External message';
|
|
||||||
return null;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const overrideColour = (() => {
|
|
||||||
// override fallback colours from starter project
|
|
||||||
if (phase === TimerPhase.Warning) return data.warningColor ?? '#FFAB33';
|
|
||||||
if (phase === TimerPhase.Danger) return data.dangerColor ?? '#ED3333';
|
|
||||||
return data.normalColor ?? '#FFFC';
|
|
||||||
})();
|
|
||||||
|
|
||||||
const showColourOverride = main == 'Timer';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.preview}>
|
|
||||||
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'timer')} />
|
|
||||||
<div className={contentClasses}>
|
|
||||||
<div
|
|
||||||
className={style.mainContent}
|
|
||||||
data-phase={phase}
|
|
||||||
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
|
|
||||||
>
|
|
||||||
{main}
|
|
||||||
</div>
|
|
||||||
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
|
|
||||||
</div>
|
|
||||||
<div className={style.eventStatus}>
|
|
||||||
<Tooltip label='Time type: Count down' openDelay={tooltipDelayMid} shouldWrapChildren>
|
|
||||||
<IoArrowDown className={style.statusIcon} data-active={timerType === TimerType.CountDown} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label='Time type: Count up' openDelay={tooltipDelayMid} shouldWrapChildren>
|
|
||||||
<IoArrowUp className={style.statusIcon} data-active={timerType === TimerType.CountUp} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label='Time type: Clock' openDelay={tooltipDelayMid} shouldWrapChildren>
|
|
||||||
<IoTime className={style.statusIcon} data-active={timerType === TimerType.Clock} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label='Time type: Time to end' openDelay={tooltipDelayMid} shouldWrapChildren>
|
|
||||||
<IoFlag className={style.statusIcon} data-active={timerType === TimerType.TimeToEnd} />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { Button } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket';
|
|
||||||
|
|
||||||
import TimerPreview from './TimerPreview';
|
|
||||||
|
|
||||||
import style from './MessageControl.module.scss';
|
|
||||||
|
|
||||||
export default function TimerControlsPreview() {
|
|
||||||
const { blackout, blink, secondarySource } = useTimerViewControl();
|
|
||||||
|
|
||||||
const toggleSecondary = (newValue: 'aux' | 'external' | null) => {
|
|
||||||
if (secondarySource === newValue) {
|
|
||||||
setMessage.timerSecondary(null);
|
|
||||||
} else {
|
|
||||||
setMessage.timerSecondary(newValue);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.previewContainer}>
|
|
||||||
<TimerPreview />
|
|
||||||
|
|
||||||
<div className={style.options}>
|
|
||||||
<Button
|
|
||||||
size='sm'
|
|
||||||
variant={secondarySource === 'aux' ? 'ontime-filled' : 'ontime-subtle'}
|
|
||||||
onClick={() => toggleSecondary('aux')}
|
|
||||||
>
|
|
||||||
Show Aux timer
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size='sm'
|
|
||||||
variant={secondarySource === 'external' ? 'ontime-filled' : 'ontime-subtle'}
|
|
||||||
onClick={() => toggleSecondary('external')}
|
|
||||||
>
|
|
||||||
Show external
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<hr className={style.divider} />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size='sm'
|
|
||||||
variant={blink ? 'ontime-filled' : 'ontime-subtle'}
|
|
||||||
onClick={() => setMessage.timerBlink(!blink)}
|
|
||||||
data-testid='toggle timer blink'
|
|
||||||
>
|
|
||||||
Blink
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size='sm'
|
|
||||||
className={style.blackoutButton}
|
|
||||||
variant={blackout ? 'ontime-filled' : 'ontime-subtle'}
|
|
||||||
onClick={() => setMessage.timerBlackout(!blackout)}
|
|
||||||
data-testid='toggle timer blackout'
|
|
||||||
>
|
|
||||||
Blackout screen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -68,7 +68,7 @@ function AuxTimerInput() {
|
|||||||
|
|
||||||
const handleTimeUpdate = (_field: string, value: string) => {
|
const handleTimeUpdate = (_field: string, value: string) => {
|
||||||
const newTime = parseUserTime(value);
|
const newTime = parseUserTime(value);
|
||||||
setDuration(newTime / 1000); // frontend api is seconds based
|
setDuration(newTime / 1000); //frontend api is seconds based;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -50,15 +50,9 @@ $table-header-font-size: calc(1rem - 3px);
|
|||||||
|
|
||||||
font-size: $table-header-font-size;
|
font-size: $table-header-font-size;
|
||||||
color: $gray-700;
|
color: $gray-700;
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
th {
|
||||||
background-color: $gray-1300;
|
background-color: $gray-1300;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.resizer {
|
|
||||||
width: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,19 +126,30 @@ th {
|
|||||||
|
|
||||||
.resizer {
|
.resizer {
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
opacity: $opacity-disabled;
|
opacity: 0;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 0;
|
width: 3px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
transform: translateX(50%);
|
||||||
background-color: $action-blue;
|
background-color: $action-blue;
|
||||||
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
|
||||||
|
transition-duration: $transition-time-action;
|
||||||
|
transition-property: width, background-color;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
opacity: $opacity-disabled;
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.isResizing {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
width: 6px;
|
||||||
|
background-color: $action-blue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { CSS } from '@dnd-kit/utilities';
|
|||||||
import { Header } from '@tanstack/react-table';
|
import { Header } from '@tanstack/react-table';
|
||||||
import { OntimeRundownEntry } from 'ontime-types';
|
import { OntimeRundownEntry } from 'ontime-types';
|
||||||
|
|
||||||
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
|
||||||
import styles from '../Cuesheet.module.scss';
|
import styles from '../Cuesheet.module.scss';
|
||||||
|
|
||||||
interface SortableCellProps {
|
interface SortableCellProps {
|
||||||
@@ -27,6 +29,8 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
|
|||||||
transition,
|
transition,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resizerClasses = cx([styles.resizer, header.column.getIsResizing() ? styles.isResizing : null]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<th ref={setNodeRef} style={dragStyle} colSpan={colSpan}>
|
<th ref={setNodeRef} style={dragStyle} colSpan={colSpan}>
|
||||||
<div {...attributes} {...listeners}>
|
<div {...attributes} {...listeners}>
|
||||||
@@ -37,7 +41,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
|
|||||||
onMouseDown: header.getResizeHandler(),
|
onMouseDown: header.getResizeHandler(),
|
||||||
onTouchStart: header.getResizeHandler(),
|
onTouchStart: header.getResizeHandler(),
|
||||||
}}
|
}}
|
||||||
className={styles.resizer}
|
className={resizerClasses}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ $panel-gap: 0.5rem;
|
|||||||
.playback,
|
.playback,
|
||||||
.messages {
|
.messages {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: var(--editor--panel__br);
|
border-radius: 8px;
|
||||||
background-color: $bg-container-l2;
|
background-color: $bg-container-l2;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
@@ -62,10 +62,3 @@ $panel-gap: 0.5rem;
|
|||||||
.content {
|
.content {
|
||||||
padding-top: 1.5rem;
|
padding-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contentColumnLayout {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $section-spacing;
|
|
||||||
color: $ui-white;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
@use '../../theme/ontimeColours' as *;
|
@use '../../theme/ontimeColours' as *;
|
||||||
@use '../../theme/ontimeStyles' as *;
|
@use '../../theme/ontimeStyles' as *;
|
||||||
|
|
||||||
// declare editor specific styling constants
|
@mixin absolute-top-right($distance) {
|
||||||
:root {
|
|
||||||
--editor--panel__br: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin corner() {
|
|
||||||
display: none;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.5rem;
|
top: $distance;
|
||||||
right: 0.5rem;
|
right: $distance;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
transition-property: color;
|
transition-property: color;
|
||||||
@@ -23,11 +15,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin corner() {
|
||||||
|
display: none;
|
||||||
|
@include absolute-top-right(0.5rem);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
@mixin panel() {
|
@mixin panel() {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: var(--editor--panel__br);
|
border-radius: 8px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: $bg-container-l2;
|
background-color: $bg-container-l2;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import EventBlockProgressBar from './composite/EventBlockProgressBar';
|
|||||||
|
|
||||||
import style from './EventBlock.module.scss';
|
import style from './EventBlock.module.scss';
|
||||||
|
|
||||||
|
const tooltipProps = {
|
||||||
|
openDelay: tooltipDelayMid,
|
||||||
|
};
|
||||||
|
|
||||||
interface EventBlockInnerProps {
|
interface EventBlockInnerProps {
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
timeEnd: number;
|
timeEnd: number;
|
||||||
@@ -94,7 +98,11 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className={style.titleSection}>
|
<div className={style.titleSection}>
|
||||||
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
|
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
|
||||||
{isNext && <span className={style.nextTag}>UP NEXT</span>}
|
{isNext && (
|
||||||
|
<Tooltip label='Next event' {...tooltipProps}>
|
||||||
|
<span className={style.nextTag}>UP NEXT</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<EventBlockPlayback
|
<EventBlockPlayback
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
@@ -110,17 +118,17 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
|||||||
{loaded && <EventBlockProgressBar />}
|
{loaded && <EventBlockProgressBar />}
|
||||||
</div>
|
</div>
|
||||||
<div className={style.eventStatus} tabIndex={-1}>
|
<div className={style.eventStatus} tabIndex={-1}>
|
||||||
<Tooltip label={`Time type: ${timerType}`} openDelay={tooltipDelayMid}>
|
<Tooltip label={`Time type: ${timerType}`} {...tooltipProps}>
|
||||||
<span>
|
<span>
|
||||||
<TimerIcon type={timerType} className={style.statusIcon} />
|
<TimerIcon type={timerType} className={style.statusIcon} />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={`End action: ${endAction}`} openDelay={tooltipDelayMid}>
|
<Tooltip label={`End action: ${endAction}`} {...tooltipProps}>
|
||||||
<span>
|
<span>
|
||||||
<EndActionIcon action={endAction} className={style.statusIcon} />
|
<EndActionIcon action={endAction} className={style.statusIcon} />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} openDelay={tooltipDelayMid}>
|
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} {...tooltipProps}>
|
||||||
<span>
|
<span>
|
||||||
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
|
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { ComponentType, useMemo } from 'react';
|
|||||||
import { ViewExtendedTimer } from 'common/models/TimeManager.type';
|
import { ViewExtendedTimer } from 'common/models/TimeManager.type';
|
||||||
import {
|
import {
|
||||||
CustomFields,
|
CustomFields,
|
||||||
MessageState,
|
Message,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
ProjectData,
|
ProjectData,
|
||||||
Runtime,
|
Runtime,
|
||||||
Settings,
|
Settings,
|
||||||
SimpleTimerState,
|
|
||||||
SupportedEvent,
|
SupportedEvent,
|
||||||
|
TimerMessage,
|
||||||
ViewSettings,
|
ViewSettings,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { useStore } from 'zustand';
|
import { useStore } from 'zustand';
|
||||||
@@ -23,17 +23,17 @@ import { runtimeStore } from '../../common/stores/runtime';
|
|||||||
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
||||||
|
|
||||||
type WithDataProps = {
|
type WithDataProps = {
|
||||||
auxTimer: SimpleTimerState;
|
|
||||||
backstageEvents: OntimeEvent[];
|
backstageEvents: OntimeEvent[];
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
eventNext: OntimeEvent | null;
|
eventNext: OntimeEvent | null;
|
||||||
eventNow: OntimeEvent | null;
|
eventNow: OntimeEvent | null;
|
||||||
events: OntimeEvent[];
|
events: OntimeEvent[];
|
||||||
|
external: Message;
|
||||||
general: ProjectData;
|
general: ProjectData;
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
message: MessageState;
|
|
||||||
nextId: string | null;
|
nextId: string | null;
|
||||||
onAir: boolean;
|
onAir: boolean;
|
||||||
|
pres: TimerMessage;
|
||||||
publicEventNext: OntimeEvent | null;
|
publicEventNext: OntimeEvent | null;
|
||||||
publicEventNow: OntimeEvent | null;
|
publicEventNow: OntimeEvent | null;
|
||||||
publicSelectedId: string | null;
|
publicSelectedId: string | null;
|
||||||
@@ -68,7 +68,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
|||||||
}, [rundownData]);
|
}, [rundownData]);
|
||||||
|
|
||||||
// websocket data
|
// websocket data
|
||||||
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime, auxtimer1 } =
|
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime } =
|
||||||
useStore(runtimeStore);
|
useStore(runtimeStore);
|
||||||
const publicSelectedId = publicEventNow?.id ?? null;
|
const publicSelectedId = publicEventNow?.id ?? null;
|
||||||
const selectedId = eventNow?.id ?? null;
|
const selectedId = eventNow?.id ?? null;
|
||||||
@@ -96,17 +96,17 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
|||||||
<ViewNavigationMenu />
|
<ViewNavigationMenu />
|
||||||
<Component
|
<Component
|
||||||
{...props}
|
{...props}
|
||||||
auxTimer={auxtimer1}
|
|
||||||
backstageEvents={rundownData}
|
backstageEvents={rundownData}
|
||||||
customFields={customFields}
|
customFields={customFields}
|
||||||
eventNext={eventNext}
|
eventNext={eventNext}
|
||||||
eventNow={eventNow}
|
eventNow={eventNow}
|
||||||
events={publicEvents}
|
events={publicEvents}
|
||||||
|
external={message.external}
|
||||||
general={project}
|
general={project}
|
||||||
isMirrored={isMirrored}
|
isMirrored={isMirrored}
|
||||||
message={message}
|
|
||||||
nextId={nextId}
|
nextId={nextId}
|
||||||
onAir={onAir}
|
onAir={onAir}
|
||||||
|
pres={message.timer}
|
||||||
publicEventNext={publicEventNext}
|
publicEventNext={publicEventNext}
|
||||||
publicEventNow={publicEventNow}
|
publicEventNow={publicEventNow}
|
||||||
publicSelectedId={publicSelectedId}
|
publicSelectedId={publicSelectedId}
|
||||||
|
|||||||
@@ -120,9 +120,6 @@ $orange-active: #f60;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.next-title {
|
.next-title {
|
||||||
height: 12.5vh;
|
|
||||||
width: 70%;
|
|
||||||
|
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--studio-active-label, $cyan-active);
|
color: var(--studio-active-label, $cyan-active);
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { Playback } from 'ontime-types';
|
|||||||
import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
|
import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/constants';
|
import { overrideStylesURL } from '../../../common/api/constants';
|
||||||
import { FitText } from '../../../common/components/fit-text/FitText';
|
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
|
import useFitText from '../../../common/hooks/useFitText';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
@@ -32,17 +32,15 @@ interface StudioClockProps {
|
|||||||
export default function StudioClock(props: StudioClockProps) {
|
export default function StudioClock(props: StudioClockProps) {
|
||||||
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, viewSettings, settings } = props;
|
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, viewSettings, settings } = props;
|
||||||
|
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
// TODO: can we prevent the Flash of Unstyled Content on the 7segment fonts?
|
||||||
|
// deferring rendering seems to affect styling (font and useFitText)
|
||||||
|
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
|
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ minFontSize: 150, maxFontSize: 500 });
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
useWindowTitle('Studio Clock');
|
useWindowTitle('Studio Clock');
|
||||||
|
|
||||||
// defer rendering until we load stylesheets
|
|
||||||
if (!shouldRender) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeIndicators = [...Array(12).keys()];
|
const activeIndicators = [...Array(12).keys()];
|
||||||
const secondsIndicators = [...Array(60).keys()];
|
const secondsIndicators = [...Array(60).keys()];
|
||||||
|
|
||||||
@@ -79,7 +77,13 @@ export default function StudioClock(props: StudioClockProps) {
|
|||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
{hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>}
|
{hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>}
|
||||||
<div className={`studio-timer ${!hideSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
|
<div className={`studio-timer ${!hideSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
|
||||||
<FitText className='next-title'>{eventNext?.title}</FitText>
|
<div
|
||||||
|
ref={titleRef}
|
||||||
|
className='next-title'
|
||||||
|
style={{ fontSize: titleFontSize, height: '12.5vh', width: '100%', maxWidth: '80%' }}
|
||||||
|
>
|
||||||
|
{eventNext?.title}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
next-countdown ${isNegative ? ' next-countdown--overtime' : ''} ${isPaused ? ' next-countdown--paused' : ''}
|
next-countdown ${isNegative ? ' next-countdown--overtime' : ''} ${isPaused ? ' next-countdown--paused' : ''}
|
||||||
|
|||||||
@@ -1,49 +1,50 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { useViewportSize } from '@mantine/hooks';
|
import { useViewportSize } from '@mantine/hooks';
|
||||||
import { isOntimeEvent, isPlayableEvent, MaybeNumber, OntimeRundown } from 'ontime-types';
|
import { isOntimeEvent, MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||||
import { checkIsNextDay, dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
|
import { checkIsNextDay, dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { useTimelineOverview } from '../../../common/hooks/useSocket';
|
||||||
|
|
||||||
import TimelineMarkers from './timeline-markers/TimelineMarkers';
|
import TimelineMarkers from './timeline-markers/TimelineMarkers';
|
||||||
import TimelineProgressBar from './timeline-progress-bar/TimelineProgressBar';
|
import ProgressBar from './timeline-progress-bar/TimelineProgressBar';
|
||||||
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
|
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
|
||||||
import { ProgressStatus, TimelineEntry } from './TimelineEntry';
|
import { ProgressStatus, TimelineEntry } from './TimelineEntry';
|
||||||
|
|
||||||
import style from './Timeline.module.scss';
|
import style from './Timeline.module.scss';
|
||||||
|
|
||||||
interface TimelineProps {
|
interface TimelineProps {
|
||||||
firstStart: number;
|
|
||||||
rundown: OntimeRundown;
|
|
||||||
selectedEventId: string | null;
|
selectedEventId: string | null;
|
||||||
totalDuration: number;
|
rundown: OntimeEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(Timeline);
|
export default memo(Timeline);
|
||||||
|
|
||||||
function Timeline(props: TimelineProps) {
|
function Timeline(props: TimelineProps) {
|
||||||
const { firstStart, rundown, selectedEventId, totalDuration } = props;
|
const { selectedEventId, rundown } = props;
|
||||||
const { width: screenWidth } = useViewportSize();
|
const { width: screenWidth } = useViewportSize();
|
||||||
|
const { plannedStart, plannedEnd } = useTimelineOverview();
|
||||||
|
|
||||||
if (totalDuration === 0) {
|
if (plannedStart === null || plannedEnd === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { lastEvent } = getLastEvent(rundown);
|
const { lastEvent } = getLastEvent(rundown);
|
||||||
const startHour = getStartHour(firstStart);
|
const startHour = getStartHour(plannedStart);
|
||||||
const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0));
|
const endHour = getEndHour(plannedEnd + (lastEvent?.delay ?? 0));
|
||||||
|
|
||||||
|
let hasTimelinePassedMidnight = false;
|
||||||
let previousEventStartTime: MaybeNumber = null;
|
let previousEventStartTime: MaybeNumber = null;
|
||||||
// we use selectedEventId as a signifier on whether the timeline is live
|
// we use selectedEventId as a signifier on whether the timeline is live
|
||||||
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
|
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
|
||||||
let elapsedDays = 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.timeline}>
|
<div className={style.timeline}>
|
||||||
<TimelineMarkers startHour={startHour} endHour={endHour} />
|
<TimelineMarkers startHour={startHour} endHour={endHour} />
|
||||||
<TimelineProgressBar startHour={startHour} endHour={endHour} />
|
<ProgressBar startHour={startHour} endHour={endHour} />
|
||||||
<div className={style.timelineEvents}>
|
<div className={style.timelineEvents}>
|
||||||
{rundown.map((event) => {
|
{rundown.map((event) => {
|
||||||
// for now we dont render delays and blocks
|
// for now we dont render delays and blocks
|
||||||
if (!isOntimeEvent(event) || !isPlayableEvent(event)) {
|
if (!isOntimeEvent(event)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,14 +56,17 @@ function Timeline(props: TimelineProps) {
|
|||||||
eventStatus = 'live';
|
eventStatus = 'live';
|
||||||
}
|
}
|
||||||
|
|
||||||
// we only need to check for next day if we have a previous event
|
if (!hasTimelinePassedMidnight) {
|
||||||
if (
|
// we need to offset the start to account for midnight
|
||||||
previousEventStartTime !== null &&
|
hasTimelinePassedMidnight = previousEventStartTime !== null && event.timeStart < previousEventStartTime;
|
||||||
checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
|
|
||||||
) {
|
|
||||||
elapsedDays++;
|
|
||||||
}
|
}
|
||||||
const normalisedStart = event.timeStart + elapsedDays * dayInMs;
|
// 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(
|
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
|
||||||
startHour * MILLIS_PER_HOUR,
|
startHour * MILLIS_PER_HOUR,
|
||||||
@@ -72,9 +76,6 @@ function Timeline(props: TimelineProps) {
|
|||||||
screenWidth,
|
screenWidth,
|
||||||
);
|
);
|
||||||
|
|
||||||
// prepare values for next iteration
|
|
||||||
previousEventStartTime = normalisedStart;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TimelineEntry
|
<TimelineEntry
|
||||||
key={event.id}
|
key={event.id}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
|||||||
import Section from './timeline-section/TimelineSection';
|
import Section from './timeline-section/TimelineSection';
|
||||||
import Timeline from './Timeline';
|
import Timeline from './Timeline';
|
||||||
import { getTimelineOptions } from './timeline.options';
|
import { getTimelineOptions } from './timeline.options';
|
||||||
import { getFormattedTimeToStart, getUpcomingEvents, useScopedRundown } from './timeline.utils';
|
import { getFormattedTimeToStart, getScopedRundown, getUpcomingEvents } from './timeline.utils';
|
||||||
|
|
||||||
import './TimelinePage.scss';
|
import './TimelinePage.scss';
|
||||||
|
|
||||||
@@ -34,11 +34,15 @@ interface TimelinePageProps {
|
|||||||
export default function TimelinePage(props: TimelinePageProps) {
|
export default function TimelinePage(props: TimelinePageProps) {
|
||||||
const { backstageEvents, general, selectedId, settings, time, viewSettings } = props;
|
const { backstageEvents, general, selectedId, settings, time, viewSettings } = props;
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
// holds copy of the rundown with only relevant events
|
|
||||||
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(backstageEvents, selectedId);
|
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const clock = formatTime(time.clock);
|
const clock = formatTime(time.clock);
|
||||||
|
|
||||||
|
// holds copy of the rundown with only relevant events
|
||||||
|
const scopedRundown = useMemo(() => {
|
||||||
|
return getScopedRundown(backstageEvents, selectedId);
|
||||||
|
}, [backstageEvents, selectedId]);
|
||||||
|
|
||||||
const { now, next, followedBy } = useMemo(() => {
|
const { now, next, followedBy } = useMemo(() => {
|
||||||
return getUpcomingEvents(scopedRundown, selectedId);
|
return getUpcomingEvents(scopedRundown, selectedId);
|
||||||
}, [scopedRundown, selectedId]);
|
}, [scopedRundown, selectedId]);
|
||||||
@@ -82,12 +86,7 @@ export default function TimelinePage(props: TimelinePageProps) {
|
|||||||
category='next'
|
category='next'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Timeline
|
<Timeline selectedEventId={selectedId} rundown={scopedRundown} />
|
||||||
firstStart={firstStart}
|
|
||||||
rundown={scopedRundown}
|
|
||||||
selectedEventId={selectedId}
|
|
||||||
totalDuration={totalDuration}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { useMemo } from 'react';
|
import { isOntimeEvent, MaybeString, OntimeEvent } from 'ontime-types';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { isOntimeEvent, isPlayableEvent, MaybeString, OntimeEvent, OntimeRundown, PlayableEvent } from 'ontime-types';
|
|
||||||
import {
|
import {
|
||||||
dayInMs,
|
dayInMs,
|
||||||
getEventWithId,
|
getEventWithId,
|
||||||
getFirstEvent,
|
getFirstEvent,
|
||||||
getNextEvent,
|
getNextEvent,
|
||||||
getTimeFromPrevious,
|
|
||||||
isNewLatest,
|
|
||||||
MILLIS_PER_HOUR,
|
MILLIS_PER_HOUR,
|
||||||
millisToString,
|
millisToString,
|
||||||
removeSeconds,
|
removeSeconds,
|
||||||
@@ -91,83 +87,29 @@ export function getStatusLabel(timeToStart: number, status: ProgressStatus): str
|
|||||||
return formatDuration(timeToStart);
|
return formatDuration(timeToStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScopedRundownData {
|
export function getScopedRundown(rundown: OntimeEvent[], selectedEventId: MaybeString): OntimeEvent[] {
|
||||||
scopedRundown: PlayableEvent[];
|
|
||||||
firstStart: number;
|
|
||||||
totalDuration: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeString): ScopedRundownData {
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
const data = useMemo(() => {
|
|
||||||
if (rundown.length === 0) {
|
if (rundown.length === 0) {
|
||||||
return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const hideBackstage = isStringBoolean(searchParams.get('hideBackstage'));
|
const params = new URL(document.location.href).searchParams;
|
||||||
const hidePast = isStringBoolean(searchParams.get('hidePast'));
|
const hideBackstage = isStringBoolean(params.get('hideBackstage'));
|
||||||
|
const hidePast = isStringBoolean(params.get('hidePast'));
|
||||||
|
|
||||||
const scopedRundown: PlayableEvent[] = [];
|
let scopedRundown = [...rundown];
|
||||||
let selectedIndex = selectedEventId ? Infinity : -1;
|
|
||||||
let firstStart = null;
|
|
||||||
let totalDuration = 0;
|
|
||||||
let lastEntry: PlayableEvent | null = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < rundown.length; i++) {
|
if (hidePast && selectedEventId) {
|
||||||
const currentEntry = rundown[i];
|
const currentIndex = rundown.findIndex((event) => event.id === selectedEventId);
|
||||||
// we only deal with playableEvents
|
if (currentIndex >= 0) {
|
||||||
if (isOntimeEvent(currentEntry) && isPlayableEvent(currentEntry)) {
|
scopedRundown = scopedRundown.slice(currentIndex);
|
||||||
if (currentEntry.id === selectedEventId) {
|
|
||||||
selectedIndex = i;
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybe filter past
|
|
||||||
if (hidePast && i < selectedIndex) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybe filter backstage
|
|
||||||
if (!currentEntry.isPublic && hideBackstage) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// add to scopedRundown
|
|
||||||
scopedRundown.push(currentEntry);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive timers
|
|
||||||
* This logic is partially from rundownCache.generate
|
|
||||||
* With the addition of deriving the current day offset
|
|
||||||
*/
|
|
||||||
if (firstStart === null) {
|
|
||||||
firstStart = currentEntry.timeStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeFromPrevious: number = getTimeFromPrevious(
|
|
||||||
currentEntry.timeStart,
|
|
||||||
lastEntry?.timeStart,
|
|
||||||
lastEntry?.timeEnd,
|
|
||||||
lastEntry?.duration,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (timeFromPrevious === 0) {
|
|
||||||
totalDuration += currentEntry.duration;
|
|
||||||
} else if (timeFromPrevious > 0) {
|
|
||||||
totalDuration += timeFromPrevious + currentEntry.duration;
|
|
||||||
} else if (timeFromPrevious < 0) {
|
|
||||||
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
|
|
||||||
}
|
|
||||||
if (isNewLatest(currentEntry.timeStart, currentEntry.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
|
|
||||||
lastEntry = currentEntry;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hideBackstage) {
|
||||||
|
scopedRundown = scopedRundown.filter((event) => event.isPublic);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { scopedRundown, firstStart: firstStart ?? 0, totalDuration };
|
return scopedRundown;
|
||||||
}, [rundown, searchParams, selectedEventId]);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpcomingEvents = {
|
type UpcomingEvents = {
|
||||||
@@ -179,17 +121,18 @@ type UpcomingEvents = {
|
|||||||
/**
|
/**
|
||||||
* Returns upcoming events from current: now, next and followedBy
|
* Returns upcoming events from current: now, next and followedBy
|
||||||
*/
|
*/
|
||||||
export function getUpcomingEvents(events: OntimeRundown, selectedId: MaybeString): UpcomingEvents {
|
export function getUpcomingEvents(events: OntimeEvent[], selectedId: MaybeString): UpcomingEvents {
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return { now: null, next: null, followedBy: null };
|
return { now: null, next: null, followedBy: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = selectedId ? getEventWithId(events, selectedId) : null;
|
const now = selectedId ? getEventWithId(events, selectedId) : getFirstEvent(events)?.firstEvent;
|
||||||
|
|
||||||
if (!isOntimeEvent(now)) {
|
if (!isOntimeEvent(now)) {
|
||||||
now = null;
|
return { now: null, next: null, followedBy: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = now ? getNextEvent(events, now.id)?.nextEvent : getFirstEvent(events).firstEvent;
|
const next = getNextEvent(events, now.id)?.nextEvent;
|
||||||
const followedBy = next ? getNextEvent(events, next.id)?.nextEvent : null;
|
const followedBy = next ? getNextEvent(events, next.id)?.nextEvent : null;
|
||||||
|
|
||||||
// Return the titles, handling nulls appropriately
|
// Return the titles, handling nulls appropriately
|
||||||
|
|||||||
@@ -129,22 +129,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
.external {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
margin-top: 0.25em;
|
|
||||||
padding-block: 0.25em;
|
|
||||||
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--external-color-override, $external-color);
|
color: var(--external-color-override, $external-color);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
line-height: 0.9em;
|
line-height: 0.9em;
|
||||||
|
padding-bottom: 0.2em;
|
||||||
transition-property: opacity, height;
|
transition-property: opacity, height;
|
||||||
transition-duration: $viewer-transition-time;
|
transition-duration: $viewer-transition-time;
|
||||||
border-top: 1px solid color-mix(in srgb, var(--external-color-override, $external-color) 10%, transparent);
|
border-top: 1px solid rgba(white, 0.1);
|
||||||
|
|
||||||
&--hidden {
|
&--hidden {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -169,28 +167,36 @@
|
|||||||
|
|
||||||
.message-overlay {
|
.message-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
padding: 2vw;
|
background-color: $viewer-overlay-bg-color;
|
||||||
background: var(--background-color-override, $viewer-background-color);
|
z-index: -1;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity $viewer-transition-time;
|
transition: $viewer-transition-time;
|
||||||
z-index: 2;
|
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
transition: $viewer-transition-time;
|
||||||
|
transition-property: opacity;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
display: grid;
|
width: inherit;
|
||||||
place-content: center;
|
padding: 2vw;
|
||||||
height: 100%;
|
position: absolute;
|
||||||
width: 100%;
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
color: var(--color-override, $viewer-color);
|
color: $viewer-color;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
-ms-transform: translate(-50%, -50%);
|
||||||
|
font-size: 15vw;
|
||||||
|
line-height: 30vh;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,17 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
CustomFields,
|
CustomFields,
|
||||||
MessageState,
|
Message,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
Playback,
|
Playback,
|
||||||
Settings,
|
Settings,
|
||||||
SimpleTimerState,
|
TimerMessage,
|
||||||
TimerPhase,
|
TimerPhase,
|
||||||
TimerType,
|
TimerType,
|
||||||
ViewSettings,
|
ViewSettings,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/constants';
|
import { overrideStylesURL } from '../../../common/api/constants';
|
||||||
import { FitText } from '../../../common/components/fit-text/FitText';
|
|
||||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
@@ -48,19 +47,19 @@ const titleVariants = {
|
|||||||
export const MotionTitleCard = motion(TitleCard);
|
export const MotionTitleCard = motion(TitleCard);
|
||||||
|
|
||||||
interface TimerProps {
|
interface TimerProps {
|
||||||
auxTimer: SimpleTimerState;
|
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
eventNext: OntimeEvent | null;
|
eventNext: OntimeEvent | null;
|
||||||
eventNow: OntimeEvent | null;
|
eventNow: OntimeEvent | null;
|
||||||
|
external: Message;
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
message: MessageState;
|
pres: TimerMessage;
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
time: ViewExtendedTimer;
|
time: ViewExtendedTimer;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Timer(props: TimerProps) {
|
export default function Timer(props: TimerProps) {
|
||||||
const { auxTimer, customFields, eventNow, eventNext, isMirrored, message, settings, time, viewSettings } = props;
|
const { customFields, isMirrored, pres, eventNow, eventNext, time, viewSettings, external, settings } = props;
|
||||||
|
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
@@ -114,7 +113,7 @@ export default function Timer(props: TimerProps) {
|
|||||||
const mainFieldNow = (main ? getPropertyValue(eventNow, main) : eventNow?.title) ?? '';
|
const mainFieldNow = (main ? getPropertyValue(eventNow, main) : eventNow?.title) ?? '';
|
||||||
const mainFieldNext = (main ? getPropertyValue(eventNext, main) : eventNext?.title) ?? '';
|
const mainFieldNext = (main ? getPropertyValue(eventNext, main) : eventNext?.title) ?? '';
|
||||||
|
|
||||||
const showOverlay = message.timer.text !== '' && message.timer.visible;
|
const showOverlay = pres.text !== '' && pres.visible;
|
||||||
const isPlaying = time.playback !== Playback.Pause;
|
const isPlaying = time.playback !== Playback.Pause;
|
||||||
|
|
||||||
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
|
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
|
||||||
@@ -128,20 +127,10 @@ export default function Timer(props: TimerProps) {
|
|||||||
const showFinished = finished && (shouldShowModifiers || showEndMessage);
|
const showFinished = finished && (shouldShowModifiers || showEndMessage);
|
||||||
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
|
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
|
||||||
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
|
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
|
||||||
|
const showBlinking = pres.blink;
|
||||||
|
const showBlackout = pres.blackout;
|
||||||
const showClock = time.timerType !== TimerType.Clock;
|
const showClock = time.timerType !== TimerType.Clock;
|
||||||
|
const showExternal = external.visible && external.text;
|
||||||
const secondaryContent = ((): string | undefined => {
|
|
||||||
if (message.timer.secondarySource === 'aux') {
|
|
||||||
return getFormattedTimer(auxTimer.current, TimerType.CountDown, getLocalizedString('common.minutes'), {
|
|
||||||
removeSeconds: userOptions.hideTimerSeconds,
|
|
||||||
removeLeadingZero: userOptions.removeLeadingZeros,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (message.timer.secondarySource === 'external' && message.external) {
|
|
||||||
return message.external;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
})();
|
|
||||||
|
|
||||||
let timerColor = viewSettings.normalColor;
|
let timerColor = viewSettings.normalColor;
|
||||||
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
|
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
|
||||||
@@ -156,14 +145,13 @@ export default function Timer(props: TimerProps) {
|
|||||||
const stageTimerCharacters = display.replace('/:/g', '').length;
|
const stageTimerCharacters = display.replace('/:/g', '').length;
|
||||||
|
|
||||||
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`;
|
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`;
|
||||||
|
|
||||||
let timerFontSize = 89 / (stageTimerCharacters - 1);
|
let timerFontSize = 89 / (stageTimerCharacters - 1);
|
||||||
// we need to shrink the timer if the external is going to be there
|
// we need to shrink the timer if the external is going to be there
|
||||||
if (secondaryContent) {
|
if (showExternal) {
|
||||||
timerFontSize *= 0.8;
|
timerFontSize *= 0.8;
|
||||||
}
|
}
|
||||||
const externalFontSize = timerFontSize * 0.4;
|
const externalFontSize = timerFontSize * 0.4;
|
||||||
const timerContainerClasses = `timer-container ${message.timer.blink ? (showOverlay ? '' : 'blink') : ''}`;
|
const timerContainerClasses = `timer-container ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`;
|
||||||
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
|
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
|
||||||
|
|
||||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||||
@@ -172,12 +160,10 @@ export default function Timer(props: TimerProps) {
|
|||||||
return (
|
return (
|
||||||
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
|
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
|
||||||
<ViewParamsEditor viewOptions={timerOptions} />
|
<ViewParamsEditor viewOptions={timerOptions} />
|
||||||
<div className={message.timer.blackout ? 'blackout blackout--active' : 'blackout'} />
|
<div className={showBlackout ? 'blackout blackout--active' : 'blackout'} />
|
||||||
{!userOptions.hideMessage && (
|
{!userOptions.hideMessage && (
|
||||||
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
||||||
<FitText mode='multi' min={32} max={256} className={`message ${message.timer.blink ? 'blink' : ''}`}>
|
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
|
||||||
{message.timer.text}
|
|
||||||
</FitText>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -203,10 +189,10 @@ export default function Timer(props: TimerProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`secondary${secondaryContent ? '' : ' secondary--hidden'}`}
|
className={`external${showExternal ? '' : ' external--hidden'}`}
|
||||||
style={{ fontSize: `${externalFontSize}vw` }}
|
style={{ fontSize: `${externalFontSize}vw` }}
|
||||||
>
|
>
|
||||||
{secondaryContent}
|
{external.text}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ $viewer-background-color: $ui-black; // --background-color-override
|
|||||||
$viewer-color: rgba(white, 80%); // --color-override
|
$viewer-color: rgba(white, 80%); // --color-override
|
||||||
$viewer-secondary-color: rgba(white, 45%); // --secondary-color-override
|
$viewer-secondary-color: rgba(white, 45%); // --secondary-color-override
|
||||||
$viewer-card-bg-color: rgba(white, 7%); // --card-background-color-override
|
$viewer-card-bg-color: rgba(white, 7%); // --card-background-color-override
|
||||||
|
$viewer-overlay-bg-color: rgba(black, 90%);
|
||||||
$element-border-radius: 8px;
|
$element-border-radius: 8px;
|
||||||
|
|
||||||
// Properties related to timer
|
// Properties related to timer
|
||||||
@@ -26,7 +27,7 @@ $timer-color: rgba(white, 80%); // --timer-color-override
|
|||||||
$timer-finished-color: $playback-negative;
|
$timer-finished-color: $playback-negative;
|
||||||
$timer-bold-font-family: 'Arial Black', sans-serif; // --card-background-color-override
|
$timer-bold-font-family: 'Arial Black', sans-serif; // --card-background-color-override
|
||||||
|
|
||||||
$external-color: rgba(white, 85%); // --external-color-override
|
$external-color: rgba(white, 70%); // --external-color-override
|
||||||
|
|
||||||
// properties of other timers (clock and countdown)
|
// properties of other timers (clock and countdown)
|
||||||
$timer-label-size: clamp(16px, 1.5vw, 24px);
|
$timer-label-size: clamp(16px, 1.5vw, 24px);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
export const navigatorConstants = [
|
export const navigatorConstants = [
|
||||||
{ url: 'timer', label: 'Timer' },
|
{ url: '/timer', label: 'Timer' },
|
||||||
{ url: 'minimal', label: 'Minimal Timer' },
|
{ url: '/clock', label: 'Clock' },
|
||||||
{ url: 'clock', label: 'Wall Clock' },
|
{ url: '/minimal', label: 'Minimal Timer' },
|
||||||
{ url: 'backstage', label: 'Backstage' },
|
{ url: '/backstage', label: 'Backstage' },
|
||||||
{ url: 'timeline', label: 'Timeline (beta)' },
|
{ url: '/timeline', label: 'Timeline (beta)' },
|
||||||
{ url: 'public', label: 'Public' },
|
{ url: '/public', label: 'Public' },
|
||||||
{ url: 'lower', label: 'Lower Thirds' },
|
{ url: '/lower', label: 'Lower Thirds' },
|
||||||
{ url: 'studio', label: 'Studio Clock' },
|
{ url: '/studio', label: 'Studio Clock' },
|
||||||
{ url: 'countdown', label: 'Countdown' },
|
{ url: '/countdown', label: 'Countdown' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// default time format to use for users in 12 hour clocks
|
// default time format to use for users in 12 hour clocks
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 567 B After Width: | Height: | Size: 567 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
@@ -7,7 +7,7 @@ module.exports = {
|
|||||||
production: (port = 4001) => `http://localhost:${port}`,
|
production: (port = 4001) => `http://localhost:${port}`,
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
pathToEntrypoint: '../../extraResources/server/index.cjs',
|
pathToEntrypoint: '../extraResources/server/index.cjs',
|
||||||
},
|
},
|
||||||
assets: {
|
assets: {
|
||||||
pathToAssets: './assets/',
|
pathToAssets: './assets/',
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
const { app, BrowserWindow, Menu, globalShortcut, Tray, dialog, ipcMain, shell, Notification } = require('electron');
|
const { app, BrowserWindow, Menu, globalShortcut, Tray, dialog, ipcMain, shell, Notification } = require('electron');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const electronConfig = require('./electron.config');
|
||||||
|
const { version } = require('./package.json');
|
||||||
|
const { getApplicationMenu } = require('./src/menu/applicationMenu.js');
|
||||||
|
|
||||||
const { getApplicationMenu } = require('./menu/applicationMenu.js');
|
const env = process.env.NODE_ENV || 'production';
|
||||||
const { getTrayMenu } = require('./menu/trayMenu.js');
|
const isProduction = env === 'production';
|
||||||
|
const isMac = process.platform === 'darwin';
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
|
||||||
const electronConfig = require('./electron.config.js');
|
// path to server
|
||||||
const {
|
const nodePath = isProduction
|
||||||
env,
|
? path.join(__dirname, electronConfig.server.pathToEntrypoint)
|
||||||
isProduction,
|
: path.join(__dirname, '../server/dist/index.cjs');
|
||||||
isWindows,
|
|
||||||
nodePath,
|
|
||||||
getClientUrl,
|
|
||||||
trayIcon,
|
|
||||||
appIcon,
|
|
||||||
getServerUrl,
|
|
||||||
} = require('./external.js');
|
|
||||||
|
|
||||||
if (!isProduction) {
|
if (!isProduction) {
|
||||||
console.log(`Electron running in ${env} environment`);
|
console.log(`Electron running in ${env} environment`);
|
||||||
@@ -22,13 +20,10 @@ if (!isProduction) {
|
|||||||
process.traceProcessWarnings = true;
|
process.traceProcessWarnings = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flag holds server loading state */
|
// path to icons
|
||||||
let loaded = 'Ontime running';
|
const trayIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'background.png');
|
||||||
|
const appIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'logo.png');
|
||||||
/**
|
let loaded = 'Nothing loaded';
|
||||||
* Flag whether user has requested a quit
|
|
||||||
* Used to coordinate window closes without exit
|
|
||||||
*/
|
|
||||||
let isQuitting = false;
|
let isQuitting = false;
|
||||||
|
|
||||||
// initialise
|
// initialise
|
||||||
@@ -61,13 +56,13 @@ async function startBackend() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @description utility function to create a notification
|
* @description utility function to create a notification
|
||||||
* @param {string} title - Notification title
|
* @param title
|
||||||
* @param {string} body - Notification body
|
* @param text
|
||||||
*/
|
*/
|
||||||
function showNotification(title, body) {
|
function showNotification(title, text) {
|
||||||
new Notification({
|
new Notification({
|
||||||
title,
|
title,
|
||||||
body,
|
body: text,
|
||||||
silent: true,
|
silent: true,
|
||||||
}).show();
|
}).show();
|
||||||
}
|
}
|
||||||
@@ -146,7 +141,7 @@ function createWindow() {
|
|||||||
skipTaskbar: true,
|
skipTaskbar: true,
|
||||||
});
|
});
|
||||||
splash.setIgnoreMouseEvents(true);
|
splash.setIgnoreMouseEvents(true);
|
||||||
const splashPath = path.join('file://', __dirname, '/splash/splash.html');
|
const splashPath = path.join('file://', __dirname, '/src/splash/splash.html');
|
||||||
splash.loadURL(splashPath);
|
splash.loadURL(splashPath);
|
||||||
|
|
||||||
win = new BrowserWindow({
|
win = new BrowserWindow({
|
||||||
@@ -161,7 +156,7 @@ function createWindow() {
|
|||||||
enableWebSQL: false,
|
enableWebSQL: false,
|
||||||
darkTheme: true,
|
darkTheme: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, './preload.js'),
|
preload: path.join(__dirname, './src/preload.js'),
|
||||||
nodeIntegration: true,
|
nodeIntegration: true,
|
||||||
contextIsolation: false,
|
contextIsolation: false,
|
||||||
},
|
},
|
||||||
@@ -178,19 +173,25 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
||||||
|
// register global shortcuts
|
||||||
|
// (available regardless of whether app is in focus)
|
||||||
|
// bring focus to window
|
||||||
|
globalShortcut.register('Alt+1', () => {
|
||||||
|
bringToFront();
|
||||||
|
});
|
||||||
|
|
||||||
startBackend()
|
startBackend()
|
||||||
.then((port) => {
|
.then((port) => {
|
||||||
const clientUrl = getClientUrl(port);
|
// Load page served by node or use React dev run
|
||||||
const serverUrl = getServerUrl(port);
|
const clientUrl = isProduction
|
||||||
const menu = getApplicationMenu(
|
? electronConfig.reactAppUrl.production(port)
|
||||||
askToQuit,
|
: electronConfig.reactAppUrl.development(port);
|
||||||
clientUrl,
|
|
||||||
serverUrl,
|
const template = getApplicationMenu(isMac, askToQuit, clientUrl, `v${version}`, (path) => {
|
||||||
(path) => {
|
|
||||||
win.loadURL(`${clientUrl}/${path}`);
|
win.loadURL(`${clientUrl}/${path}`);
|
||||||
},
|
});
|
||||||
(url) => win.webContents.downloadURL(url),
|
const menu = Menu.buildFromTemplate(template);
|
||||||
);
|
|
||||||
Menu.setApplicationMenu(menu);
|
Menu.setApplicationMenu(menu);
|
||||||
|
|
||||||
win
|
win
|
||||||
@@ -235,9 +236,13 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// create tray and set its context menu
|
// create tray
|
||||||
tray = new Tray(trayIcon);
|
tray = new Tray(trayIcon);
|
||||||
const trayContextMenu = getTrayMenu(bringToFront, askToQuit);
|
|
||||||
|
// Define context menu
|
||||||
|
const { getTrayMenu } = require('./src/menu/trayMenu.js');
|
||||||
|
const trayMenuTemplate = getTrayMenu(bringToFront, askToQuit);
|
||||||
|
const trayContextMenu = Menu.buildFromTemplate(trayMenuTemplate);
|
||||||
tray.setContextMenu(trayContextMenu);
|
tray.setContextMenu(trayContextMenu);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -263,7 +268,7 @@ ipcMain.on('shutdown', () => {
|
|||||||
/**
|
/**
|
||||||
* Handles requests to set window properties
|
* Handles requests to set window properties
|
||||||
*/
|
*/
|
||||||
ipcMain.on('set-window', (_event, arg) => {
|
ipcMain.on('set-window', (event, arg) => {
|
||||||
switch (arg) {
|
switch (arg) {
|
||||||
case 'show-dev':
|
case 'show-dev':
|
||||||
win.webContents.openDevTools({ mode: 'detach' });
|
win.webContents.openDevTools({ mode: 'detach' });
|
||||||
@@ -276,10 +281,6 @@ ipcMain.on('set-window', (_event, arg) => {
|
|||||||
/**
|
/**
|
||||||
* Handles requests to open external links
|
* Handles requests to open external links
|
||||||
*/
|
*/
|
||||||
ipcMain.on('send-to-link', (_event, arg) => {
|
ipcMain.on('send-to-link', (event, arg) => {
|
||||||
try {
|
|
||||||
shell.openExternal(arg);
|
shell.openExternal(arg);
|
||||||
} catch (_error) {
|
|
||||||
/** unhandled error */
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "3.6.0",
|
"version": "3.5.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"timer"
|
"timer"
|
||||||
],
|
],
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"main": "src/main.js",
|
"main": "main.js",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^31.2.0",
|
"electron": "^31.2.0",
|
||||||
"electron-builder": "^24.13.3",
|
"electron-builder": "^24.13.3",
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"**/*",
|
"**/*",
|
||||||
"src/assets/",
|
"assets/",
|
||||||
"!**/{yarn.lock,yarn-error.log}",
|
"!**/{yarn.lock,yarn-error.log}",
|
||||||
"!**/{pnpm-lock.yaml}",
|
"!**/{pnpm-lock.yaml}",
|
||||||
"!**/{test,tests,__test__,__tests__}",
|
"!**/{test,tests,__test__,__tests__}",
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
"!*{.spec.js,*.test.js,*.spec.ts,.test.ts}"
|
"!*{.spec.js,*.test.js,*.spec.ts,.test.ts}"
|
||||||
],
|
],
|
||||||
"directories": {
|
"directories": {
|
||||||
"buildResources": "./src/assets/"
|
"buildResources": "./assets/"
|
||||||
},
|
},
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
@@ -112,6 +112,13 @@
|
|||||||
"!*{.spec.js,*.test.js,*.spec.ts,.test.ts}"
|
"!*{.spec.js,*.test.js,*.spec.ts,.test.ts}"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "../server/src/preloaded-db/",
|
||||||
|
"to": "extraResources/preloaded-db/",
|
||||||
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "../server/src/external/",
|
"from": "../server/src/external/",
|
||||||
"to": "extraResources/external/",
|
"to": "extraResources/external/",
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
/**
|
|
||||||
* This file contains a list of constants that may need to be resolved at runtime
|
|
||||||
*/
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const { version } = require('../package.json');
|
|
||||||
|
|
||||||
const electronConfig = require('./electron.config.js');
|
|
||||||
|
|
||||||
// external links
|
|
||||||
const linkToDocs = 'https://docs.getontime.no/';
|
|
||||||
const linkToGitHub = 'https://github.com/cpvalente/ontime';
|
|
||||||
const linkToDiscord = 'https://discord.com/invite/eje3CSUEXm';
|
|
||||||
|
|
||||||
// environment and platform constants
|
|
||||||
const env = process.env.NODE_ENV || 'production';
|
|
||||||
const isProduction = env === 'production';
|
|
||||||
const isMac = process.platform === 'darwin';
|
|
||||||
const isWindows = process.platform === 'win32';
|
|
||||||
const isLinux = process.platform === 'linux';
|
|
||||||
|
|
||||||
const releaseTag = `v${version}`;
|
|
||||||
|
|
||||||
/** path to server directory */
|
|
||||||
const nodePath = isProduction
|
|
||||||
? path.join(__dirname, electronConfig.server.pathToEntrypoint)
|
|
||||||
: path.join(__dirname, '../../server/dist/index.cjs');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves correct URL for client
|
|
||||||
* @param {number | undefined} port - the port at which the server is running
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
const getClientUrl = (port) =>
|
|
||||||
isProduction ? electronConfig.reactAppUrl.production(port) : electronConfig.reactAppUrl.development(port);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves correct URL for server
|
|
||||||
* @param {number | undefined} port - the port at which the server is running
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
const getServerUrl = (port) => `http://localhost:${port}`;
|
|
||||||
|
|
||||||
/** Resolves URL path to download resources */
|
|
||||||
const downloadPath = '/data/db/';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Returns public path depending on OS
|
|
||||||
* This is the correct path for the app running in production mode
|
|
||||||
*/
|
|
||||||
function getAppDataPath() {
|
|
||||||
if (isMac) {
|
|
||||||
return path.join(process.env.HOME, 'Library', 'Application Support', 'Ontime');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isWindows) {
|
|
||||||
return path.join(process.env.APPDATA, 'Ontime');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLinux) {
|
|
||||||
return path.join(process.env.HOME, '.Ontime');
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectsPath = path.join(getAppDataPath(), 'projects');
|
|
||||||
const corruptProjectsPath = path.join(getAppDataPath(), 'corrupt files');
|
|
||||||
const crashLogPath = path.join(getAppDataPath(), 'crash logs');
|
|
||||||
const stylesPath = path.join(getAppDataPath(), 'styles');
|
|
||||||
const externalPath = path.join(getAppDataPath(), 'external');
|
|
||||||
|
|
||||||
/** path to tray icon */
|
|
||||||
const trayIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'background.png');
|
|
||||||
/** path to app icon directory */
|
|
||||||
const appIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'logo.png');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
linkToDocs,
|
|
||||||
linkToGitHub,
|
|
||||||
linkToDiscord,
|
|
||||||
env,
|
|
||||||
isProduction,
|
|
||||||
isMac,
|
|
||||||
isWindows,
|
|
||||||
releaseTag,
|
|
||||||
nodePath,
|
|
||||||
getClientUrl,
|
|
||||||
getServerUrl,
|
|
||||||
projectsPath,
|
|
||||||
corruptProjectsPath,
|
|
||||||
crashLogPath,
|
|
||||||
stylesPath,
|
|
||||||
externalPath,
|
|
||||||
downloadPath,
|
|
||||||
trayIcon,
|
|
||||||
appIcon,
|
|
||||||
};
|
|
||||||
@@ -1,248 +1,185 @@
|
|||||||
const { Menu, shell } = require('electron');
|
const { shell } = require('electron');
|
||||||
|
|
||||||
const {
|
|
||||||
linkToGitHub,
|
|
||||||
linkToDocs,
|
|
||||||
linkToDiscord,
|
|
||||||
isMac,
|
|
||||||
releaseTag,
|
|
||||||
projectsPath,
|
|
||||||
corruptProjectsPath,
|
|
||||||
crashLogPath,
|
|
||||||
stylesPath,
|
|
||||||
externalPath,
|
|
||||||
downloadPath,
|
|
||||||
} = require('../external');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the application menu
|
* Build description of application menu
|
||||||
|
* @param {boolean} isMac - Whether the target platform is mac
|
||||||
* @param {function} askToQuit - function for quitting process
|
* @param {function} askToQuit - function for quitting process
|
||||||
* @param {string} clientUrl - base url for the application
|
|
||||||
* @param {string} serverUrl - base url for the application
|
|
||||||
* @param {function} redirectWindow - function to redirect main window content
|
|
||||||
* @param {function} download - function to download a resource from url
|
|
||||||
* @returns {Menu} - application menu
|
|
||||||
*/
|
*/
|
||||||
function getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, download) {
|
function getApplicationMenu(isMac, askToQuit, urlBase, version, redirectWindow) {
|
||||||
const template = [
|
return [
|
||||||
...(isMac ? [makeMacMenu(askToQuit)] : []),
|
...(isMac
|
||||||
makeFileMenu(serverUrl, redirectWindow, download),
|
? [
|
||||||
makeViewMenu(clientUrl),
|
{
|
||||||
makeSettingsMenu(redirectWindow),
|
|
||||||
makeHelpMenu(redirectWindow),
|
|
||||||
];
|
|
||||||
return Menu.buildFromTemplate(template);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function generates the app menu (macOS only)
|
|
||||||
* @param {function} askToQuit - function for quitting process
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
function makeMacMenu(askToQuit) {
|
|
||||||
return {
|
|
||||||
label: 'Ontime',
|
label: 'Ontime',
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: 'about', label: 'About Ontime' },
|
{ role: 'about' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ role: 'hide', label: 'Hide Ontime' },
|
{ role: 'hide' },
|
||||||
{ role: 'hideOthers' },
|
{ role: 'hideOthers' },
|
||||||
{ role: 'unhide' },
|
{ role: 'unhide' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'Quit',
|
label: 'Quit',
|
||||||
click: () => askToQuit(),
|
click: () => askToQuit(),
|
||||||
accelerator: isMac ? 'Cmd+Q' : 'Alt+F4',
|
accelerator: 'Cmd+Q',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
},
|
||||||
}
|
]
|
||||||
|
: []),
|
||||||
/**
|
{
|
||||||
* Utility function generates the file menu
|
|
||||||
* @param {string} serverUrl - base url for the application
|
|
||||||
* @param {function} redirectWindow - function to redirect main window content
|
|
||||||
* @param {function} download - function to download a resource from url
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
function makeFileMenu(serverUrl, redirectWindow, download) {
|
|
||||||
const downloadProject = () => {
|
|
||||||
try {
|
|
||||||
download(serverUrl + downloadPath);
|
|
||||||
} catch (_error) {
|
|
||||||
/** unhandled error */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: 'File',
|
label: 'File',
|
||||||
|
submenu: [isMac ? { role: 'close' } : { role: 'quit' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{ role: 'undo' },
|
||||||
label: 'New project...',
|
{ role: 'redo' },
|
||||||
click: () => redirectWindow('settings=project__manage&create=true'),
|
{ type: 'separator' },
|
||||||
},
|
{ role: 'cut' },
|
||||||
{
|
{ role: 'copy' },
|
||||||
label: 'Edit project info',
|
{ role: 'paste' },
|
||||||
click: () => redirectWindow('editor?settings=project__data'),
|
...(isMac
|
||||||
},
|
? [
|
||||||
{
|
{ role: 'pasteAndMatchStyle' },
|
||||||
label: 'Manage projects...',
|
{ role: 'delete' },
|
||||||
click: () => redirectWindow('editor?settings=project__manage'),
|
{ role: 'selectAll' },
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Download project',
|
|
||||||
click: downloadProject,
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'Open directory',
|
label: 'Speech',
|
||||||
submenu: [
|
submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }],
|
||||||
makeItemOpenInDesktop('Projects', projectsPath),
|
},
|
||||||
makeItemOpenInDesktop('Corrupted projects', corruptProjectsPath),
|
]
|
||||||
makeItemOpenInDesktop('Crash logs', crashLogPath),
|
: [{ role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }]),
|
||||||
makeItemOpenInDesktop('CSS override', stylesPath),
|
|
||||||
makeItemOpenInDesktop('External', externalPath),
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{
|
||||||
{ role: isMac ? 'close' : 'quit' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function generates the views menu
|
|
||||||
* @param {string} clientUrl - base url for the application
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
function makeViewMenu(clientUrl) {
|
|
||||||
return {
|
|
||||||
label: 'Views',
|
label: 'Views',
|
||||||
submenu: [
|
submenu: [
|
||||||
makeItemOpenInBrowser('Public', `${clientUrl}/public`),
|
{
|
||||||
makeItemOpenInBrowser('Lower Thirds', `${clientUrl}/lower`),
|
label: 'Ontime Views (opens in browser)',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Public',
|
||||||
|
click: async () => {
|
||||||
|
await shell.openExternal(`${urlBase}/public`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Lower Thirds',
|
||||||
|
click: async () => {
|
||||||
|
await shell.openExternal(`${urlBase}/lower`);
|
||||||
|
},
|
||||||
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
makeItemOpenInBrowser('Timer', `${clientUrl}/timer`),
|
{
|
||||||
makeItemOpenInBrowser('Minimal Timer', `${clientUrl}/minimal`),
|
label: 'Timer',
|
||||||
makeItemOpenInBrowser('Clock', `${clientUrl}/clock`),
|
accelerator: 'CmdOrCtrl+V',
|
||||||
makeItemOpenInBrowser('Backstage', `${clientUrl}/backstage`),
|
click: async () => {
|
||||||
makeItemOpenInBrowser('Timeline (beta)', `${clientUrl}/timeline`),
|
await shell.openExternal(`${urlBase}/timer`);
|
||||||
makeItemOpenInBrowser('Studio Clock', `${clientUrl}/studio`),
|
},
|
||||||
makeItemOpenInBrowser('Countdown', `${clientUrl}/countdown`),
|
},
|
||||||
|
{
|
||||||
|
label: 'Clock',
|
||||||
|
click: async () => {
|
||||||
|
await shell.openExternal(`${urlBase}/clock`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Minimal Timer',
|
||||||
|
click: async () => {
|
||||||
|
await shell.openExternal(`${urlBase}/minimal`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Backstage',
|
||||||
|
click: async () => {
|
||||||
|
await shell.openExternal(`${urlBase}/backstage`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Timeline (beta)',
|
||||||
|
click: async () => {
|
||||||
|
await shell.openExternal(`${urlBase}/timeline`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Studio Clock',
|
||||||
|
click: async () => {
|
||||||
|
await shell.openExternal(`${urlBase}/studio`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Countdown',
|
||||||
|
click: async () => {
|
||||||
|
await shell.openExternal(`${urlBase}/countdown`);
|
||||||
|
},
|
||||||
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
makeItemOpenInBrowser('Editor', `${clientUrl}/editor`),
|
{
|
||||||
makeItemOpenInBrowser('Cuesheet', `${clientUrl}/cuesheet`),
|
label: 'Editor',
|
||||||
makeItemOpenInBrowser('Operator', `${clientUrl}/op`),
|
click: async () => {
|
||||||
|
await shell.openExternal(`${urlBase}/editor`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cuesheet',
|
||||||
|
click: async () => {
|
||||||
|
await shell.openExternal(`${urlBase}/cuesheet`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Operator',
|
||||||
|
click: async () => {
|
||||||
|
await shell.openExternal(`${urlBase}/op`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ role: 'forceReload' },
|
{ role: 'forceReload' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ role: 'resetZoom' },
|
{ role: 'resetZoom' },
|
||||||
{ role: 'zoomIn' },
|
{ role: 'zoomIn' },
|
||||||
{ role: 'zoomOut' },
|
{ role: 'zoomOut' },
|
||||||
{ type: 'separator' },
|
|
||||||
{ role: 'togglefullscreen' },
|
|
||||||
],
|
],
|
||||||
};
|
},
|
||||||
}
|
{
|
||||||
|
label: 'Window',
|
||||||
/**
|
|
||||||
* Utility function generates the settings menu
|
|
||||||
* @param {function} redirectWindow - function to redirect main window content
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
function makeSettingsMenu(redirectWindow) {
|
|
||||||
return {
|
|
||||||
label: 'Settings',
|
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{ role: 'minimize' },
|
||||||
label: 'Open settings',
|
{ role: 'zoom' },
|
||||||
accelerator: 'CommandOrControl+,',
|
...(isMac
|
||||||
click: () => redirectWindow('editor?settings=project'),
|
? [{ type: 'separator' }, { role: 'front' }, { type: 'separator' }, { role: 'window' }]
|
||||||
},
|
: [{ role: 'close' }]),
|
||||||
{
|
|
||||||
label: 'App settings',
|
|
||||||
click: () => redirectWindow('editor?settings=general'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Editor settings',
|
|
||||||
click: () => redirectWindow('editor?settings=general__editor'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'View settings',
|
|
||||||
click: () => redirectWindow('editor?settings=general__view'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Integrations',
|
|
||||||
click: () => redirectWindow('editor?settings=integrations'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Network',
|
|
||||||
click: () => redirectWindow('editor?settings=network'),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
},
|
||||||
}
|
{
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function generates the help menu
|
|
||||||
* @param {function} redirectWindow - function to redirect main window content
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
function makeHelpMenu(redirectWindow) {
|
|
||||||
return {
|
|
||||||
role: 'help',
|
role: 'help',
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: `Ontime ${releaseTag}`,
|
label: 'About',
|
||||||
click: () => redirectWindow('editor?settings=about'),
|
click: () => redirectWindow('editor?settings=about'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'separator',
|
label: version,
|
||||||
|
click: () => redirectWindow('editor?settings=about'),
|
||||||
},
|
},
|
||||||
makeItemOpenInBrowser('See on github', linkToGitHub),
|
{
|
||||||
makeItemOpenInBrowser('Online documentation', linkToDocs),
|
label: 'See on github',
|
||||||
makeItemOpenInBrowser('Join us on Discord', linkToDiscord),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function to safely open a URL in the default browser
|
|
||||||
* @param {string} label
|
|
||||||
* @param {string} url
|
|
||||||
* @returns {object} - MenuItem
|
|
||||||
*/
|
|
||||||
function makeItemOpenInBrowser(label, url) {
|
|
||||||
return {
|
|
||||||
label: `${label} ↗`,
|
|
||||||
click: async () => {
|
click: async () => {
|
||||||
try {
|
await shell.openExternal('https://github.com/cpvalente/ontime');
|
||||||
await shell.openExternal(url);
|
|
||||||
} catch (_error) {
|
|
||||||
/** unhandled error */
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function to open a file in the OS explorer / finder
|
|
||||||
* @param {string} label
|
|
||||||
* @param {string} path
|
|
||||||
* @returns {object} - MenuItem
|
|
||||||
*/
|
|
||||||
function makeItemOpenInDesktop(label, path) {
|
|
||||||
return {
|
|
||||||
label,
|
|
||||||
click: () => {
|
|
||||||
try {
|
|
||||||
shell.openPath(path);
|
|
||||||
} catch (_error) {
|
|
||||||
/** unhandled error */
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
{
|
||||||
|
label: 'Online documentation',
|
||||||
|
click: async () => {
|
||||||
|
await shell.openExternal('https://docs.getontime.no/');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getApplicationMenu };
|
module.exports = { getApplicationMenu };
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
const { Menu } = require('electron');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the application tray menu
|
* Build description of tray context menu
|
||||||
* @param {function} showApp - function for making the window visible
|
* @param {function} showApp - function for making the window visible
|
||||||
* @param {function} askToQuit - function for quitting process
|
* @param {function} askToQuit - function for quitting process
|
||||||
* @returns {Menu} - application tray menu
|
|
||||||
*/
|
*/
|
||||||
function getTrayMenu(showApp, askToQuit) {
|
function getTrayMenu(showApp, askToQuit) {
|
||||||
return Menu.buildFromTemplate([
|
return [
|
||||||
{
|
{
|
||||||
label: 'Show App',
|
label: 'Show App',
|
||||||
click: showApp,
|
click: () => showApp(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Shutdown',
|
label: 'Shutdown',
|
||||||
click: askToQuit,
|
click: () => askToQuit(),
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getTrayMenu };
|
module.exports = { getTrayMenu };
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<img src="../assets/logo.png" />
|
<img src="../../assets/logo.png" />
|
||||||
<h1>ontime · event timers</h1>
|
<h1>ontime · event timers</h1>
|
||||||
<div class="lds-ellipsis">
|
<div class="lds-ellipsis">
|
||||||
<div></div>
|
<div></div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "3.6.0",
|
"version": "3.5.0",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
|
|||||||
@@ -58,19 +58,6 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Allows downloading of current project file
|
|
||||||
*/
|
|
||||||
export async function currentProjectDownload(_req: Request, res: Response) {
|
|
||||||
const { filename, pathToFile } = await projectService.getCurrentProject();
|
|
||||||
res.download(pathToFile, filename, (error) => {
|
|
||||||
if (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(500).send({ message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows downloading of project files
|
* Allows downloading of project files
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import express from 'express';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createProjectFile,
|
createProjectFile,
|
||||||
currentProjectDownload,
|
|
||||||
deleteProjectFile,
|
deleteProjectFile,
|
||||||
duplicateProjectFile,
|
duplicateProjectFile,
|
||||||
getInfo,
|
getInfo,
|
||||||
@@ -24,7 +23,6 @@ import {
|
|||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
router.get('/', currentProjectDownload);
|
|
||||||
router.post('/download', validateFilenameBody, projectDownload);
|
router.post('/download', validateFilenameBody, projectDownload);
|
||||||
router.post('/upload', uploadProjectFile, postProjectFile);
|
router.post('/upload', uploadProjectFile, postProjectFile);
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { handleLegacyMessageConversion } from '../integration.legacy.js';
|
|
||||||
|
|
||||||
describe('handleLegacyConversion', () => {
|
|
||||||
it('should return the payload as is if it is not a legacy message', () => {
|
|
||||||
expect(handleLegacyMessageConversion({})).toEqual({});
|
|
||||||
const newPayload = {
|
|
||||||
timer: {
|
|
||||||
text: 'text',
|
|
||||||
visible: true,
|
|
||||||
blink: true,
|
|
||||||
blackout: true,
|
|
||||||
},
|
|
||||||
external: 'text',
|
|
||||||
};
|
|
||||||
expect(handleLegacyMessageConversion(newPayload)).toEqual(newPayload);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should convert a legacy payload with external message', () => {
|
|
||||||
expect(handleLegacyMessageConversion({ external: { text: 'text', visible: true } })).toEqual({
|
|
||||||
external: 'text',
|
|
||||||
timer: {
|
|
||||||
secondarySource: 'external',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(handleLegacyMessageConversion({ external: { visible: true } })).toEqual({
|
|
||||||
timer: {
|
|
||||||
secondarySource: 'external',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(handleLegacyMessageConversion({ external: { text: 'text' } })).toEqual({
|
|
||||||
external: 'text',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { MessageState, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
|
import { DeepPartial, MessageState, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||||
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||||
|
|
||||||
import { DeepPartial } from 'ts-essentials';
|
|
||||||
|
|
||||||
import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
|
import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
|
||||||
import { auxTimerService } from '../services/aux-timer-service/AuxTimerService.js';
|
import { auxTimerService } from '../services/aux-timer-service/AuxTimerService.js';
|
||||||
import * as messageService from '../services/message-service/MessageService.js';
|
import { messageService } from '../services/message-service/MessageService.js';
|
||||||
import { validateMessage, validateTimerMessage } from '../services/message-service/messageUtils.js';
|
import { validateMessage, validateTimerMessage } from '../services/message-service/messageUtils.js';
|
||||||
import { runtimeService } from '../services/runtime-service/RuntimeService.js';
|
import { runtimeService } from '../services/runtime-service/RuntimeService.js';
|
||||||
import { eventStore } from '../stores/EventStore.js';
|
import { eventStore } from '../stores/EventStore.js';
|
||||||
@@ -16,8 +14,6 @@ import { socket } from '../adapters/WebsocketAdapter.js';
|
|||||||
import { throttle } from '../utils/throttle.js';
|
import { throttle } from '../utils/throttle.js';
|
||||||
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
|
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
|
||||||
|
|
||||||
import { handleLegacyMessageConversion } from './integration.legacy.js';
|
|
||||||
|
|
||||||
const throttledUpdateEvent = throttle(updateEvent, 20);
|
const throttledUpdateEvent = throttle(updateEvent, 20);
|
||||||
|
|
||||||
export function dispatchFromAdapter(type: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') {
|
export function dispatchFromAdapter(type: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') {
|
||||||
@@ -82,12 +78,9 @@ const actionHandlers: Record<string, ActionHandler> = {
|
|||||||
message: (payload) => {
|
message: (payload) => {
|
||||||
assert.isObject(payload);
|
assert.isObject(payload);
|
||||||
|
|
||||||
// TODO: remove this once we feel its been enough time, ontime 3.6.0, 20/09/2024
|
|
||||||
const migratedPayload = handleLegacyMessageConversion(payload);
|
|
||||||
|
|
||||||
const patch: DeepPartial<MessageState> = {
|
const patch: DeepPartial<MessageState> = {
|
||||||
timer: 'timer' in migratedPayload ? validateTimerMessage(migratedPayload.timer) : undefined,
|
timer: 'timer' in payload ? validateTimerMessage(payload.timer) : undefined,
|
||||||
external: 'external' in migratedPayload ? validateMessage(migratedPayload.external) : undefined,
|
external: 'external' in payload ? validateMessage(payload.external) : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const newMessage = messageService.patch(patch);
|
const newMessage = messageService.patch(patch);
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import { MessageState } from 'ontime-types';
|
|
||||||
import { DeepPartial } from 'ts-essentials';
|
|
||||||
|
|
||||||
export type LegacyMessageState = DeepPartial<{
|
|
||||||
timer: {
|
|
||||||
text: string;
|
|
||||||
visible: boolean;
|
|
||||||
blink: boolean;
|
|
||||||
blackout: boolean;
|
|
||||||
};
|
|
||||||
external: {
|
|
||||||
text: string;
|
|
||||||
visible: boolean;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
|
|
||||||
function isLegacyMessageState(value: object): value is LegacyMessageState {
|
|
||||||
// @ts-expect-error -- good enough here
|
|
||||||
return value?.external?.text !== undefined || value?.external?.visible !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function is used to maintain support for legacy data in the /message endpoint
|
|
||||||
* The previous message endpoint expected a patch of the message state
|
|
||||||
* @example {
|
|
||||||
* timer: { blink: boolean, blackout: boolean, text: string, visible: boolean },
|
|
||||||
* external: { visible: boolean, text: string }
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* This change is introduced in version 3.6.0
|
|
||||||
*/
|
|
||||||
export function handleLegacyMessageConversion(payload: object): object | Partial<MessageState> {
|
|
||||||
// if it is not a legacy message, we pass it as is
|
|
||||||
if (!isLegacyMessageState(payload)) {
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The current migration only needs to handle the cases
|
|
||||||
* for the deprecated external message controls
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Migrate external message
|
|
||||||
// 2.1 the user gives us the text and a visible flag
|
|
||||||
if (payload?.external?.text !== undefined && payload.external.visible !== undefined) {
|
|
||||||
return {
|
|
||||||
timer: { secondarySource: payload.external.visible ? 'external' : null },
|
|
||||||
external: payload.external.text,
|
|
||||||
} as Partial<MessageState>;
|
|
||||||
}
|
|
||||||
// 2.2 the user gives us the text
|
|
||||||
else if (payload?.external?.text !== undefined) {
|
|
||||||
return {
|
|
||||||
external: payload.external.text,
|
|
||||||
} as Partial<MessageState>;
|
|
||||||
}
|
|
||||||
// 2.3 the user gives us the visible flag
|
|
||||||
else if (payload?.external?.visible !== undefined) {
|
|
||||||
return {
|
|
||||||
timer: { secondarySource: payload.external.visible ? 'external' : null },
|
|
||||||
} as Partial<MessageState>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// there should be no case for us to reach this since
|
|
||||||
// the type guard would have ensured one of the above states
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
@@ -38,7 +38,7 @@ import { populateStyles } from './setup/loadStyles.js';
|
|||||||
import { eventStore } from './stores/EventStore.js';
|
import { eventStore } from './stores/EventStore.js';
|
||||||
import { runtimeService } from './services/runtime-service/RuntimeService.js';
|
import { runtimeService } from './services/runtime-service/RuntimeService.js';
|
||||||
import { restoreService } from './services/RestoreService.js';
|
import { restoreService } from './services/RestoreService.js';
|
||||||
import * as messageService from './services/message-service/MessageService.js';
|
import { messageService } from './services/message-service/MessageService.js';
|
||||||
import { populateDemo } from './setup/loadDemo.js';
|
import { populateDemo } from './setup/loadDemo.js';
|
||||||
import { getState } from './stores/runtimeState.js';
|
import { getState } from './stores/runtimeState.js';
|
||||||
import { initRundown } from './services/rundown-service/RundownService.js';
|
import { initRundown } from './services/rundown-service/RundownService.js';
|
||||||
@@ -210,7 +210,7 @@ export const startServer = async (
|
|||||||
runtimeService.init(maybeRestorePoint);
|
runtimeService.init(maybeRestorePoint);
|
||||||
|
|
||||||
// eventStore set is a dependency of the services that publish to it
|
// eventStore set is a dependency of the services that publish to it
|
||||||
messageService.init((key, value) => eventStore.set(key, value));
|
messageService.init(eventStore.set.bind(eventStore));
|
||||||
|
|
||||||
expressServer.listen(serverPort, '0.0.0.0', () => {
|
expressServer.listen(serverPort, '0.0.0.0', () => {
|
||||||
const nif = getNetworkInterfaces();
|
const nif = getNetworkInterfaces();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { OntimeEvent, SupportedEvent } from 'ontime-types';
|
import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
|
||||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
|
|
||||||
import { loadRoll } from '../rollUtils.js';
|
import { loadRoll } from '../rollUtils.js';
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export class OscIntegration implements IIntegration<OscSubscription, OSCSettings
|
|||||||
|
|
||||||
dispatch(action: TimerLifeCycleKey, state?: object) {
|
dispatch(action: TimerLifeCycleKey, state?: object) {
|
||||||
// noop
|
// noop
|
||||||
if (!this.oscClient || !this.enabledOut) {
|
if (!this.oscClient) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,63 +1,68 @@
|
|||||||
import { TimerMessage, MessageState } from 'ontime-types';
|
import { DeepPartial, Message, TimerMessage, MessageState } from 'ontime-types';
|
||||||
import { DeepPartial } from 'ts-essentials';
|
|
||||||
|
|
||||||
import { throttle } from '../../utils/throttle.js';
|
import { throttle } from '../../utils/throttle.js';
|
||||||
|
|
||||||
import type { PublishFn } from '../../stores/EventStore.js';
|
import type { PublishFn } from '../../stores/EventStore.js';
|
||||||
|
|
||||||
const defaultTimer: TimerMessage = {
|
let instance: MessageService | null = null;
|
||||||
|
|
||||||
|
class MessageService {
|
||||||
|
timer: TimerMessage;
|
||||||
|
external: Message;
|
||||||
|
|
||||||
|
private throttledSet: PublishFn;
|
||||||
|
private publish: PublishFn | null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (instance) {
|
||||||
|
throw new Error('There can be only one');
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
|
||||||
|
instance = this;
|
||||||
|
|
||||||
|
this.throttledSet = () => {
|
||||||
|
throw new Error('Published called before initialisation');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.timer = {
|
||||||
text: '',
|
text: '',
|
||||||
visible: false,
|
visible: false,
|
||||||
blink: false,
|
blink: false,
|
||||||
blackout: false,
|
blackout: false,
|
||||||
secondarySource: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let timer = { ...defaultTimer };
|
this.external = {
|
||||||
let external = '';
|
text: '',
|
||||||
|
visible: false,
|
||||||
let throttledSet: PublishFn | null = null;
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialises the message service with a publish function
|
|
||||||
* @param publishFn
|
|
||||||
*/
|
|
||||||
export function init(publishFn: PublishFn) {
|
|
||||||
throttledSet = throttle(publishFn, 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
init(publish: PublishFn) {
|
||||||
* Exposes function to reset the internal state
|
this.publish = publish;
|
||||||
*/
|
this.throttledSet = throttle((key, value) => this.publish?.(key, value), 100);
|
||||||
export function clear() {
|
|
||||||
timer = { ...defaultTimer };
|
|
||||||
external = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
getState(): MessageState {
|
||||||
* Exposes the internal state of the message service
|
|
||||||
*/
|
|
||||||
export function getState(): MessageState {
|
|
||||||
return {
|
return {
|
||||||
external,
|
timer: this.timer,
|
||||||
timer,
|
external: this.external,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
patch(message: DeepPartial<MessageState>) {
|
||||||
* Utility function allows patching internal object
|
if (message.timer) this.timer = { ...this.timer, ...message.timer };
|
||||||
*/
|
if (message.external) this.external = { ...this.external, ...message.external };
|
||||||
export function patch(patch: DeepPartial<MessageState>): MessageState {
|
|
||||||
// we cannot call patch before init
|
|
||||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
|
||||||
DEV: {
|
|
||||||
if (throttledSet === null) {
|
|
||||||
throw new Error('MessageService.patch() called before init()');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('timer' in patch) timer = { ...timer, ...patch.timer };
|
const newState = this.getState();
|
||||||
if ('external' in patch && patch.external !== undefined) external = patch.external;
|
|
||||||
const newState = getState();
|
this.throttledSet('message', newState);
|
||||||
throttledSet?.('message', newState);
|
|
||||||
return newState;
|
return newState;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messageService = new MessageService();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as messageService from '../MessageService.js';
|
import { messageService } from '../MessageService.js';
|
||||||
|
|
||||||
describe('MessageService', () => {
|
describe('MessageService', () => {
|
||||||
const publishFunction = () => {};
|
const publishFunction = () => {};
|
||||||
@@ -14,14 +14,17 @@ describe('MessageService', () => {
|
|||||||
it('should patch the message state', () => {
|
it('should patch the message state', () => {
|
||||||
const message = {
|
const message = {
|
||||||
timer: { text: 'new text', visible: true },
|
timer: { text: 'new text', visible: true },
|
||||||
external: 'external',
|
external: { visible: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
const newState = messageService.patch(message);
|
const newState = messageService.patch(message);
|
||||||
|
|
||||||
expect(newState).toEqual({
|
expect(newState).toEqual({
|
||||||
timer: { text: 'new text', visible: true, blackout: false, blink: false, secondarySource: null },
|
timer: { text: 'new text', visible: true, blackout: false, blink: false },
|
||||||
external: 'external',
|
external: {
|
||||||
|
text: '',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,8 +36,11 @@ describe('MessageService', () => {
|
|||||||
const newState = messageService.patch(initialMessage);
|
const newState = messageService.patch(initialMessage);
|
||||||
|
|
||||||
expect(newState).toEqual({
|
expect(newState).toEqual({
|
||||||
timer: { text: 'initial text', visible: true, blackout: false, blink: false, secondarySource: null },
|
timer: { text: 'initial text', visible: true, blackout: false, blink: false },
|
||||||
external: '',
|
external: {
|
||||||
|
text: '',
|
||||||
|
visible: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,26 @@ import { validateMessage, validateTimerMessage } from '../messageUtils.js';
|
|||||||
|
|
||||||
describe('validateMessage()', () => {
|
describe('validateMessage()', () => {
|
||||||
it('returns a valid Message object', () => {
|
it('returns a valid Message object', () => {
|
||||||
expect(validateMessage('test')).toEqual('test');
|
const payload = {
|
||||||
|
text: '12312',
|
||||||
|
visible: 'true',
|
||||||
|
};
|
||||||
|
const expected = {
|
||||||
|
text: '12312',
|
||||||
|
visible: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(validateMessage(payload)).toEqual(expected);
|
||||||
|
});
|
||||||
|
it('skips keys not given', () => {
|
||||||
|
const payload = {
|
||||||
|
visible: 'true',
|
||||||
|
};
|
||||||
|
const expected = {
|
||||||
|
visible: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(validateMessage(payload)).toStrictEqual(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TimerMessage } from 'ontime-types';
|
import { Message, TimerMessage } from 'ontime-types';
|
||||||
|
|
||||||
import * as assert from '../../utils/assert.js';
|
import * as assert from '../../utils/assert.js';
|
||||||
import { coerceBoolean, coerceString } from '../../utils/coerceType.js';
|
import { coerceBoolean, coerceString } from '../../utils/coerceType.js';
|
||||||
@@ -7,8 +7,14 @@ import { coerceBoolean, coerceString } from '../../utils/coerceType.js';
|
|||||||
* Creates a valid Message object from a payload
|
* Creates a valid Message object from a payload
|
||||||
* @throws if the payload is not an object
|
* @throws if the payload is not an object
|
||||||
*/
|
*/
|
||||||
export function validateMessage(message: unknown): string {
|
export function validateMessage(message: unknown): Partial<Message> {
|
||||||
return decodeURI(coerceString(message));
|
assert.isObject(message);
|
||||||
|
|
||||||
|
const result: Partial<Message> = {};
|
||||||
|
if ('text' in message) result.text = coerceString(message.text);
|
||||||
|
if ('visible' in message) result.visible = coerceBoolean(message.visible);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,28 +26,10 @@ export function validateTimerMessage(message: unknown): Partial<TimerMessage> {
|
|||||||
|
|
||||||
const result: Partial<TimerMessage> = {};
|
const result: Partial<TimerMessage> = {};
|
||||||
|
|
||||||
if ('text' in message) result.text = decodeURI(coerceString(message.text));
|
if ('text' in message) result.text = coerceString(message.text);
|
||||||
if ('visible' in message) result.visible = coerceBoolean(message.visible);
|
if ('visible' in message) result.visible = coerceBoolean(message.visible);
|
||||||
if ('blink' in message) result.blink = coerceBoolean(message.blink);
|
if ('blink' in message) result.blink = coerceBoolean(message.blink);
|
||||||
if ('blackout' in message) result.blackout = coerceBoolean(message.blackout);
|
if ('blackout' in message) result.blackout = coerceBoolean(message.blackout);
|
||||||
if ('secondarySource' in message) result.secondarySource = coerceSecondary(message.secondarySource);
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Asserts that the secondary value is one of the permitted values
|
|
||||||
*/
|
|
||||||
function assertSecondary(source: unknown): source is TimerMessage['secondarySource'] {
|
|
||||||
return source === 'aux' || source === 'external' || source === null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures that the secondary value is one of the permitted values
|
|
||||||
*/
|
|
||||||
function coerceSecondary(source: unknown): TimerMessage['secondarySource'] {
|
|
||||||
if (!assertSecondary(source)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return source;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -51,13 +51,6 @@ function init() {
|
|||||||
ensureDirectory(resolveCorruptDirectory);
|
ensureDirectory(resolveCorruptDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCurrentProject() {
|
|
||||||
const filename = await getLastLoadedProject();
|
|
||||||
const pathToFile = getPathToProject(filename);
|
|
||||||
|
|
||||||
return { filename, pathToFile };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Private function loads a demo project
|
* Private function loads a demo project
|
||||||
* to be composed in the loading functions
|
* to be composed in the loading functions
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { dayInMs, getFirstEvent } from 'ontime-utils';
|
import { dayInMs, getFirstEvent, getLastEvent } from 'ontime-utils';
|
||||||
import { OntimeEvent, MaybeNumber, PlayableEvent, isPlayableEvent } from 'ontime-types';
|
import { OntimeEvent, MaybeNumber, PlayableEvent, isPlayableEvent } from 'ontime-types';
|
||||||
|
|
||||||
import { normaliseEndTime } from './timerUtils.js';
|
import { normaliseEndTime } from './timerUtils.js';
|
||||||
|
|||||||
@@ -1,243 +1,249 @@
|
|||||||
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
|
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
|
||||||
import { apply } from '../delayUtils.js';
|
import { apply } from '../delayUtils.js';
|
||||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Small utility to fill in the necessary data for the test
|
|
||||||
*/
|
|
||||||
function makeOntimeEvent(event: Partial<OntimeEvent>): OntimeEvent {
|
|
||||||
return { ...event, type: SupportedEvent.Event, revision: 1 } as OntimeEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Small utility to make a delay event
|
|
||||||
*/
|
|
||||||
function makeOntimeDelay(duration: number): OntimeDelay {
|
|
||||||
return { id: 'delay', type: SupportedEvent.Delay, duration } as OntimeDelay;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('apply() ', () => {
|
describe('apply() ', () => {
|
||||||
it('applies a positive delay to the rundown', () => {
|
describe('in a rundown without the delay field, persisted rundown', () => {
|
||||||
const testRundown = [
|
it('applies delays', () => {
|
||||||
makeOntimeDelay(10),
|
const delayId = '1';
|
||||||
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
|
|
||||||
makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
|
|
||||||
{ id: '3', type: SupportedEvent.Block } as OntimeBlock,
|
|
||||||
makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
|
|
||||||
makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
|
|
||||||
];
|
|
||||||
|
|
||||||
const updatedRundown = apply('delay', testRundown);
|
|
||||||
expect(updatedRundown).not.toBe(testRundown);
|
|
||||||
expect(updatedRundown).toMatchObject([
|
|
||||||
{ id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 },
|
|
||||||
{ id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '1' },
|
|
||||||
{ id: '3' },
|
|
||||||
{ id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: null },
|
|
||||||
{ id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: '4' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies negative delays', () => {
|
|
||||||
const testRundown = [
|
|
||||||
makeOntimeDelay(-10),
|
|
||||||
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
|
|
||||||
makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
|
|
||||||
{ id: '3', type: SupportedEvent.Block } as OntimeBlock,
|
|
||||||
makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
|
|
||||||
makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
|
|
||||||
];
|
|
||||||
|
|
||||||
const updatedRundown = apply('delay', testRundown);
|
|
||||||
expect(updatedRundown).toMatchObject([
|
|
||||||
{ id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 },
|
|
||||||
{ id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null },
|
|
||||||
{ id: '3' },
|
|
||||||
{ id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: null },
|
|
||||||
{ id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '4' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should account for minimum duration and start when applying negative delays', () => {
|
|
||||||
const testRundown: OntimeRundown = [
|
const testRundown: OntimeRundown = [
|
||||||
makeOntimeDelay(-50),
|
{ id: delayId, type: SupportedEvent.Delay, duration: 10 } as OntimeDelay,
|
||||||
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
|
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
|
||||||
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: '1' }),
|
{ id: '3', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
|
||||||
|
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
|
||||||
|
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
|
||||||
];
|
];
|
||||||
|
|
||||||
const expected = [
|
const expected = [
|
||||||
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 2 } as OntimeEvent,
|
{ id: '2', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } as OntimeEvent,
|
||||||
{
|
{ id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } as OntimeEvent,
|
||||||
id: '2',
|
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
|
||||||
type: SupportedEvent.Event,
|
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
|
||||||
timeStart: 50,
|
|
||||||
timeEnd: 100,
|
|
||||||
duration: 50,
|
|
||||||
linkStart: null,
|
|
||||||
revision: 2,
|
|
||||||
} as OntimeEvent,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const updatedRundown = apply('delay', testRundown);
|
const updatedRundown = apply(delayId, testRundown);
|
||||||
expect(updatedRundown).toMatchObject(expected);
|
expect(updatedRundown).toStrictEqual(expected);
|
||||||
});
|
});
|
||||||
|
it('applies negative delays', () => {
|
||||||
it('unlinks events to maintain gaps when applying positive delays', () => {
|
const delayId = '1';
|
||||||
const testRundown = [
|
|
||||||
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
|
|
||||||
makeOntimeDelay(50),
|
|
||||||
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(apply('delay', testRundown)).toMatchObject([
|
|
||||||
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent,
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
type: SupportedEvent.Event,
|
|
||||||
timeStart: 150,
|
|
||||||
timeEnd: 200,
|
|
||||||
duration: 50,
|
|
||||||
linkStart: null,
|
|
||||||
revision: 2,
|
|
||||||
} as OntimeEvent,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maintains links if there is no gap', () => {
|
|
||||||
const testRundown = [
|
|
||||||
makeOntimeDelay(50),
|
|
||||||
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
|
|
||||||
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(apply('delay', testRundown)).toMatchObject([
|
|
||||||
{ id: '1', type: SupportedEvent.Event, timeStart: 50, timeEnd: 150, duration: 100, revision: 2 } as OntimeEvent,
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
type: SupportedEvent.Event,
|
|
||||||
timeStart: 150,
|
|
||||||
timeEnd: 200,
|
|
||||||
duration: 50,
|
|
||||||
linkStart: '1',
|
|
||||||
revision: 2,
|
|
||||||
} as OntimeEvent,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unlinks events to maintain gaps when applying negative delays', () => {
|
|
||||||
const testRundown = [
|
|
||||||
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
|
|
||||||
makeOntimeDelay(-50),
|
|
||||||
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(apply('delay', testRundown)).toMatchObject([
|
|
||||||
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent,
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
type: SupportedEvent.Event,
|
|
||||||
timeStart: 50,
|
|
||||||
timeEnd: 100,
|
|
||||||
duration: 50,
|
|
||||||
linkStart: null,
|
|
||||||
revision: 2,
|
|
||||||
} as OntimeEvent,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gaps reduce positive delay', () => {
|
|
||||||
const testRundown: OntimeRundown = [
|
const testRundown: OntimeRundown = [
|
||||||
makeOntimeDelay(100),
|
{ id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay,
|
||||||
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
|
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
|
||||||
// gap 50
|
{ id: '3', type: SupportedEvent.Event, timeStart: 20, timeEnd: 40, duration: 20, revision: 1 } as OntimeEvent,
|
||||||
makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50 }),
|
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
|
||||||
// gap 50
|
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
|
||||||
makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50 }),
|
|
||||||
// gap 50
|
|
||||||
makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50 }),
|
|
||||||
// linked
|
|
||||||
makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const updatedRundown = apply('delay', testRundown);
|
const expected = [
|
||||||
expect(updatedRundown).toMatchObject([
|
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 2 } as OntimeEvent,
|
||||||
{ id: '1', timeStart: 0 + 100, timeEnd: 100 + 100, duration: 100, revision: 2 },
|
{ id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 30, duration: 20, revision: 2 } as OntimeEvent,
|
||||||
// gap 50 (100 - 50)
|
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
|
||||||
{ id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 },
|
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
|
||||||
// gap 50 (50 - 50)
|
];
|
||||||
{ id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2 },
|
|
||||||
// gap (delay is 0)
|
|
||||||
{ id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 },
|
|
||||||
// linked
|
|
||||||
{ id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: '4' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gaps reduce positive delay (2)', () => {
|
const updatedRundown = apply(delayId, testRundown);
|
||||||
|
expect(updatedRundown).toStrictEqual(expected);
|
||||||
|
});
|
||||||
|
it('maintains constant duration', () => {
|
||||||
|
const delayId = '1';
|
||||||
const testRundown: OntimeRundown = [
|
const testRundown: OntimeRundown = [
|
||||||
makeOntimeDelay(2 * MILLIS_PER_HOUR),
|
{ id: delayId, type: SupportedEvent.Delay, duration: -30 } as OntimeDelay,
|
||||||
makeOntimeEvent({
|
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
|
||||||
id: '1',
|
{ id: '3', type: SupportedEvent.Event, timeStart: 20, timeEnd: 40, duration: 20, revision: 1 } as OntimeEvent,
|
||||||
timeStart: 46800000, // 13:00:00
|
|
||||||
timeEnd: 50400000, // 14:00:00
|
|
||||||
duration: MILLIS_PER_HOUR,
|
|
||||||
}),
|
|
||||||
// gap 1h
|
|
||||||
makeOntimeEvent({
|
|
||||||
id: '2',
|
|
||||||
timeStart: 54000000, // 15:00:00
|
|
||||||
timeEnd: 57600000, // 16:00:00
|
|
||||||
duration: MILLIS_PER_HOUR,
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const updatedRundown = apply('delay', testRundown);
|
const expected = [
|
||||||
expect(updatedRundown).toMatchObject([
|
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 2 } as OntimeEvent,
|
||||||
{ id: '1', timeStart: 54000000 /* 16 */, revision: 2 },
|
{ id: '3', type: SupportedEvent.Event, timeStart: 0, timeEnd: 20, duration: 20, revision: 2 } as OntimeEvent,
|
||||||
// gap 1h (2h - 1h)
|
];
|
||||||
{ id: '2', timeStart: 57600000 /* 16 */, revision: 2 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes empty delays without applying changes', () => {
|
const updatedRundown = apply(delayId, testRundown);
|
||||||
|
expect(updatedRundown).toStrictEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('in a rundown with the delay field, cached rundown', () => {
|
||||||
|
it('applies delays', () => {
|
||||||
|
const delayId = '1';
|
||||||
const testRundown: OntimeRundown = [
|
const testRundown: OntimeRundown = [
|
||||||
makeOntimeDelay(0),
|
{ id: delayId, type: SupportedEvent.Delay, duration: 10 } as OntimeDelay,
|
||||||
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
|
|
||||||
];
|
|
||||||
|
|
||||||
const updatedRundown = apply('delay', testRundown);
|
|
||||||
expect(updatedRundown).toMatchObject([{ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes delays in last position without applying changes', () => {
|
|
||||||
const testRundown: OntimeRundown = [
|
|
||||||
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
|
|
||||||
makeOntimeDelay(100),
|
|
||||||
];
|
|
||||||
|
|
||||||
const updatedRundown = apply('delay', testRundown);
|
|
||||||
expect(updatedRundown).toMatchObject([{ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unlinks events to across blocks is it is the first event after the delay', () => {
|
|
||||||
const testRundown = [
|
|
||||||
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
|
|
||||||
makeOntimeDelay(50),
|
|
||||||
{ id: 'block', type: SupportedEvent.Block } as OntimeBlock,
|
|
||||||
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
|
|
||||||
];
|
|
||||||
expect(apply('delay', testRundown)).toMatchObject([
|
|
||||||
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent,
|
|
||||||
{ id: 'block', type: SupportedEvent.Block },
|
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
timeStart: 150,
|
timeStart: 0,
|
||||||
timeEnd: 200,
|
timeEnd: 10,
|
||||||
duration: 50,
|
duration: 10,
|
||||||
linkStart: null,
|
revision: 1,
|
||||||
revision: 2,
|
delay: 10,
|
||||||
} as OntimeEvent,
|
} as OntimeEvent,
|
||||||
]);
|
{
|
||||||
|
id: '3',
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
timeStart: 0,
|
||||||
|
timeEnd: 10,
|
||||||
|
duration: 10,
|
||||||
|
revision: 1,
|
||||||
|
delay: 10,
|
||||||
|
} as OntimeEvent,
|
||||||
|
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
timeStart: 0,
|
||||||
|
timeEnd: 10,
|
||||||
|
duration: 10,
|
||||||
|
revision: 1,
|
||||||
|
delay: 0,
|
||||||
|
} as OntimeEvent,
|
||||||
|
];
|
||||||
|
|
||||||
|
const expected = [
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
timeStart: 10,
|
||||||
|
timeEnd: 20,
|
||||||
|
duration: 10,
|
||||||
|
revision: 2,
|
||||||
|
delay: 0,
|
||||||
|
} as OntimeEvent,
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
timeStart: 10,
|
||||||
|
timeEnd: 20,
|
||||||
|
duration: 10,
|
||||||
|
revision: 2,
|
||||||
|
delay: 0,
|
||||||
|
} as OntimeEvent,
|
||||||
|
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
timeStart: 0,
|
||||||
|
timeEnd: 10,
|
||||||
|
duration: 10,
|
||||||
|
revision: 1,
|
||||||
|
delay: 0,
|
||||||
|
} as OntimeEvent,
|
||||||
|
];
|
||||||
|
|
||||||
|
const updatedRundown = apply(delayId, testRundown);
|
||||||
|
expect(updatedRundown).toStrictEqual(expected);
|
||||||
|
});
|
||||||
|
it('applies negative delays', () => {
|
||||||
|
const delayId = '1';
|
||||||
|
const testRundown: OntimeRundown = [
|
||||||
|
{ id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay,
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
timeStart: 0,
|
||||||
|
timeEnd: 10,
|
||||||
|
duration: 10,
|
||||||
|
revision: 1,
|
||||||
|
delay: -10,
|
||||||
|
} as OntimeEvent,
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
timeStart: 20,
|
||||||
|
timeEnd: 40,
|
||||||
|
duration: 20,
|
||||||
|
revision: 1,
|
||||||
|
delay: -10,
|
||||||
|
} as OntimeEvent,
|
||||||
|
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
timeStart: 0,
|
||||||
|
timeEnd: 10,
|
||||||
|
duration: 10,
|
||||||
|
revision: 1,
|
||||||
|
delay: 0,
|
||||||
|
} as OntimeEvent,
|
||||||
|
];
|
||||||
|
|
||||||
|
const expected = [
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
timeStart: 0,
|
||||||
|
timeEnd: 10,
|
||||||
|
duration: 10,
|
||||||
|
revision: 2,
|
||||||
|
delay: 0,
|
||||||
|
} as OntimeEvent,
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
timeStart: 10,
|
||||||
|
timeEnd: 30,
|
||||||
|
duration: 20,
|
||||||
|
revision: 2,
|
||||||
|
delay: 0,
|
||||||
|
} as OntimeEvent,
|
||||||
|
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
timeStart: 0,
|
||||||
|
timeEnd: 10,
|
||||||
|
duration: 10,
|
||||||
|
revision: 1,
|
||||||
|
delay: 0,
|
||||||
|
} as OntimeEvent,
|
||||||
|
];
|
||||||
|
|
||||||
|
const updatedRundown = apply(delayId, testRundown);
|
||||||
|
expect(updatedRundown).toStrictEqual(expected);
|
||||||
|
});
|
||||||
|
it('maintains constant duration', () => {
|
||||||
|
const delayId = '1';
|
||||||
|
const testRundown: OntimeRundown = [
|
||||||
|
{ id: delayId, type: SupportedEvent.Delay, duration: -30 } as OntimeDelay,
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
timeStart: 0,
|
||||||
|
timeEnd: 10,
|
||||||
|
duration: 10,
|
||||||
|
revision: 1,
|
||||||
|
delay: -30,
|
||||||
|
} as OntimeEvent,
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
timeStart: 20,
|
||||||
|
timeEnd: 40,
|
||||||
|
duration: 20,
|
||||||
|
revision: 1,
|
||||||
|
delay: -30,
|
||||||
|
} as OntimeEvent,
|
||||||
|
];
|
||||||
|
|
||||||
|
const expected = [
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
timeStart: 0,
|
||||||
|
timeEnd: 10,
|
||||||
|
duration: 10,
|
||||||
|
revision: 2,
|
||||||
|
delay: 0,
|
||||||
|
} as OntimeEvent,
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
timeStart: 0,
|
||||||
|
timeEnd: 20,
|
||||||
|
duration: 20,
|
||||||
|
revision: 2,
|
||||||
|
delay: 0,
|
||||||
|
} as OntimeEvent,
|
||||||
|
];
|
||||||
|
|
||||||
|
const updatedRundown = apply(delayId, testRundown);
|
||||||
|
expect(updatedRundown).toStrictEqual(expected);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent, OntimeEvent } from 'ontime-types';
|
import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent } from 'ontime-types';
|
||||||
import { getTimeFromPrevious, deleteAtIndex } from 'ontime-utils';
|
|
||||||
|
import { deleteAtIndex } from '../../../../../packages/utils/src/array-utils/arrayUtils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates all delays in a given rundown
|
* Calculates all delays in a given rundown
|
||||||
@@ -91,7 +92,10 @@ export function getDelayAt(eventIndex: number, rundown: OntimeRundown): number {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies delay from given event ID, deletes the delay event after
|
* Applies delay from given event ID, deletes the delay event after
|
||||||
|
* @param eventId
|
||||||
|
* @param rundown
|
||||||
* @throws {Error} if event ID not found or is not a delay
|
* @throws {Error} if event ID not found or is not a delay
|
||||||
|
* @returns
|
||||||
*/
|
*/
|
||||||
export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
|
export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
|
||||||
const delayIndex = rundown.findIndex((event) => event.id === eventId);
|
const delayIndex = rundown.findIndex((event) => event.id === eventId);
|
||||||
@@ -105,71 +109,27 @@ export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
|
|||||||
throw new Error('Given event ID is not a delay');
|
throw new Error('Given event ID is not a delay');
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the delay is empty, or the last element, we can just delete it
|
const updatedRundown = [...rundown];
|
||||||
if (delayEvent.duration === 0 || delayIndex === rundown.length - 1) {
|
const delayValue = delayEvent.duration;
|
||||||
return deleteAtIndex(delayIndex, rundown);
|
|
||||||
|
if (delayValue === 0 || delayIndex === rundown.length - 1) {
|
||||||
|
// nothing to apply
|
||||||
|
return updatedRundown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
for (let i = delayIndex + 1; i < rundown.length; i++) {
|
||||||
* We apply the delay to the rundown
|
const currentEvent = updatedRundown[i];
|
||||||
* This logic is mostly in sync with rundownCache.generate
|
|
||||||
*/
|
|
||||||
const updatedRundown = structuredClone(rundown);
|
|
||||||
let delayValue = delayEvent.duration;
|
|
||||||
let lastEntry: OntimeEvent | null = null;
|
|
||||||
let isFirstEvent = true;
|
|
||||||
|
|
||||||
for (let i = delayIndex + 1; i < updatedRundown.length; i++) {
|
if (isOntimeBlock(currentEvent)) {
|
||||||
const currentEntry = updatedRundown[i];
|
|
||||||
|
|
||||||
// we don't do operation on other event types
|
|
||||||
if (!isOntimeEvent(currentEntry)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// we need to remove the link in the first event to maintain the gap
|
|
||||||
let shouldUnlink = isFirstEvent;
|
|
||||||
isFirstEvent = false;
|
|
||||||
|
|
||||||
// if the event is not linked, we try and maintain gaps
|
|
||||||
if (lastEntry !== null) {
|
|
||||||
const timeFromPrevious: number = getTimeFromPrevious(
|
|
||||||
currentEntry.timeStart,
|
|
||||||
lastEntry.timeStart,
|
|
||||||
lastEntry.timeEnd,
|
|
||||||
lastEntry.duration,
|
|
||||||
);
|
|
||||||
|
|
||||||
// when applying negative delays, we need to unlink the event
|
|
||||||
// if the previous event was fully consumed by the delay
|
|
||||||
if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) {
|
|
||||||
shouldUnlink = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timeFromPrevious > 0) {
|
|
||||||
delayValue = Math.max(delayValue - timeFromPrevious, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (delayValue === 0) {
|
|
||||||
// we can bail from continuing if there are no further delays to apply
|
|
||||||
break;
|
break;
|
||||||
|
} else if (isOntimeEvent(currentEvent)) {
|
||||||
|
currentEvent.timeStart = Math.max(0, currentEvent.timeStart + delayValue);
|
||||||
|
currentEvent.timeEnd = Math.max(currentEvent.duration, currentEvent.timeEnd + delayValue);
|
||||||
|
if (currentEvent.delay) {
|
||||||
|
currentEvent.delay = currentEvent.delay - delayValue;
|
||||||
}
|
}
|
||||||
|
currentEvent.revision += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// save the current entry before making mutations on its values
|
|
||||||
lastEntry = { ...currentEntry };
|
|
||||||
|
|
||||||
if (shouldUnlink) {
|
|
||||||
currentEntry.linkStart = null;
|
|
||||||
shouldUnlink = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// event times move up by the delay value
|
|
||||||
// we dont update the delay value since we would need to iterate through the entire dataset
|
|
||||||
// this is handled by the rundownCache.generate function
|
|
||||||
currentEntry.timeStart = Math.max(0, currentEntry.timeStart + delayValue);
|
|
||||||
currentEntry.timeEnd = Math.max(currentEntry.duration, currentEntry.timeEnd + delayValue);
|
|
||||||
currentEntry.revision += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return deleteAtIndex(delayIndex, updatedRundown);
|
return deleteAtIndex(delayIndex, updatedRundown);
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ class RuntimeService {
|
|||||||
|
|
||||||
// we need to reload in a few scenarios:
|
// we need to reload in a few scenarios:
|
||||||
// 1. we are not confident that changes do not affect running event (eg. all events where changed)
|
// 1. we are not confident that changes do not affect running event (eg. all events where changed)
|
||||||
const safeOption = affectedIds === undefined;
|
const safeOption = typeof affectedIds === 'undefined';
|
||||||
// 2. the edited event is in memory (now or next) running
|
// 2. the edited event is in memory (now or next) running
|
||||||
// behind conditional to avoid doing unnecessary work
|
// behind conditional to avoid doing unnecessary work
|
||||||
const eventInMemory = safeOption ? false : this.affectsLoaded(affectedIds);
|
const eventInMemory = safeOption ? false : this.affectsLoaded(affectedIds);
|
||||||
|
|||||||
@@ -120,10 +120,8 @@ export function clear() {
|
|||||||
runtimeState.clock = clock.timeNow();
|
runtimeState.clock = clock.timeNow();
|
||||||
runtimeState.timer = { ...initialTimer };
|
runtimeState.timer = { ...initialTimer };
|
||||||
|
|
||||||
// when clearing, we maintain the total delay from the rundown
|
// we maintain the total delay
|
||||||
runtimeState._timer.forceFinish = null;
|
|
||||||
runtimeState._timer.pausedAt = null;
|
runtimeState._timer.pausedAt = null;
|
||||||
runtimeState._timer.secondaryTarget = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -266,9 +264,6 @@ export function loadNext(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// temporarily reset this value to simplify loop logic
|
|
||||||
runtimeState.eventNext = null;
|
|
||||||
|
|
||||||
for (let i = eventIndex + 1; i < timedEvents.length; i++) {
|
for (let i = eventIndex + 1; i < timedEvents.length; i++) {
|
||||||
const event = timedEvents[i];
|
const event = timedEvents[i];
|
||||||
// we dont deal with events that are not playable
|
// we dont deal with events that are not playable
|
||||||
@@ -500,7 +495,7 @@ export function update(): UpdateResult {
|
|||||||
|
|
||||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
// eslint-disable-next-line no-unused-labels -- dev code path
|
||||||
DEV: {
|
DEV: {
|
||||||
if (runtimeState.timer.duration === null) {
|
if (!runtimeState.timer.duration) {
|
||||||
throw new Error('runtimeState.update: invalid state received');
|
throw new Error('runtimeState.update: invalid state received');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -729,37 +729,26 @@ describe('test import of v2 datamodel', () => {
|
|||||||
|
|
||||||
describe('makeString()', () => {
|
describe('makeString()', () => {
|
||||||
it('converts variables to string', () => {
|
it('converts variables to string', () => {
|
||||||
const cases = [
|
let val = 2;
|
||||||
{
|
let expected = '2';
|
||||||
val: 2,
|
let converted = makeString(val);
|
||||||
expected: '2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
val: 2.22222222,
|
|
||||||
expected: '2.22222222',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
val: ['testing'],
|
|
||||||
expected: 'testing',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
val: ' testing ',
|
|
||||||
expected: 'testing',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
val: { doing: 'testing' },
|
|
||||||
expected: 'fallback',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
val: undefined,
|
|
||||||
expected: 'fallback',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
cases.forEach(({ val, expected }) => {
|
|
||||||
const converted = makeString(val, 'fallback');
|
|
||||||
expect(converted).toBe(expected);
|
expect(converted).toBe(expected);
|
||||||
});
|
|
||||||
|
val = 2.22222222;
|
||||||
|
expected = '2.22222222';
|
||||||
|
converted = makeString(val);
|
||||||
|
expect(converted).toBe(expected);
|
||||||
|
|
||||||
|
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
||||||
|
val = ['testing'];
|
||||||
|
expected = 'testing';
|
||||||
|
converted = makeString(val);
|
||||||
|
expect(converted).toBe(expected);
|
||||||
|
|
||||||
|
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
||||||
|
val = { doing: 'testing' };
|
||||||
|
converted = makeString(val, 'fallback');
|
||||||
|
expect(converted).toBe('fallback');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1233,7 +1222,6 @@ describe('parseExcel()', () => {
|
|||||||
expect(result.rundown.length).toBe(1);
|
expect(result.rundown.length).toBe(1);
|
||||||
expect((result.rundown.at(0) as OntimeEvent).title).toBe('A song from the hearth');
|
expect((result.rundown.at(0) as OntimeEvent).title).toBe('A song from the hearth');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('imports blocks', () => {
|
it('imports blocks', () => {
|
||||||
const testdata = [
|
const testdata = [
|
||||||
[
|
[
|
||||||
@@ -1400,126 +1388,6 @@ describe('parseExcel()', () => {
|
|||||||
expect((result.rundown.at(1) as OntimeEvent).timerType).toBe(TimerType.CountDown);
|
expect((result.rundown.at(1) as OntimeEvent).timerType).toBe(TimerType.CountDown);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('imports as events if timer type is empty or has whitespace', () => {
|
|
||||||
const testdata = [
|
|
||||||
[
|
|
||||||
'Time Start',
|
|
||||||
'Time End',
|
|
||||||
'Title',
|
|
||||||
'End Action',
|
|
||||||
'Public',
|
|
||||||
'Skip',
|
|
||||||
'Notes',
|
|
||||||
'test0',
|
|
||||||
'test1',
|
|
||||||
'test2',
|
|
||||||
'test3',
|
|
||||||
'test4',
|
|
||||||
'test5',
|
|
||||||
'test6',
|
|
||||||
'test7',
|
|
||||||
'test8',
|
|
||||||
'test9',
|
|
||||||
'Colour',
|
|
||||||
'cue',
|
|
||||||
'Timer type',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'x',
|
|
||||||
'',
|
|
||||||
'Ballyhoo',
|
|
||||||
'a0',
|
|
||||||
'a1',
|
|
||||||
'a2',
|
|
||||||
'a3',
|
|
||||||
'a4',
|
|
||||||
'a5',
|
|
||||||
'a6',
|
|
||||||
'a7',
|
|
||||||
'a8',
|
|
||||||
'a9',
|
|
||||||
'red',
|
|
||||||
101,
|
|
||||||
' ',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'1899-12-30T08:00:00.000Z',
|
|
||||||
'1899-12-30T08:30:00.000Z',
|
|
||||||
'A song from the hearth',
|
|
||||||
'load-next',
|
|
||||||
'',
|
|
||||||
'x',
|
|
||||||
'Rainbow chase',
|
|
||||||
'b0',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'b5',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'#F00',
|
|
||||||
102,
|
|
||||||
undefined,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'1899-12-30T08:00:00.000Z',
|
|
||||||
'1899-12-30T08:30:00.000Z',
|
|
||||||
'A song from the hearth',
|
|
||||||
'load-next',
|
|
||||||
'',
|
|
||||||
'x',
|
|
||||||
'Rainbow chase',
|
|
||||||
'b0',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'b5',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'#F00',
|
|
||||||
103,
|
|
||||||
' count-up ',
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
];
|
|
||||||
|
|
||||||
const importMap = {
|
|
||||||
worksheet: 'event schedule',
|
|
||||||
timeStart: 'time start',
|
|
||||||
timeEnd: 'time end',
|
|
||||||
duration: 'duration',
|
|
||||||
cue: 'cue',
|
|
||||||
title: 'title',
|
|
||||||
isPublic: 'public',
|
|
||||||
skip: 'skip',
|
|
||||||
note: 'notes',
|
|
||||||
colour: 'colour',
|
|
||||||
endAction: 'end action',
|
|
||||||
timerType: 'timer type',
|
|
||||||
timeWarning: 'warning time',
|
|
||||||
timeDanger: 'danger time',
|
|
||||||
custom: {},
|
|
||||||
};
|
|
||||||
const result = parseExcel(testdata, importMap);
|
|
||||||
expect(result.rundown.length).toBe(3);
|
|
||||||
expect((result.rundown.at(0) as OntimeEvent).type).toBe(SupportedEvent.Event);
|
|
||||||
expect((result.rundown.at(0) as OntimeEvent).timerType).toBe(TimerType.CountDown);
|
|
||||||
expect((result.rundown.at(1) as OntimeEvent).type).toBe(SupportedEvent.Event);
|
|
||||||
expect((result.rundown.at(1) as OntimeEvent).timerType).toBe(TimerType.CountDown);
|
|
||||||
expect((result.rundown.at(2) as OntimeEvent).type).toBe(SupportedEvent.Event);
|
|
||||||
expect((result.rundown.at(2) as OntimeEvent).timerType).toBe(TimerType.CountUp);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('am/pm conversion to 24h', () => {
|
it('am/pm conversion to 24h', () => {
|
||||||
const testData = [
|
const testData = [
|
||||||
['Time Start', 'Time End', 'Title', 'End Action', 'Public', 'Skip', 'Notes', 'Colour', 'cue'],
|
['Time Start', 'Time End', 'Title', 'End Action', 'Public', 'Skip', 'Notes', 'Colour', 'cue'],
|
||||||
|
|||||||
@@ -195,12 +195,11 @@ export const parseExcel = (excelData: unknown[][], options?: Partial<ImportMap>)
|
|||||||
const column = row[j];
|
const column = row[j];
|
||||||
// 1. we check if we have set a flag for a known field
|
// 1. we check if we have set a flag for a known field
|
||||||
if (j === timerTypeIndex) {
|
if (j === timerTypeIndex) {
|
||||||
const maybeTimeType = makeString(column, '');
|
if (column === 'block') {
|
||||||
if (maybeTimeType === 'block') {
|
|
||||||
event.type = SupportedEvent.Block;
|
event.type = SupportedEvent.Block;
|
||||||
} else if (maybeTimeType === '' || isKnownTimerType(maybeTimeType)) {
|
} else if (column === '' || isKnownTimerType(column)) {
|
||||||
event.type = SupportedEvent.Event;
|
event.type = SupportedEvent.Event;
|
||||||
event.timerType = validateTimerType(maybeTimeType);
|
event.timerType = validateTimerType(column);
|
||||||
} else {
|
} else {
|
||||||
// if it is not a block or a known type, we dont import it
|
// if it is not a block or a known type, we dont import it
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { deepmerge } from 'ontime-utils';
|
|||||||
* @returns {string} - value as string or fallback if not possible
|
* @returns {string} - value as string or fallback if not possible
|
||||||
*/
|
*/
|
||||||
export const makeString = (val: unknown, fallback = ''): string => {
|
export const makeString = (val: unknown, fallback = ''): string => {
|
||||||
if (typeof val === 'string') return val.trim();
|
if (typeof val === 'string') return val;
|
||||||
else if (val == null || val.constructor === Object) return fallback;
|
else if (val == null || val.constructor === Object) return fallback;
|
||||||
return val.toString().trim();
|
return val.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ test.describe('test view navigation feature', () => {
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'toggle menu' }).click();
|
await page.getByRole('button', { name: 'toggle menu' }).click();
|
||||||
await page.locator('data-test-id=navigation__menu');
|
await page.locator('data-test-id=navigation__menu');
|
||||||
await page.getByRole('link', { name: 'Minimal Timer' }).click();
|
await page.getByRole('link', { name: 'Clock', exact: true }).click();
|
||||||
await page.locator('data-test-id=minimal-timer');
|
await page.locator('data-test-id=clock-view');
|
||||||
await expect(page).toHaveURL('http://localhost:4001/minimal');
|
await expect(page).toHaveURL('http://localhost:4001/clock');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'toggle menu' }).click();
|
await page.getByRole('button', { name: 'toggle menu' }).click();
|
||||||
await page.locator('data-test-id=navigation__menu');
|
await page.locator('data-test-id=navigation__menu');
|
||||||
await page.getByRole('link', { name: 'Wall Clock', exact: true }).click();
|
await page.getByRole('link', { name: 'Minimal Timer' }).click();
|
||||||
await page.locator('data-test-id=clock-view');
|
await page.locator('data-test-id=minimal-timer');
|
||||||
await expect(page).toHaveURL('http://localhost:4001/clock');
|
await expect(page).toHaveURL('http://localhost:4001/minimal');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'toggle menu' }).click();
|
await page.getByRole('button', { name: 'toggle menu' }).click();
|
||||||
await page.locator('data-test-id=navigation__menu');
|
await page.locator('data-test-id=navigation__menu');
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ test('message control sends messages to screens', async ({ context }) => {
|
|||||||
await editorPage.goto('http://localhost:4001/messagecontrol');
|
await editorPage.goto('http://localhost:4001/messagecontrol');
|
||||||
|
|
||||||
// stage timer message
|
// stage timer message
|
||||||
await editorPage.getByPlaceholder('Message shown fullscreen in stage timer').click();
|
await editorPage.getByPlaceholder('Timer').click();
|
||||||
await editorPage.getByPlaceholder('Message shown fullscreen in stage timer').fill('testing stage');
|
await editorPage.getByPlaceholder('Timer').fill('testing stage');
|
||||||
await editorPage.getByRole('button', { name: /toggle timer message/i }).click({ timeout: 5000 });
|
await editorPage.getByRole('button', { name: /toggle timer/i }).click({ timeout: 5000 });
|
||||||
|
|
||||||
await featurePage.goto('http://localhost:4001/timer');
|
await featurePage.goto('http://localhost:4001/timer');
|
||||||
await featurePage.waitForLoadState('load', { timeout: 5000 });
|
await featurePage.waitForLoadState('load', { timeout: 5000 });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "3.6.0",
|
"version": "3.5.0",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ontime",
|
"ontime",
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
export type TimerMessage = {
|
export type Message = {
|
||||||
text: string;
|
text: string;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimerMessage = Message & {
|
||||||
blink: boolean;
|
blink: boolean;
|
||||||
blackout: boolean;
|
blackout: boolean;
|
||||||
secondarySource: 'aux' | 'external' | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageState = {
|
export type MessageState = {
|
||||||
timer: TimerMessage;
|
timer: TimerMessage;
|
||||||
external: string;
|
external: Message;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export type { RundownCached, NormalisedRundown } from './api/rundown-controller/
|
|||||||
export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
|
export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
|
||||||
export { Playback } from './definitions/runtime/Playback.type.js';
|
export { Playback } from './definitions/runtime/Playback.type.js';
|
||||||
export { TimerLifeCycle } from './definitions/core/TimerLifecycle.type.js';
|
export { TimerLifeCycle } from './definitions/core/TimerLifecycle.type.js';
|
||||||
export type { TimerMessage, MessageState } from './definitions/runtime/MessageControl.type.js';
|
export type { Message, TimerMessage, MessageState } from './definitions/runtime/MessageControl.type.js';
|
||||||
|
|
||||||
export type { Runtime } from './definitions/runtime/Runtime.type.js';
|
export type { Runtime } from './definitions/runtime/Runtime.type.js';
|
||||||
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
|
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
|
||||||
@@ -80,4 +80,4 @@ export {
|
|||||||
isOntimeCycle,
|
isOntimeCycle,
|
||||||
isKeyOfType,
|
isKeyOfType,
|
||||||
} from './utils/guards.js';
|
} from './utils/guards.js';
|
||||||
export type { MaybeNumber, MaybeString } from './utils/utils.type.js';
|
export type { DeepPartial, MaybeNumber, MaybeString } from './utils/utils.type.js';
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
export type MaybeNumber = number | null;
|
export type MaybeNumber = number | null;
|
||||||
export type MaybeString = string | null;
|
export type MaybeString = string | null;
|
||||||
|
|
||||||
|
export type DeepPartial<T> = {
|
||||||
|
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
|
||||||
|
};
|
||||||
|
|||||||
@@ -57,11 +57,4 @@ describe('checkIsNextDay', () => {
|
|||||||
const timeStart = 2 * MILLIS_PER_HOUR;
|
const timeStart = 2 * MILLIS_PER_HOUR;
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
|
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should account for normalised start over multiple days', () => {
|
|
||||||
const previousStart = 90000000; // 25:00:00
|
|
||||||
const previousDuration = 1 * MILLIS_PER_HOUR;
|
|
||||||
const timeStart = 0;
|
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,9 +24,8 @@ export function checkIsNextDay(previousStart: number, timeStart: number, previou
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cappedStart = previousStart % dayInMs;
|
if (timeStart <= previousStart) {
|
||||||
if (timeStart <= cappedStart) {
|
const normalisedPreviousEnd = previousStart + previousDuration;
|
||||||
const normalisedPreviousEnd = cappedStart + previousDuration;
|
|
||||||
if (normalisedPreviousEnd === dayInMs) {
|
if (normalisedPreviousEnd === dayInMs) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||