Compare commits

..

35 Commits

Author SHA1 Message Date
Carlos Valente f554605007 wip: poc for playing a sound on time end 2024-10-07 20:46:36 +02:00
Alex Christoffer Rasmussen 86e6f8b58c bump version to 3.6.1 (#1237) 2024-10-03 14:50:05 +02:00
Carlos Valente 1de062a38e refactor: remove alt shortcut 2024-10-03 08:35:56 +02:00
Carlos Valente eb7a30fcf3 refactor: improve resizing of column 2024-10-02 22:34:19 +02:00
Carlos Valente e2cbe43a2b fix: form element alignment in form 2024-10-02 21:57:52 +02:00
Carlos Valente 7877af571f fix: apply delay 2024-10-02 21:44:03 +02:00
Carlos Valente 086bd5b644 docs: add discord link 2024-10-01 08:18:19 +02:00
Carlos Valente f045cf2292 bump version to 3.6.0 2024-09-28 20:41:21 +02:00
Alex Christoffer Rasmussen 78e2f217a4 handle timer type whitespace (#1225) 2024-09-28 20:34:04 +02:00
Carlos Valente 8a5cef79a8 refactor: simplify secondary styles 2024-09-27 20:14:58 +02:00
Carlos Valente 4d8bea2940 feat: timer preview 2024-09-27 20:14:58 +02:00
Carlos Valente fa709dc9be fix: issue where event next was stale 2024-09-24 22:25:45 +02:00
Carlos Valente 177c9a35ec feat: electron links open in browser 2024-09-24 13:46:09 +02:00
Carlos Valente 7f7691a452 fix: stop integration if output disabled 2024-09-23 19:39:19 +02:00
Carlos Valente 92a16ea33b refactor: migrate fit text logic 2024-09-23 16:56:15 +02:00
Carlos Valente bc6b7c5596 refactor: fit user message in screen 2024-09-23 16:56:15 +02:00
Carlos Valente 66083813f9 refactor: allow local shutdown 2024-09-23 16:55:22 +02:00
Carlos Valente d8cf1abb37 docs: warn of shutdown conditions 2024-09-23 16:55:22 +02:00
Carlos Valente 5b471980f5 refactor: enable timer type and end action default settings 2024-09-09 10:15:55 +02:00
Carlos Valente ad8f6dfcc7 bump version to 3.5.1 2024-09-07 08:49:39 +02:00
Carlos Valente 4706ed39f6 refactor: scope rundown to selected 2024-09-07 08:46:32 +02:00
Carlos Valente a8182cac8c refactor: simplify logic 2024-09-07 08:46:32 +02:00
Carlos Valente f9a5c55c07 refactor: reactive options 2024-09-07 08:46:32 +02:00
Carlos Valente 980ae7a7c5 refactor: show upcoming events 2024-09-07 08:46:32 +02:00
Carlos Valente db8ed93d41 fix: handle multiple days 2024-09-07 08:46:32 +02:00
Carlos Valente f8ca5b9cef fix: clear internal state on stop 2024-09-07 08:45:43 +02:00
Carlos Valente b9db08089f fix: ignore 0 duration events 2024-09-07 08:45:43 +02:00
Carlos Valente a3020ef3b2 fix: issue with roll evaluating multiple days 2024-09-07 08:45:43 +02:00
Carlos Valente 2492972097 fix: incorrect falsy check 2024-09-04 09:28:15 +02:00
Alex Christoffer Rasmussen aa4ee546ec Merge import (#1164)
---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-09-01 20:38:59 +02:00
Carlos Valente 0abaf7724d refactor: refetch on change 2024-09-01 17:18:48 +02:00
Carlos Valente 7b932ef0f7 refactor: add options to hide schedule 2024-09-01 10:41:54 +02:00
Carlos Valente 8bd7645c95 ui: rename mode to match UI 2024-08-31 10:55:32 +02:00
Carlos Valente 81f0e61953 refactor: add extra data to sentry logs 2024-08-29 15:10:42 +02:00
Carlos Valente 3877e207d0 chore: remove incorrect label 2024-08-27 14:21:35 +02:00
90 changed files with 1914 additions and 1044 deletions
+19 -18
View File
@@ -14,8 +14,10 @@
- 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>
## Using Ontime? ## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
## Using Ontime?
Let us know! 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.
@@ -35,6 +37,16 @@ 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. \
@@ -83,6 +95,7 @@ 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
``` ```
``` ```
@@ -100,26 +113,13 @@ 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 documentation is available [in our docs](https://docs.getontime.no) More information 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 tweaking features in collaboration with users. Ontime is under active development. We continue adding and improving features in collaboration with users.
Have an idea? Reach out via [email](mail@getontime.no) 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,8 +161,9 @@ If you are a developer and would like to contribute with code, please open an is
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md) Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
## Links ## Links
- [Ontime website](https://getontime.no)
See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no) - [Documentation](https://docs.getontime.no)
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
## License ## License
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.5.0", "version": "3.6.1",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.5.0", "version": "3.6.1",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
Binary file not shown.
+1
View File
@@ -18,6 +18,7 @@ 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 -1
View File
@@ -11,7 +11,7 @@ const dbPath = `${apiEntryUrl}/db`;
/** /**
* HTTP request to the current DB * HTTP request to the current DB
*/ */
async function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> { export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download/`, { filename }); return axios.post(`${dbPath}/download/`, { filename });
} }
@@ -28,9 +28,11 @@ class ErrorBoundary extends React.Component {
}); });
Sentry.withScope((scope) => { Sentry.withScope((scope) => {
scope.setExtras('error', error); scope.setExtras({
scope.setExtras('store', runtimeStore.getState()); error,
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts }); store: runtimeStore.getState(),
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
});
const eventId = Sentry.captureException(error); const eventId = Sentry.captureException(error);
this.setState({ eventId, info }); this.setState({ eventId, info });
}); });
@@ -0,0 +1,63 @@
/**
* Copied from
* https://github.com/namhong2001/react-textfit/blob/master/index.tsx
*/
import { HTMLAttributes, PropsWithChildren, useCallback, useEffect, useRef } from 'react';
import { bsearch } from './fitText.utils';
interface FitTextProps extends HTMLAttributes<HTMLDivElement> {
mode?: 'single' | 'multi';
min?: number; // inclusive
max?: number; // inclusive
}
export function FitText(props: PropsWithChildren<FitTextProps>) {
const { children, mode = 'multi', min = 16, max = 256, ...elementProps } = props;
const ref = useRef<HTMLDivElement>(null);
const isOverflown = useCallback(() => {
const el = ref.current;
if (!el) return false;
return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
}, []);
const setFontSize = useCallback(() => {
const el = ref.current;
if (!el) return;
const originVisibility = el.style.visibility;
el.style.visibility = 'hidden';
const fontSize = bsearch(min, max + 1, (mid) => {
el.style.fontSize = `${mid}px`;
return !isOverflown();
});
el.style.fontSize = `${fontSize}px`;
el.style.visibility = originVisibility;
}, [isOverflown, min, max]);
useEffect(() => {
const el = ref.current;
if (!el) return;
setFontSize();
const observer = new ResizeObserver(setFontSize);
observer.observe(el);
return () => observer.disconnect();
}, [children, mode, setFontSize]);
return (
<div
ref={ref}
style={{
whiteSpace: mode === 'single' ? 'nowrap' : 'normal',
}}
{...elementProps}
>
{children}
</div>
);
}
@@ -0,0 +1,18 @@
/**
* @param low inclusive, must be true on predicate function
* @param high exclusive,
* @param predicate predicate function
*/
export const bsearch = (low: number, high: number, predicate: (mid: number) => boolean): number => {
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (mid === low) break;
if (predicate(mid)) {
low = mid;
} else {
high = mid;
}
}
return low;
};
@@ -47,6 +47,8 @@ $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, useRef } from 'react'; import { memo, PropsWithChildren, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Link } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { import {
Drawer, Drawer,
DrawerBody, DrawerBody,
@@ -19,9 +19,12 @@ 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';
@@ -40,6 +43,7 @@ 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);
@@ -96,38 +100,29 @@ 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>
<Link <ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
to='/cuesheet'
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
tabIndex={0}
>
<IoLockClosedOutline /> <IoLockClosedOutline />
Cuesheet Cuesheet
<IoArrowUp className={style.linkIcon} /> <IoArrowUp className={style.linkIcon} />
</Link> </ClientLink>
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}> <ClientLink to='op' current={location.pathname === '/op'}>
<IoLockClosedOutline /> <IoLockClosedOutline />
Operator Operator
<IoArrowUp className={style.linkIcon} /> <IoArrowUp className={style.linkIcon} />
</Link> </ClientLink>
<hr className={style.separator} /> <hr className={style.separator} />
{navigatorConstants.map((route) => ( {navigatorConstants.map((route) => (
<Link <ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
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} />
</Link> </ClientLink>
))} ))}
</DrawerBody> </DrawerBody>
</DrawerContent> </DrawerContent>
@@ -137,4 +132,30 @@ function NavigationMenu(props: NavigationMenuProps) {
); );
} }
interface ClientLinkProps {
current: boolean;
to: string;
}
function ClientLink(props: PropsWithChildren<ClientLinkProps>) {
const { current, to, children } = props;
const { isElectron } = useElectronEvent();
const classes = cx([style.link, current && style.current]);
if (isElectron) {
return (
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
{children}
</button>
);
}
return (
<Link to={`/${to}`} className={classes} tabIndex={0}>
{children}
</Link>
);
}
export default memo(NavigationMenu); export default memo(NavigationMenu);
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { CustomFields } from 'ontime-types'; import { CustomFields } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CUSTOM_FIELDS } from '../api/constants'; import { CUSTOM_FIELDS } from '../api/constants';
import { getCustomFields } from '../api/customFields'; import { getCustomFields } from '../api/customFields';
@@ -14,7 +14,7 @@ export default function useCustomFields() {
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always', networkMode: 'always',
}); });
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types'; import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { fetchNormalisedRundown } from '../api/rundown'; import { fetchNormalisedRundown } from '../api/rundown';
@@ -16,7 +16,7 @@ export default function useRundown() {
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always', networkMode: 'always',
}); });
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
+34 -6
View File
@@ -9,6 +9,8 @@ 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';
@@ -32,7 +34,15 @@ import { useEditorSettings } from '../stores/editorSettings';
*/ */
export const useEventAction = () => { export const useEventAction = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { defaultPublic, linkPrevious, defaultDuration, defaultWarnTime, defaultDangerTime } = useEditorSettings(); const {
defaultPublic,
linkPrevious,
defaultDuration,
defaultWarnTime,
defaultDangerTime,
defaultTimerType,
defaultEndAction,
} = useEditorSettings();
/** /**
* Calls mutation to add new event * Calls mutation to add new event
@@ -46,17 +56,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;
}>; }>;
/** /**
@@ -68,13 +78,12 @@ 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) {
@@ -89,6 +98,7 @@ export const useEventAction = () => {
} }
} }
// Override event with options from editor settings
if (applicationOptions.defaultPublic) { if (applicationOptions.defaultPublic) {
newEvent.isPublic = true; newEvent.isPublic = true;
} }
@@ -104,6 +114,14 @@ 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
@@ -117,7 +135,17 @@ 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,
],
); );
/** /**
-159
View File
@@ -1,159 +0,0 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
export type TLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none';
export type TOptions = {
logLevel?: TLogLevel;
maxFontSize?: number;
minFontSize?: number;
onFinish?: (fontSize: number) => void;
onStart?: () => void;
resolution?: number;
};
const LOG_LEVEL: Record<TLogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
none: 100,
};
const useFitText = ({
logLevel: logLevelOption = 'info',
maxFontSize = 100,
minFontSize = 20,
onFinish,
onStart,
resolution = 5,
}: TOptions = {}) => {
const logLevel = LOG_LEVEL[logLevelOption];
const initState = useCallback(() => {
return {
calcKey: 0,
fontSize: maxFontSize,
fontSizePrev: minFontSize,
fontSizeMax: maxFontSize,
fontSizeMin: minFontSize,
};
}, [maxFontSize, minFontSize]);
const ref = useRef<HTMLDivElement>(null);
const innerHtmlPrevRef = useRef<string | null>();
const isCalculatingRef = useRef(false);
const [state, setState] = useState(initState);
const { calcKey, fontSize, fontSizeMax, fontSizeMin, fontSizePrev } = state;
// Monitor div size changes and recalculate on resize
let animationFrameId: number | null = null;
const [ro] = useState(
() =>
new ResizeObserver(() => {
animationFrameId = window.requestAnimationFrame(() => {
if (isCalculatingRef.current) {
return;
}
onStart && onStart();
isCalculatingRef.current = true;
// `calcKey` is used in the dependencies array of
// `useIsoLayoutEffect` below. It is incremented so that the font size
// will be recalculated even if the previous state didn't change (e.g.
// when the text fit initially).
setState({
...initState(),
calcKey: calcKey + 1,
});
});
}),
);
useEffect(() => {
if (ref.current) {
ro.observe(ref.current);
}
return () => {
animationFrameId && window.cancelAnimationFrame(animationFrameId);
ro.disconnect();
};
}, [animationFrameId, ro]);
// Recalculate when the div contents change
const innerHtml = ref.current && ref.current.innerHTML;
useEffect(() => {
if (calcKey === 0 || isCalculatingRef.current) {
return;
}
if (innerHtml !== innerHtmlPrevRef.current) {
onStart && onStart();
setState({
...initState(),
calcKey: calcKey + 1,
});
}
innerHtmlPrevRef.current = innerHtml;
}, [calcKey, initState, innerHtml, onStart]);
// Check overflow and resize font
useLayoutEffect(() => {
// Don't start calculating font size until the `resizeKey` is incremented
// above in the `ResizeObserver` callback. This avoids an extra resize
// on initialization.
if (calcKey === 0) {
return;
}
const isWithinResolution = Math.abs(fontSize - fontSizePrev) <= resolution;
const isOverflow =
!!ref.current &&
(ref.current.scrollHeight > ref.current.offsetHeight || ref.current.scrollWidth > ref.current.offsetWidth);
const isFailed = isOverflow && fontSize === fontSizePrev;
const isAsc = fontSize > fontSizePrev;
// Return if the font size has been adjusted "enough" (change within `resolution`)
// reduce font size by one increment if it's overflowing.
if (isWithinResolution) {
if (isFailed) {
isCalculatingRef.current = false;
if (logLevel <= LOG_LEVEL.info) {
console.info(`[use-fit-text] reached \`minFontSize = ${minFontSize}\` without fitting text`);
}
} else if (isOverflow) {
setState({
fontSize: isAsc ? fontSizePrev : fontSizeMin,
fontSizeMax,
fontSizeMin,
fontSizePrev,
calcKey,
});
} else {
isCalculatingRef.current = false;
onFinish && onFinish(fontSize);
}
return;
}
// Binary search to adjust font size
let delta: number;
let newMax = fontSizeMax;
let newMin = fontSizeMin;
if (isOverflow) {
delta = isAsc ? fontSizePrev - fontSize : fontSizeMin - fontSize;
newMax = Math.min(fontSizeMax, fontSize);
} else {
delta = isAsc ? fontSizeMax - fontSize : fontSizePrev - fontSize;
newMin = Math.max(fontSizeMin, fontSize);
}
setState({
calcKey,
fontSize: fontSize + delta / 2,
fontSizeMax: newMax,
fontSizeMin: newMin,
fontSizePrev: fontSize,
});
}, [calcKey, fontSize, fontSizeMax, fontSizeMin, fontSizePrev, onFinish, ref, resolution]);
return { fontSize: `${fontSize}%`, ref };
};
export default useFitText;
+48 -14
View File
@@ -1,4 +1,4 @@
import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types'; import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime'; import { useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
@@ -28,11 +28,43 @@ export const useOperator = () => {
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
export const useMessageControl = () => { export const useTimerViewControl = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
timer: state.message.timer, blackout: state.message.timer.blackout,
external: state.message.external, blink: state.message.timer.blink,
onAir: state.onAir, secondarySource: state.message.timer.secondarySource,
});
return useRuntimeStore(featureSelector);
};
export const useTimerMessageInput = () => {
const featureSelector = (state: RuntimeStore) => ({
text: state.message.timer.text,
visible: state.message.timer.visible,
});
return useRuntimeStore(featureSelector);
};
export const useExternalMessageInput = () => {
const featureSelector = (state: RuntimeStore) => ({
text: state.message.external,
visible: state.message.timer.secondarySource === 'external',
});
return useRuntimeStore(featureSelector);
};
export const useMessagePreview = () => {
const featureSelector = (state: RuntimeStore) => ({
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
phase: state.timer.phase,
showAuxTimer: state.message.timer.secondarySource === 'aux',
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null,
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
@@ -41,8 +73,11 @@ export const useMessageControl = () => {
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 = () => {
@@ -141,6 +176,14 @@ export const useTimer = () => {
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
export const useTimerPhase = () => {
const featureSelector = (state: RuntimeStore) => ({
phase: state.timer.phase,
});
return useRuntimeStore(featureSelector);
};
export const useClock = () => { export const useClock = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
clock: state.clock, clock: state.clock,
@@ -187,15 +230,6 @@ 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,3 +1,5 @@
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';
@@ -8,11 +10,15 @@ 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 = {
@@ -21,6 +27,8 @@ 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 {
@@ -29,6 +37,8 @@ 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) => {
@@ -38,6 +48,14 @@ 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(() => {
@@ -65,5 +83,15 @@ 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 };
}),
}; };
}); });
+2 -4
View File
@@ -23,11 +23,9 @@ 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,
+13 -2
View File
@@ -1,6 +1,6 @@
import { Log, RuntimeStore } from 'ontime-types'; import { Log, RundownCached, RuntimeStore } from 'ontime-types';
import { CLIENT_LIST, isProduction, RUNTIME, websocketUrl } from '../api/constants'; import { CLIENT_LIST, CUSTOM_FIELDS, isProduction, RUNDOWN, RUNTIME, websocketUrl } from '../api/constants';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
import { import {
getClientId, getClientId,
@@ -176,6 +176,17 @@ export const connectSocket = () => {
updateDevTools({ auxtimer1: payload }); updateDevTools({ auxtimer1: payload });
break; break;
} }
case 'ontime-refetch': {
// the refetch message signals that the rundown has changed in the server side
const { revision } = payload;
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
}
break;
}
} }
} catch (_) { } catch (_) {
// ignore unhandled // ignore unhandled
@@ -147,7 +147,7 @@ export default function ViewSettingsForm() {
variant='ontime-filled' variant='ontime-filled'
maxLength={150} maxLength={150}
width='275px' width='275px'
placeholder='Message shown when timer reaches end' placeholder='Shown when timer reaches end'
{...register('endMessage')} {...register('endMessage')}
/> />
</Panel.ListItem> </Panel.ListItem>
@@ -7,17 +7,26 @@ 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 eventSettings = useEditorSettings((state) => state); const {
defaultDuration,
linkPrevious,
defaultWarnTime,
defaultDangerTime,
defaultPublic,
defaultTimerType,
defaultEndAction,
setDefaultDuration,
setLinkPrevious,
setWarnTime,
setDangerTime,
setDefaultPublic,
setDefaultTimerType,
setDefaultEndAction,
} = useEditorSettings((state) => state);
const setDefaultDuration = eventSettings.setDefaultDuration; const durationInMs = parseUserTime(defaultDuration);
const setLinkPrevious = eventSettings.setLinkPrevious; const warnTimeInMs = parseUserTime(defaultWarnTime);
const setWarnTime = eventSettings.setWarnTime; const dangerTimeInMs = parseUserTime(defaultDangerTime);
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>
@@ -44,13 +53,19 @@ export default function EditorSettingsForm() {
<Switch <Switch
variant='ontime' variant='ontime'
size='lg' size='lg'
defaultChecked={eventSettings.linkPrevious} defaultChecked={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 variant='ontime' size='sm' width='auto' isDisabled> <Select
variant='ontime'
size='sm'
width='auto'
value={defaultTimerType}
onChange={(event) => setDefaultTimerType(event.target.value as TimerType)}
>
<option value={TimerType.CountDown}>Count down</option> <option value={TimerType.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>
@@ -59,7 +74,13 @@ 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 variant='ontime' size='sm' width='auto' isDisabled> <Select
variant='ontime'
size='sm'
width='auto'
value={defaultEndAction}
onChange={(event) => setDefaultEndAction(event.target.value as EndAction)}
>
<option value={EndAction.None}>None</option> <option value={EndAction.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>
@@ -93,14 +114,14 @@ export default function EditorSettingsForm() {
<Switch <Switch
variant='ontime' variant='ontime'
size='lg' size='lg'
defaultChecked={eventSettings.defaultPublic} defaultChecked={defaultPublic}
onChange={(event) => setDefaultPublic(event.target.checked)} onChange={(event) => setDefaultPublic(event.target.checked)}
/> />
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
</Panel.Section> </Panel.Section>
<Panel.Section> <Panel.Section>
<Panel.Title>Play mode</Panel.Title> <Panel.Title>Run mode</Panel.Title>
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
@@ -42,7 +42,7 @@ export default function ManageProjects() {
const errorMessage = maybeAxiosError(error); const errorMessage = maybeAxiosError(error);
setError(`Error uploading file: ${errorMessage}`); setError(`Error uploading file: ${errorMessage}`);
} finally { } finally {
invalidateAllCaches(); await invalidateAllCaches();
} }
setLoading(null); setLoading(null);
@@ -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>}
<div className={style.innerColumn}> <Panel.Section 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>
</div> </Panel.Section>
</Panel.Section> </Panel.Section>
); );
} }
@@ -9,7 +9,7 @@ export type ProjectFormValues = {
}; };
interface ProjectFormProps { interface ProjectFormProps {
action: 'duplicate' | 'rename'; action: 'duplicate' | 'rename' | 'merge';
filename: string; filename: string;
onCancel: () => void; onCancel: () => void;
onSubmit: (values: ProjectFormValues) => Promise<void>; onSubmit: (values: ProjectFormValues) => Promise<void>;
@@ -11,13 +11,15 @@ import {
renameProject, renameProject,
} from '../../../../common/api/db'; } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils'; import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../PanelUtils'; import * as Panel from '../PanelUtils';
import ProjectForm, { ProjectFormValues } from './ProjectForm'; import ProjectForm, { ProjectFormValues } from './ProjectForm';
import ProjectMergeForm from './ProjectMergeForm';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
export type EditMode = 'rename' | 'duplicate' | null; export type EditMode = 'rename' | 'duplicate' | 'merge' | null;
interface ProjectListItemProps { interface ProjectListItemProps {
current?: boolean; current?: boolean;
@@ -100,8 +102,10 @@ export default function ProjectListItem({
handleToggleEditMode(null, null); handleToggleEditMode(null, null);
}; };
const isCurrentlyBeingEdited = editingMode && filename === editingFilename; const isCurrentlyBeingEdited = filename === editingFilename;
const classes = current && !isCurrentlyBeingEdited ? style.current : undefined; const showProjectForm = (editingMode === 'rename' || editingMode === 'duplicate') && filename === editingFilename;
const showMergeForm = editingMode === 'merge' && isCurrentlyBeingEdited;
const classes = cx([current && !isCurrentlyBeingEdited && style.current, isCurrentlyBeingEdited && style.isEditing]);
return ( return (
<> <>
@@ -113,7 +117,7 @@ export default function ProjectListItem({
</tr> </tr>
)} )}
<tr key={filename} className={classes}> <tr key={filename} className={classes}>
{isCurrentlyBeingEdited ? ( {showProjectForm ? (
<td colSpan={99}> <td colSpan={99}>
<ProjectForm <ProjectForm
action={editingMode} action={editingMode}
@@ -125,7 +129,7 @@ export default function ProjectListItem({
) : ( ) : (
<> <>
<td className={style.containCell}>{filename}</td> <td className={style.containCell}>{filename}</td>
<td>{new Date(updatedAt).toLocaleString()}</td> <td>{current ? 'Currently loaded' : new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}> <td className={style.actionButton}>
<ActionMenu <ActionMenu
current={current} current={current}
@@ -133,12 +137,20 @@ export default function ProjectListItem({
onChangeEditMode={handleToggleEditMode} onChangeEditMode={handleToggleEditMode}
onDelete={handleDelete} onDelete={handleDelete}
onLoad={handleLoad} onLoad={handleLoad}
isDisabled={loading} isDisabled={loading || showMergeForm}
onMerge={(filename) => handleToggleEditMode('merge', filename)}
/> />
</td> </td>
</> </>
)} )}
</tr> </tr>
{showMergeForm && (
<tr>
<td colSpan={99}>
<ProjectMergeForm onClose={handleCancel} fileName={filename} />
</td>
</tr>
)}
</> </>
); );
} }
@@ -148,11 +160,12 @@ interface ActionMenuProps {
filename: string; filename: string;
isDisabled: boolean; isDisabled: boolean;
onChangeEditMode: (editMode: EditMode, filename: string) => void; onChangeEditMode: (editMode: EditMode, filename: string) => void;
onDelete: (filename: string) => void; onDelete: (filename: string) => Promise<void>;
onLoad: (filename: string) => void; onLoad: (filename: string) => Promise<void>;
onMerge: (filename: string) => void;
} }
function ActionMenu(props: ActionMenuProps) { function ActionMenu(props: ActionMenuProps) {
const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad } = props; const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad, onMerge } = props;
const handleRename = () => { const handleRename = () => {
onChangeEditMode('rename', filename); onChangeEditMode('rename', filename);
@@ -185,6 +198,9 @@ function ActionMenu(props: ActionMenuProps) {
<MenuItem onClick={() => onLoad(filename)} isDisabled={current}> <MenuItem onClick={() => onLoad(filename)} isDisabled={current}>
Load Load
</MenuItem> </MenuItem>
<MenuItem onClick={() => onMerge(filename)} isDisabled={current}>
Partial Load
</MenuItem>
<MenuItem onClick={handleRename}>Rename</MenuItem> <MenuItem onClick={handleRename}>Rename</MenuItem>
<MenuItem onClick={handleDuplicate}>Duplicate</MenuItem> <MenuItem onClick={handleDuplicate}>Duplicate</MenuItem>
<MenuItem onClick={handleDownload}>Download</MenuItem> <MenuItem onClick={handleDownload}>Download</MenuItem>
@@ -0,0 +1,128 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Switch } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_DATA } from '../../../../common/api/constants';
import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../PanelUtils';
import { makeProjectPatch } from './project.utils';
import style from './ProjectPanel.module.scss';
interface ProjectMergeFromProps {
onClose: () => void;
fileName: string;
}
type ProjectMergeFormValues = {
project: boolean;
rundown: boolean;
viewSettings: boolean;
urlPresets: boolean;
osc: boolean;
http: boolean;
};
export default function ProjectMergeForm(props: ProjectMergeFromProps) {
const { onClose, fileName } = props;
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
const {
handleSubmit,
register,
formState: { isSubmitting, isValid, isDirty },
} = useForm<ProjectMergeFormValues>({
defaultValues: {
project: false,
rundown: false,
viewSettings: false,
urlPresets: false,
osc: false,
http: false,
},
resetOptions: {
keepDirtyValues: true,
},
});
const handleSubmitCreate = async (values: ProjectMergeFormValues) => {
const allFalse = Object.values(values).every((value) => !value);
if (allFalse) {
setError('At least one option must be selected');
return;
}
try {
setError(null);
// make patch object
const { data } = await getDb(fileName);
const patch = await makeProjectPatch(data, values);
// request patch
await patchData(patch);
await queryClient.invalidateQueries({ queryKey: PROJECT_DATA });
onClose();
} catch (error) {
setError(maybeAxiosError(error));
}
};
return (
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
<Panel.Title>
Merge {`"${fileName}"`}
<div className={style.createActionButtons}>
<Button onClick={onClose} variant='ontime-ghosted' size='sm' isDisabled={isSubmitting}>
Cancel
</Button>
<Button
isDisabled={!isValid || !isDirty}
type='submit'
isLoading={isSubmitting}
variant='ontime-filled'
size='sm'
>
Merge
</Button>
</div>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
<Panel.Description>
Select partial data from {`"${fileName}"`} to merge into the current project.
<br /> This process is irreversible.
</Panel.Description>
<label>
<Switch variant='ontime' {...register('project')} />
Project data
</label>
<label>
<Switch variant='ontime' {...register('rundown')} />
Rundown + Custom Fields
</label>
<label>
<Switch variant='ontime' {...register('viewSettings')} />
View Settings
</label>
<label>
<Switch variant='ontime' {...register('urlPresets')} />
URL Presets
</label>
<label>
<Switch variant='ontime' {...register('osc')} />
OSC Integration
</label>
<label>
<Switch variant='ontime' {...register('http')} />
HTTP Integration
</label>
</Panel.Section>
</Panel.Section>
);
}
@@ -2,6 +2,10 @@
background-color: $blue-1100; background-color: $blue-1100;
} }
.isEditing {
color: $blue-500;
}
.actionButton { .actionButton {
flex: 1; flex: 1;
text-align: right; text-align: right;
@@ -50,3 +54,10 @@
flex-direction: column; flex-direction: column;
gap: 1em; gap: 1em;
} }
.inlineLabels {
label {
display: flex;
gap: 1rem;
}
}
@@ -0,0 +1,17 @@
import { DatabaseModel, isKeyOfType } from 'ontime-types';
export async function makeProjectPatch(data: DatabaseModel, mergeKeys: Record<string, boolean>) {
const patchObject: Partial<DatabaseModel> = {};
for (const key in mergeKeys) {
if (isKeyOfType(key, data) && mergeKeys[key]) {
// if the rundown is merged we also need the custom fields
if (key === 'rundown') {
patchObject.customFields = data['customFields'];
}
Object.assign(patchObject, { [key]: data[key] });
}
}
return patchObject;
}
@@ -10,6 +10,7 @@ import {
useDisclosure, 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';
@@ -31,9 +32,10 @@ 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}> <Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!(isElectron || isLocalhost)}>
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,10 +1,9 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { IconButton, Input } from '@chakra-ui/react'; import { Input } from '@chakra-ui/react';
import { IoEye } from '@react-icons/all-files/io5/IoEye'; import { 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';
@@ -13,15 +12,13 @@ interface InputRowProps {
label: string; label: string;
placeholder: string; placeholder: string;
text: string; text: string;
visible?: boolean; visible: boolean;
readonly?: boolean; actionHandler: () => void;
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, className, readonly } = props; const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const cursorPositionRef = useRef(0); const cursorPositionRef = useRef(0);
@@ -39,41 +36,27 @@ export default function InputRow(props: InputRowProps) {
changeHandler(event.target.value); changeHandler(event.target.value);
}; };
const classes = cx([style.inputRow, className]);
return ( return (
<div className={classes}> <div className={style.inputRow}>
<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 ? ( <TooltipActionBtn
<IconButton clickHandler={actionHandler}
size='sm' tooltip={visible ? 'Make invisible' : 'Make visible'}
isDisabled aria-label={`Toggle ${label}`}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />} openDelay={tooltipDelayMid}
aria-label={`Toggle ${label}`} icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'} variant={visible ? 'ontime-filled' : 'ontime-subtle'}
/> size='sm'
) : ( />
<TooltipActionBtn
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label={`Toggle ${label}`}
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
)}
</div> </div>
</div> </div>
); );
@@ -1,23 +1,3 @@
.messageContainer {
display: flex;
flex-direction: column;
gap: $section-spacing;
}
.buttonSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $element-spacing;
margin-top: -0.5rem;
}
.singleAction {
display: flex;
flex-direction: column;
gap: $element-spacing;
margin-top: $element-inner-spacing;
}
.label { .label {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
color: $label-gray; color: $label-gray;
@@ -26,3 +6,89 @@
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,64 +1,52 @@
import { Button } from '@chakra-ui/react'; import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
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() {
const message = useMessageControl();
const blink = message.timer.blink;
const blackout = message.timer.blackout;
return ( return (
<div className={style.messageContainer}> <>
<InputRow <TimerControlsPreview />
label='Timer' <TimerMessageInput />
placeholder='Message shown in stage timer' <ExternalInput />
text={message.timer.text} </>
visible={message.timer.visible} );
changeHandler={(newValue) => setMessage.timerText(newValue)} }
actionHandler={() => setMessage.timerVisible(!message.timer.visible)}
/> function TimerMessageInput() {
<div className={style.buttonSection}> const { text, visible } = useTimerMessageInput();
<Button
size='sm' return (
className={`${blink ? style.blink : ''}`} <InputRow
variant={blink ? 'ontime-filled' : 'ontime-subtle'} label='Timer Message'
leftIcon={blink ? <IoSunny size='1rem' /> : <IoSunnyOutline size='1rem' />} placeholder='Message shown fullscreen in stage timer'
onClick={() => setMessage.timerBlink(!blink)} text={text}
data-testid='toggle timer blink' visible={visible}
> changeHandler={(newValue) => setMessage.timerText(newValue)}
Blink actionHandler={() => setMessage.timerVisible(!visible)}
</Button> />
<Button );
size='sm' }
className={style.blackoutButton}
variant={blackout ? 'ontime-filled' : 'ontime-subtle'} function ExternalInput() {
leftIcon={blackout ? <IoEye size='1rem' /> : <IoEyeOffOutline size='1rem' />} const { text, visible } = useExternalMessageInput();
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout' const toggleExternal = () => {
> if (visible) {
Blackout screen setMessage.timerSecondary(null);
</Button> } else {
</div> setMessage.timerSecondary('external');
<InputRow }
label='External Message (read only)' };
placeholder={enDash}
readonly return (
text={message.external.text} <InputRow
visible={message.external.visible} label='External Message'
changeHandler={noop} placeholder='Message shown as secondary text in stage timer'
actionHandler={noop} text={text}
/> visible={visible}
</div> changeHandler={(newValue) => setMessage.externalText(newValue)}
actionHandler={toggleExternal}
/>
); );
} }
@@ -3,16 +3,19 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary'; import 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={style.content}> <div className={classes}>
<ErrorBoundary> <ErrorBoundary>
<MessageControl /> <MessageControl />
</ErrorBoundary> </ErrorBoundary>
@@ -0,0 +1,78 @@
import { Tooltip } from '@chakra-ui/react';
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoFlag } from '@react-icons/all-files/io5/IoFlag';
import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { TimerPhase, TimerType } from 'ontime-types';
import { useMessagePreview } from '../../../common/hooks/useSocket';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './MessageControl.module.scss';
export default function TimerPreview() {
const { blink, blackout, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } =
useMessagePreview();
const { data } = useViewSettings();
const contentClasses = cx([style.previewContent, blink && style.blink, blackout && style.blackout]);
const main = (() => {
if (showTimerMessage) return 'Message';
if (phase === TimerPhase.Pending) return 'Standby to start';
if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message';
return 'Timer';
})();
const secondary = (() => {
// message is a fullscreen overlay
if (showTimerMessage) return null;
// we need to check aux first since it takes priority
if (showAuxTimer) return 'Aux Timer';
if (showExternalMessage) return 'External message';
return null;
})();
const overrideColour = (() => {
// override fallback colours from starter project
if (phase === TimerPhase.Warning) return data.warningColor ?? '#FFAB33';
if (phase === TimerPhase.Danger) return data.dangerColor ?? '#ED3333';
return data.normalColor ?? '#FFFC';
})();
const showColourOverride = main == 'Timer';
return (
<div className={style.preview}>
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'timer')} />
<div className={contentClasses}>
<div
className={style.mainContent}
data-phase={phase}
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
>
{main}
</div>
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
</div>
<div className={style.eventStatus}>
<Tooltip label='Time type: Count down' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoArrowDown className={style.statusIcon} data-active={timerType === TimerType.CountDown} />
</Tooltip>
<Tooltip label='Time type: Count up' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoArrowUp className={style.statusIcon} data-active={timerType === TimerType.CountUp} />
</Tooltip>
<Tooltip label='Time type: Clock' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoTime className={style.statusIcon} data-active={timerType === TimerType.Clock} />
</Tooltip>
<Tooltip label='Time type: Time to end' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoFlag className={style.statusIcon} data-active={timerType === TimerType.TimeToEnd} />
</Tooltip>
</div>
</div>
);
}
@@ -0,0 +1,62 @@
import { Button } from '@chakra-ui/react';
import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket';
import TimerPreview from './TimerPreview';
import style from './MessageControl.module.scss';
export default function TimerControlsPreview() {
const { blackout, blink, secondarySource } = useTimerViewControl();
const toggleSecondary = (newValue: 'aux' | 'external' | null) => {
if (secondarySource === newValue) {
setMessage.timerSecondary(null);
} else {
setMessage.timerSecondary(newValue);
}
};
return (
<div className={style.previewContainer}>
<TimerPreview />
<div className={style.options}>
<Button
size='sm'
variant={secondarySource === 'aux' ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => toggleSecondary('aux')}
>
Show Aux timer
</Button>
<Button
size='sm'
variant={secondarySource === 'external' ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => toggleSecondary('external')}
>
Show external
</Button>
<hr className={style.divider} />
<Button
size='sm'
variant={blink ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink
</Button>
<Button
size='sm'
className={style.blackoutButton}
variant={blackout ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout'
>
Blackout screen
</Button>
</div>
</div>
);
}
@@ -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,9 +50,15 @@ $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;
}
} }
} }
@@ -126,30 +132,19 @@ $table-header-font-size: calc(1rem - 3px);
.resizer { .resizer {
cursor: col-resize; cursor: col-resize;
opacity: 0; opacity: $opacity-disabled;
display: inline-block; display: inline-block;
width: 3px; width: 0;
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,8 +4,6 @@ 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 {
@@ -29,8 +27,6 @@ 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}>
@@ -41,7 +37,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
onMouseDown: header.getResizeHandler(), onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(), onTouchStart: header.getResizeHandler(),
}} }}
className={resizerClasses} className={styles.resizer}
/> />
</th> </th>
); );
@@ -40,7 +40,7 @@ $panel-gap: 0.5rem;
.playback, .playback,
.messages { .messages {
position: relative; position: relative;
border-radius: 8px; border-radius: var(--editor--panel__br);
background-color: $bg-container-l2; background-color: $bg-container-l2;
padding: 1rem; padding: 1rem;
} }
@@ -62,3 +62,10 @@ $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,10 +1,18 @@
@use '../../theme/ontimeColours' as *; @use '../../theme/ontimeColours' as *;
@use '../../theme/ontimeStyles' as *; @use '../../theme/ontimeStyles' as *;
@mixin absolute-top-right($distance) { // declare editor specific styling constants
:root {
--editor--panel__br: 8px;
}
@mixin corner() {
display: none;
transform: rotate(45deg);
position: absolute; position: absolute;
top: $distance; top: 0.5rem;
right: $distance; right: 0.5rem;
cursor: pointer; cursor: pointer;
color: $ui-white; color: $ui-white;
transition-property: color; transition-property: color;
@@ -15,17 +23,11 @@
} }
} }
@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: 8px; border-radius: var(--editor--panel__br);
height: 100%; height: 100%;
background-color: $bg-container-l2; background-color: $bg-container-l2;
padding: 1rem; padding: 1rem;
@@ -21,10 +21,6 @@ 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;
@@ -98,11 +94,7 @@ 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 && ( {isNext && <span className={style.nextTag}>UP NEXT</span>}
<Tooltip label='Next event' {...tooltipProps}>
<span className={style.nextTag}>UP NEXT</span>
</Tooltip>
)}
</div> </div>
<EventBlockPlayback <EventBlockPlayback
eventId={eventId} eventId={eventId}
@@ -118,17 +110,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}`} {...tooltipProps}> <Tooltip label={`Time type: ${timerType}`} openDelay={tooltipDelayMid}>
<span> <span>
<TimerIcon type={timerType} className={style.statusIcon} /> <TimerIcon type={timerType} className={style.statusIcon} />
</span> </span>
</Tooltip> </Tooltip>
<Tooltip label={`End action: ${endAction}`} {...tooltipProps}> <Tooltip label={`End action: ${endAction}`} openDelay={tooltipDelayMid}>
<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'}`} {...tooltipProps}> <Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} openDelay={tooltipDelayMid}>
<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,
Message, MessageState,
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 } = const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime, auxtimer1 } =
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}
@@ -42,6 +42,11 @@ $orange-active: #f60;
grid-template-areas: 'clock schedule'; grid-template-areas: 'clock schedule';
text-transform: uppercase; text-transform: uppercase;
&.hide-right {
grid-template-columns: 1fr;
grid-template-areas: 'clock';
}
.clock-container { .clock-container {
grid-area: clock; grid-area: clock;
display: grid; display: grid;
@@ -115,6 +120,9 @@ $orange-active: #f60;
} }
.next-title { .next-title {
height: 12.5vh;
width: 70%;
font-family: monospace; font-family: monospace;
font-weight: 400; font-weight: 400;
color: var(--studio-active-label, $cyan-active); color: var(--studio-active-label, $cyan-active);
@@ -1,20 +1,19 @@
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import type { OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types'; import type { MaybeString, OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types';
import { isOntimeEvent, Playback } from 'ontime-types'; import { Playback } from 'ontime-types';
import { millisToString, removeSeconds } 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';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { isStringBoolean } from '../common/viewUtils'; import { isStringBoolean } from '../common/viewUtils';
import { getStudioClockOptions } from './studioClock.options'; import { getStudioClockOptions } from './studioClock.options';
import { secondsInMillis, trimRundown } from './studioClock.utils'; import StudioClockSchedule from './StudioClockSchedule';
import './StudioClock.scss'; import './StudioClock.scss';
@@ -23,8 +22,8 @@ interface StudioClockProps {
eventNext: OntimeEvent | null; eventNext: OntimeEvent | null;
time: ViewExtendedTimer; time: ViewExtendedTimer;
backstageEvents: OntimeRundown; backstageEvents: OntimeRundown;
selectedId: string | null; selectedId: MaybeString;
nextId: string | null; nextId: MaybeString;
onAir: boolean; onAir: boolean;
viewSettings: ViewSettings; viewSettings: ViewSettings;
settings: Settings | undefined; settings: Settings | undefined;
@@ -33,21 +32,20 @@ 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;
// TODO: can we prevent the Flash of Unstyled Content on the 7segment fonts? const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
// deferring rendering seems to affect styling (font and useFitText)
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ minFontSize: 150, maxFontSize: 500 });
const activeIndicators = [...Array(12).keys()];
const secondsIndicators = [...Array(60).keys()];
// TODO: fit titles on screen
const MAX_TITLES = 11;
const [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 secondsIndicators = [...Array(60).keys()];
let clock = formatTime(time.clock); let clock = formatTime(time.clock);
let hasAmPm = ''; let hasAmPm = '';
if (clock.includes('AM')) { if (clock.includes('AM')) {
@@ -65,8 +63,7 @@ export default function StudioClock(props: StudioClockProps) {
const defaultFormat = getDefaultFormat(settings?.timeFormat); const defaultFormat = getDefaultFormat(settings?.timeFormat);
const studioClockOptions = getStudioClockOptions(defaultFormat); const studioClockOptions = getStudioClockOptions(defaultFormat);
const delayed = backstageEvents.filter((event) => isOntimeEvent(event)) as OntimeEvent[]; const hideRight = isStringBoolean(searchParams.get('hideRight'));
const trimmedRundown = trimRundown(delayed, selectedId, MAX_TITLES);
let timer = millisToString(time.current, { fallback: '---' }); let timer = millisToString(time.current, { fallback: '---' });
const hideSeconds = isStringBoolean(searchParams.get('hideTimerSeconds')); const hideSeconds = isStringBoolean(searchParams.get('hideTimerSeconds'));
if (time.current != null && hideSeconds) { if (time.current != null && hideSeconds) {
@@ -74,18 +71,15 @@ export default function StudioClock(props: StudioClockProps) {
} }
return ( return (
<div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'> <div
className={`studio-clock ${isMirrored ? 'mirror' : ''} ${hideRight ? 'hide-right' : ''}`}
data-testid='studio-view'
>
<ViewParamsEditor viewOptions={studioClockOptions} /> <ViewParamsEditor viewOptions={studioClockOptions} />
<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>
<div <FitText className='next-title'>{eventNext?.title}</FitText>
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' : ''}
@@ -114,31 +108,9 @@ export default function StudioClock(props: StudioClockProps) {
))} ))}
</div> </div>
</div> </div>
<div className='schedule-container'> {!hideRight && (
<div <StudioClockSchedule rundown={backstageEvents} selectedId={selectedId} nextId={nextId} onAir={onAir} />
className={onAir ? 'onAir' : 'onAir onAir--idle'} )}
data-testid={onAir ? 'on-air-enabled' : 'on-air-disabled'}
>
ON AIR
</div>
<ul className='schedule'>
{trimmedRundown.map((event) => {
const start = formatTime(event.timeStart + (event?.delay ?? 0), { format12: 'h:mm a', format24: 'HH:mm' });
const isSelected = event.id === selectedId;
const isNext = event.id === nextId;
const classes = `schedule__item schedule__item${isSelected ? '--now' : isNext ? '--next' : '--future'}`;
return (
<li key={event.id} className={classes}>
<span className='event'>
<span className='event__colour' style={{ backgroundColor: `${event.colour}` }} />
<SuperscriptTime time={start} />
</span>
<span>{event.title}</span>
</li>
);
})}
</ul>
</div>
</div> </div>
); );
} }
@@ -0,0 +1,50 @@
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundown } from 'ontime-types';
import { formatTime } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { trimRundown } from './studioClock.utils';
import './StudioClock.scss';
interface StudioClockScheduleProps {
rundown: OntimeRundown;
selectedId: MaybeString;
nextId: MaybeString;
onAir: boolean;
}
// TODO: fit titles on screen
const MAX_TITLES = 11;
export default function StudioClockSchedule(props: StudioClockScheduleProps) {
const { rundown, selectedId, nextId, onAir } = props;
const delayed = rundown.filter((event) => isOntimeEvent(event)) as OntimeEvent[];
const trimmedRundown = trimRundown(delayed, selectedId, MAX_TITLES);
return (
<div className='schedule-container'>
<div className={onAir ? 'onAir' : 'onAir onAir--idle'} data-testid={onAir ? 'on-air-enabled' : 'on-air-disabled'}>
ON AIR
</div>
<ul className='schedule'>
{trimmedRundown.map((event) => {
const start = formatTime(event.timeStart + (event?.delay ?? 0), { format12: 'h:mm a', format24: 'HH:mm' });
const isSelected = event.id === selectedId;
const isNext = event.id === nextId;
const classes = `schedule__item schedule__item${isSelected ? '--now' : isNext ? '--next' : '--future'}`;
return (
<li key={event.id} className={classes}>
<span className='event'>
<span className='event__colour' style={{ backgroundColor: `${event.colour}` }} />
<SuperscriptTime time={start} />
</span>
<span>{event.title}</span>
</li>
);
})}
</ul>
</div>
);
}
@@ -1,6 +1,6 @@
import { OntimeEvent } from 'ontime-types'; import { OntimeEvent } from 'ontime-types';
import { secondsInMillis, trimRundown } from '../studioClock.utils'; import { trimRundown } from '../studioClock.utils';
describe('test trimEventlist function', () => { describe('test trimEventlist function', () => {
const limit = 8; const limit = 8;
@@ -118,14 +118,3 @@ describe('test trimEventlist function', () => {
expect(l).toStrictEqual(expected); expect(l).toStrictEqual(expected);
}); });
}); });
describe('secondsInMillis()', () => {
it('return 0 if value is null', () => {
expect(secondsInMillis(null)).toBe(0);
});
it('returns the seconds value of a millis date', () => {
const date = 1686255053619; // Thu Jun 08 2023 20:10:53
const seconds = secondsInMillis(date);
expect(seconds).toBe(53);
});
});
@@ -6,4 +6,12 @@ export const getStudioClockOptions = (timeFormat: string): ViewOption[] => [
getTimeOption(timeFormat), getTimeOption(timeFormat),
{ section: 'Timer Options' }, { section: 'Timer Options' },
hideTimerSeconds, hideTimerSeconds,
{ section: 'Element visibility' },
{
id: 'hideRight',
title: 'Hide right section',
description: 'Hides the right section with On Air indicator and the schedule',
type: 'boolean',
defaultValue: false,
},
]; ];
@@ -1,5 +1,4 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types'; import { OntimeEvent } from 'ontime-types';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
/** /**
* @description Returns trimmed event list array * @description Returns trimmed event list array
@@ -20,15 +19,3 @@ export function trimRundown(rundown: OntimeEvent[], selectedId: string | null, l
const trimmedRundown = rundown.slice(startIndex, endIndex); const trimmedRundown = rundown.slice(startIndex, endIndex);
return trimmedRundown; return trimmedRundown;
} }
/**
* @description Returns amount of seconds in a date given in milliseconds. For studio clock second indicator
* @param {MaybeNumber} millis time to format
* @returns amount of elapsed seconds
*/
export function secondsInMillis(millis: MaybeNumber): number {
if (!millis) {
return 0;
}
return Math.floor((millis % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
}
@@ -1,50 +1,49 @@
import { memo } from 'react'; import { memo } from 'react';
import { useViewportSize } from '@mantine/hooks'; import { useViewportSize } from '@mantine/hooks';
import { isOntimeEvent, MaybeNumber, OntimeEvent } from 'ontime-types'; import { isOntimeEvent, isPlayableEvent, MaybeNumber, OntimeRundown } 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 ProgressBar from './timeline-progress-bar/TimelineProgressBar'; import TimelineProgressBar 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;
rundown: OntimeEvent[]; totalDuration: number;
} }
export default memo(Timeline); export default memo(Timeline);
function Timeline(props: TimelineProps) { function Timeline(props: TimelineProps) {
const { selectedEventId, rundown } = props; const { firstStart, rundown, selectedEventId, totalDuration } = props;
const { width: screenWidth } = useViewportSize(); const { width: screenWidth } = useViewportSize();
const { plannedStart, plannedEnd } = useTimelineOverview();
if (plannedStart === null || plannedEnd === null) { if (totalDuration === 0) {
return null; return null;
} }
const { lastEvent } = getLastEvent(rundown); const { lastEvent } = getLastEvent(rundown);
const startHour = getStartHour(plannedStart); const startHour = getStartHour(firstStart);
const endHour = getEndHour(plannedEnd + (lastEvent?.delay ?? 0)); const endHour = getEndHour(firstStart + totalDuration + (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} />
<ProgressBar startHour={startHour} endHour={endHour} /> <TimelineProgressBar 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)) { if (!isOntimeEvent(event) || !isPlayableEvent(event)) {
return null; return null;
} }
@@ -56,17 +55,14 @@ function Timeline(props: TimelineProps) {
eventStatus = 'live'; eventStatus = 'live';
} }
if (!hasTimelinePassedMidnight) { // we only need to check for next day if we have a previous event
// we need to offset the start to account for midnight if (
hasTimelinePassedMidnight = previousEventStartTime !== null && event.timeStart < previousEventStartTime; previousEventStartTime !== null &&
checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
) {
elapsedDays++;
} }
// TODO: timeline must accumulate normalised time over days const normalisedStart = event.timeStart + elapsedDays * dayInMs;
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,
@@ -76,6 +72,9 @@ 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, getScopedRundown, getUpcomingEvents } from './timeline.utils'; import { getFormattedTimeToStart, getUpcomingEvents, useScopedRundown } from './timeline.utils';
import './TimelinePage.scss'; import './TimelinePage.scss';
@@ -34,15 +34,11 @@ 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]);
@@ -86,7 +82,12 @@ export default function TimelinePage(props: TimelinePageProps) {
category='next' category='next'
/> />
</div> </div>
<Timeline selectedEventId={selectedId} rundown={scopedRundown} /> <Timeline
firstStart={firstStart}
rundown={scopedRundown}
selectedEventId={selectedId}
totalDuration={totalDuration}
/>
</div> </div>
); );
} }
@@ -1,9 +1,13 @@
import { isOntimeEvent, MaybeString, OntimeEvent } from 'ontime-types'; import { useMemo } from 'react';
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,
@@ -87,29 +91,83 @@ export function getStatusLabel(timeToStart: number, status: ProgressStatus): str
return formatDuration(timeToStart); return formatDuration(timeToStart);
} }
export function getScopedRundown(rundown: OntimeEvent[], selectedEventId: MaybeString): OntimeEvent[] { interface ScopedRundownData {
if (rundown.length === 0) { scopedRundown: PlayableEvent[];
return []; firstStart: number;
} totalDuration: number;
}
const params = new URL(document.location.href).searchParams; export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeString): ScopedRundownData {
const hideBackstage = isStringBoolean(params.get('hideBackstage')); const [searchParams] = useSearchParams();
const hidePast = isStringBoolean(params.get('hidePast'));
let scopedRundown = [...rundown]; const data = useMemo(() => {
if (rundown.length === 0) {
if (hidePast && selectedEventId) { return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
const currentIndex = rundown.findIndex((event) => event.id === selectedEventId);
if (currentIndex >= 0) {
scopedRundown = scopedRundown.slice(currentIndex);
} }
}
if (hideBackstage) { const hideBackstage = isStringBoolean(searchParams.get('hideBackstage'));
scopedRundown = scopedRundown.filter((event) => event.isPublic); const hidePast = isStringBoolean(searchParams.get('hidePast'));
}
return scopedRundown; const scopedRundown: PlayableEvent[] = [];
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart = null;
let totalDuration = 0;
let lastEntry: PlayableEvent | null = null;
for (let i = 0; i < rundown.length; i++) {
const currentEntry = rundown[i];
// we only deal with playableEvents
if (isOntimeEvent(currentEntry) && isPlayableEvent(currentEntry)) {
if (currentEntry.id === selectedEventId) {
selectedIndex = i;
}
// maybe filter past
if (hidePast && i < selectedIndex) {
continue;
}
// maybe filter backstage
if (!currentEntry.isPublic && hideBackstage) {
continue;
}
// add to scopedRundown
scopedRundown.push(currentEntry);
/**
* Derive timers
* This logic is partially from rundownCache.generate
* With the addition of deriving the current day offset
*/
if (firstStart === null) {
firstStart = currentEntry.timeStart;
}
const timeFromPrevious: number = getTimeFromPrevious(
currentEntry.timeStart,
lastEntry?.timeStart,
lastEntry?.timeEnd,
lastEntry?.duration,
);
if (timeFromPrevious === 0) {
totalDuration += currentEntry.duration;
} else if (timeFromPrevious > 0) {
totalDuration += timeFromPrevious + currentEntry.duration;
} else if (timeFromPrevious < 0) {
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
}
if (isNewLatest(currentEntry.timeStart, currentEntry.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
lastEntry = currentEntry;
}
}
}
return { scopedRundown, firstStart: firstStart ?? 0, totalDuration };
}, [rundown, searchParams, selectedEventId]);
return data;
} }
type UpcomingEvents = { type UpcomingEvents = {
@@ -121,18 +179,17 @@ type UpcomingEvents = {
/** /**
* Returns upcoming events from current: now, next and followedBy * Returns upcoming events from current: now, next and followedBy
*/ */
export function getUpcomingEvents(events: OntimeEvent[], selectedId: MaybeString): UpcomingEvents { export function getUpcomingEvents(events: OntimeRundown, 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 };
} }
const now = selectedId ? getEventWithId(events, selectedId) : getFirstEvent(events)?.firstEvent; let now = selectedId ? getEventWithId(events, selectedId) : null;
if (!isOntimeEvent(now)) { if (!isOntimeEvent(now)) {
return { now: null, next: null, followedBy: null }; now = null;
} }
const next = getNextEvent(events, now.id)?.nextEvent; const next = now ? getNextEvent(events, now.id)?.nextEvent : getFirstEvent(events).firstEvent;
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,20 +129,22 @@
} }
} }
.external { .secondary {
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 rgba(white, 0.1); border-top: 1px solid color-mix(in srgb, var(--external-color-override, $external-color) 10%, transparent);
&--hidden { &--hidden {
opacity: 0; opacity: 0;
@@ -167,36 +169,28 @@
.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;
background-color: $viewer-overlay-bg-color; padding: 2vw;
z-index: -1; background: var(--background-color-override, $viewer-background-color);
opacity: 0; opacity: 0;
transition: $viewer-transition-time; transition: opacity $viewer-transition-time;
z-index: 2;
&--active { &--active {
opacity: 1; opacity: 1;
transition: $viewer-transition-time;
transition-property: opacity;
z-index: 2;
} }
} }
.message { .message {
width: inherit; display: grid;
padding: 2vw; place-content: center;
position: absolute; height: 100%;
top: 50%; width: 100%;
left: 50%;
color: $viewer-color; color: var(--color-override, $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;
} }
@@ -1,22 +1,27 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { usePrevious } from '@mantine/hooks';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { import {
CustomFields, CustomFields,
Message, MessageState,
OntimeEvent, OntimeEvent,
Playback, Playback,
Settings, Settings,
TimerMessage, SimpleTimerState,
TimerPhase, TimerPhase,
TimerType, TimerType,
ViewSettings, ViewSettings,
} from 'ontime-types'; } from 'ontime-types';
import sound from '../../../assets/sounds/buzzer.mp3';
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';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useTimerPhase } from '../../../common/hooks/useSocket';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
@@ -47,23 +52,39 @@ 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;
pres: TimerMessage; message: MessageState;
settings: Settings | undefined; settings: Settings | undefined;
time: ViewExtendedTimer; time: ViewExtendedTimer;
viewSettings: ViewSettings; viewSettings: ViewSettings;
} }
const usePhaseEvent = () => {
const { phase } = useTimerPhase();
const previousValue = usePrevious(phase);
const audio = useMemo(() => new Audio(sound), []);
if (previousValue !== TimerPhase.None && previousValue !== phase && phase === TimerPhase.Overtime) {
try {
audio.play();
} catch (error) {
console.error('Audio playback prevented', error);
}
}
};
export default function Timer(props: TimerProps) { export default function Timer(props: TimerProps) {
const { customFields, isMirrored, pres, eventNow, eventNext, time, viewSettings, external, settings } = props; const { auxTimer, customFields, eventNow, eventNext, isMirrored, message, settings, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
usePhaseEvent();
useWindowTitle('Timer'); useWindowTitle('Timer');
@@ -113,7 +134,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 = pres.text !== '' && pres.visible; const showOverlay = message.timer.text !== '' && message.timer.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;
@@ -127,10 +148,20 @@ 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;
@@ -145,13 +176,14 @@ 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 (showExternal) { if (secondaryContent) {
timerFontSize *= 0.8; timerFontSize *= 0.8;
} }
const externalFontSize = timerFontSize * 0.4; const externalFontSize = timerFontSize * 0.4;
const timerContainerClasses = `timer-container ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`; const timerContainerClasses = `timer-container ${message.timer.blink ? (showOverlay ? '' : 'blink') : ''}`;
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`; const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
const defaultFormat = getDefaultFormat(settings?.timeFormat); const defaultFormat = getDefaultFormat(settings?.timeFormat);
@@ -160,10 +192,12 @@ 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={showBlackout ? 'blackout blackout--active' : 'blackout'} /> <div className={message.timer.blackout ? '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'}>
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div> <FitText mode='multi' min={32} max={256} className={`message ${message.timer.blink ? 'blink' : ''}`}>
{message.timer.text}
</FitText>
</div> </div>
)} )}
@@ -189,10 +223,10 @@ export default function Timer(props: TimerProps) {
</div> </div>
)} )}
<div <div
className={`external${showExternal ? '' : ' external--hidden'}`} className={`secondary${secondaryContent ? '' : ' secondary--hidden'}`}
style={{ fontSize: `${externalFontSize}vw` }} style={{ fontSize: `${externalFontSize}vw` }}
> >
{external.text} {secondaryContent}
</div> </div>
</div> </div>
+2 -2
View File
@@ -2,5 +2,5 @@ export const tooltipDelaySlow = 1000;
export const tooltipDelayMid = 500; export const tooltipDelayMid = 500;
export const tooltipDelayFast = 300; export const tooltipDelayFast = 300;
export const queryRefetchInterval = 10000; export const queryRefetchInterval = 10000; // 10 seconds
export const queryRefetchIntervalSlow = 30000; export const queryRefetchIntervalSlow = 30000; // 30 seconds
+1 -2
View File
@@ -19,7 +19,6 @@ $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
@@ -27,7 +26,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, 70%); // --external-color-override $external-color: rgba(white, 85%); // --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);
+9 -9
View File
@@ -1,13 +1,13 @@
export const navigatorConstants = [ export const navigatorConstants = [
{ url: '/timer', label: 'Timer' }, { url: 'timer', label: 'Timer' },
{ url: '/clock', label: 'Clock' }, { url: 'clock', label: 'Clock' },
{ url: '/minimal', label: 'Minimal Timer' }, { 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
-7
View File
@@ -174,13 +174,6 @@ 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) => {
// Load page served by node or use React dev run // Load page served by node or use React dev run
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "3.5.0", "version": "3.6.1",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -6,7 +6,7 @@
function getTrayMenu(showApp, askToQuit) { function getTrayMenu(showApp, askToQuit) {
return [ return [
{ {
label: 'Show App (Alt + 1)', label: 'Show App',
click: () => showApp(), click: () => showApp(),
}, },
{ {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "3.5.0", "version": "3.6.1",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
@@ -0,0 +1,36 @@
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,9 +1,11 @@
import { DeepPartial, MessageState, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types'; import { 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 { messageService } from '../services/message-service/MessageService.js'; import * as 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';
@@ -14,6 +16,8 @@ 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') {
@@ -78,9 +82,12 @@ 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 payload ? validateTimerMessage(payload.timer) : undefined, timer: 'timer' in migratedPayload ? validateTimerMessage(migratedPayload.timer) : undefined,
external: 'external' in payload ? validateMessage(payload.external) : undefined, external: 'external' in migratedPayload ? validateMessage(migratedPayload.external) : undefined,
}; };
const newMessage = messageService.patch(patch); const newMessage = messageService.patch(patch);
@@ -0,0 +1,67 @@
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;
}
+3 -3
View File
@@ -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 { messageService } from './services/message-service/MessageService.js'; import * as 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';
@@ -198,7 +198,7 @@ export const startServer = async (
// initialise logging service, escalateErrorFn is only exists in electron // initialise logging service, escalateErrorFn is only exists in electron
logger.init(escalateErrorFn); logger.init(escalateErrorFn);
// initialise rundown service // initialise rundown service
const persistedRundown = getDataProvider().getRundown(); const persistedRundown = getDataProvider().getRundown();
const persistedCustomFields = getDataProvider().getCustomFields(); const persistedCustomFields = getDataProvider().getCustomFields();
initRundown(persistedRundown, persistedCustomFields); initRundown(persistedRundown, persistedCustomFields);
@@ -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(eventStore.set.bind(eventStore)); messageService.init((key, value) => eventStore.set(key, value));
expressServer.listen(serverPort, '0.0.0.0', () => { expressServer.listen(serverPort, '0.0.0.0', () => {
const nif = getNetworkInterfaces(); const nif = getNetworkInterfaces();
@@ -246,6 +246,79 @@ describe('loadRoll() handle edge cases with midnight', () => {
}); });
}); });
describe('loadRoll() handle rundowns with several days', () => {
it('should find the correct event, when we have many days', () => {
const now = 11 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE;
const timedEvents = [
{
id: '0',
timeStart: 10 * MILLIS_PER_HOUR,
timeEnd: 11 * MILLIS_PER_HOUR,
},
{
id: '2',
timeStart: 11 * MILLIS_PER_HOUR,
timeEnd: 12 * MILLIS_PER_HOUR,
},
{
id: '3',
timeStart: 12 * MILLIS_PER_HOUR,
timeEnd: 13 * MILLIS_PER_HOUR,
},
{
id: '4',
timeStart: 11 * MILLIS_PER_HOUR,
timeEnd: 12 * MILLIS_PER_HOUR,
},
];
const state = loadRoll(prepareTimedEvents(timedEvents), now);
const expected = {
event: timedEvents[1],
index: 1,
};
expect(state).toMatchObject(expected);
});
it('should find the correct event, when we have events of zero duration', () => {
const now = 20 * MILLIS_PER_HOUR + 37 * MILLIS_PER_MINUTE;
const timedEvents = [
{
id: '0',
timeStart: 18 * MILLIS_PER_HOUR,
timeEnd: 19 * MILLIS_PER_HOUR,
},
{
id: '1 no duration',
timeStart: 0,
timeEnd: 0,
},
{
id: '2',
timeStart: 19 * MILLIS_PER_HOUR,
timeEnd: 20 * MILLIS_PER_HOUR,
},
{
id: '3 no duration',
timeStart: 0,
timeEnd: 0,
},
{
id: '4',
timeStart: 20 * MILLIS_PER_HOUR,
timeEnd: 21 * MILLIS_PER_HOUR,
},
];
const state = loadRoll(prepareTimedEvents(timedEvents), now);
const expected = {
event: timedEvents[4],
index: 4,
};
expect(state).toMatchObject(expected);
});
});
describe('loadRoll() handle edge cases with before and after start', () => { describe('loadRoll() handle edge cases with before and after start', () => {
it('should prepare first event, if we are not yet in the rundown start', () => { it('should prepare first event, if we are not yet in the rundown start', () => {
const now = 7 * MILLIS_PER_HOUR; const now = 7 * MILLIS_PER_HOUR;
@@ -302,7 +375,7 @@ describe('loadRoll() handle edge cases with before and after start', () => {
index: 0, index: 0,
}; };
const state = loadRoll(singleEventList, now); const state = loadRoll(singleEventList, now);
expect(state.isPending).toBeUndefined(); expect(state.isPending).toBeUndefined(); // we are playing the event
expect(state).toStrictEqual(expected); expect(state).toStrictEqual(expected);
}); });
@@ -443,6 +516,7 @@ describe('loadRoll() test that roll behaviour multi day event edge cases', () =>
}; };
const state = loadRoll(eventlist, now); const state = loadRoll(eventlist, now);
expect(state.isPending).toBeUndefined(); // we are playing the event
expect(state).toStrictEqual(expected); expect(state).toStrictEqual(expected);
}); });
}); });
@@ -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) { if (!this.oscClient || !this.enabledOut) {
return; return;
} }
@@ -1,68 +1,63 @@
import { DeepPartial, Message, TimerMessage, MessageState } from 'ontime-types'; import { 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';
let instance: MessageService | null = null; const defaultTimer: TimerMessage = {
text: '',
visible: false,
blink: false,
blackout: false,
secondarySource: null,
};
class MessageService { let timer = { ...defaultTimer };
timer: TimerMessage; let external = '';
external: Message;
private throttledSet: PublishFn; let throttledSet: PublishFn | null = null;
private publish: PublishFn | null;
constructor() { /**
if (instance) { * Initialises the message service with a publish function
throw new Error('There can be only one'); * @param publishFn
} */
export function init(publishFn: PublishFn) {
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton throttledSet = throttle(publishFn, 100);
instance = this;
this.throttledSet = () => {
throw new Error('Published called before initialisation');
};
this.clear();
}
clear() {
this.timer = {
text: '',
visible: false,
blink: false,
blackout: false,
};
this.external = {
text: '',
visible: false,
};
}
init(publish: PublishFn) {
this.publish = publish;
this.throttledSet = throttle((key, value) => this.publish?.(key, value), 100);
}
getState(): MessageState {
return {
timer: this.timer,
external: this.external,
};
}
patch(message: DeepPartial<MessageState>) {
if (message.timer) this.timer = { ...this.timer, ...message.timer };
if (message.external) this.external = { ...this.external, ...message.external };
const newState = this.getState();
this.throttledSet('message', newState);
return newState;
}
} }
export const messageService = new MessageService(); /**
* Exposes function to reset the internal state
*/
export function clear() {
timer = { ...defaultTimer };
external = '';
}
/**
* Exposes the internal state of the message service
*/
export function getState(): MessageState {
return {
external,
timer,
};
}
/**
* Utility function allows patching internal object
*/
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 };
if ('external' in patch && patch.external !== undefined) external = patch.external;
const newState = getState();
throttledSet?.('message', newState);
return newState;
}
@@ -1,4 +1,4 @@
import { messageService } from '../MessageService.js'; import * as messageService from '../MessageService.js';
describe('MessageService', () => { describe('MessageService', () => {
const publishFunction = () => {}; const publishFunction = () => {};
@@ -14,17 +14,14 @@ 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: { visible: true }, external: 'external',
}; };
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 }, timer: { text: 'new text', visible: true, blackout: false, blink: false, secondarySource: null },
external: { external: 'external',
text: '',
visible: true,
},
}); });
}); });
@@ -36,11 +33,8 @@ 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 }, timer: { text: 'initial text', visible: true, blackout: false, blink: false, secondarySource: null },
external: { external: '',
text: '',
visible: false,
},
}); });
}); });
}); });
@@ -2,26 +2,7 @@ import { validateMessage, validateTimerMessage } from '../messageUtils.js';
describe('validateMessage()', () => { describe('validateMessage()', () => {
it('returns a valid Message object', () => { it('returns a valid Message object', () => {
const payload = { expect(validateMessage('test')).toEqual('test');
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 { Message, TimerMessage } from 'ontime-types'; import { 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,14 +7,8 @@ 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): Partial<Message> { export function validateMessage(message: unknown): string {
assert.isObject(message); return decodeURI(coerceString(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;
} }
/** /**
@@ -26,10 +20,28 @@ export function validateTimerMessage(message: unknown): Partial<TimerMessage> {
const result: Partial<TimerMessage> = {}; const result: Partial<TimerMessage> = {};
if ('text' in message) result.text = coerceString(message.text); if ('text' in message) result.text = decodeURI(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;
}
+6 -17
View File
@@ -1,4 +1,4 @@
import { dayInMs, getFirstEvent, getLastEvent } from 'ontime-utils'; import { dayInMs, getFirstEvent } 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';
@@ -15,26 +15,11 @@ export function loadRoll(
isPending?: boolean; isPending?: boolean;
} { } {
const { firstEvent } = getFirstEvent(timedEvents); const { firstEvent } = getFirstEvent(timedEvents);
const { lastEvent } = getLastEvent(timedEvents);
if (!firstEvent || !lastEvent) { if (!firstEvent) {
return { event: null, index: null }; return { event: null, index: null };
} }
// check that the rundown wraps around midnight
const wrapsAroundMidnight = firstEvent.timeStart > lastEvent.timeEnd;
if (!wrapsAroundMidnight) {
// check whether we are before or after the rundown
const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd);
const isAfterRundown = timeNow > lastNormalEnd;
const isBeforeRundown = timeNow < firstEvent.timeStart && !isAfterRundown;
if (isAfterRundown || isBeforeRundown) {
return { event: firstEvent, index: 0, isPending: true };
}
}
// we know we are in the middle of the rundown and we need to find the current event // we know we are in the middle of the rundown and we need to find the current event
// account for number of times we went over midnight // account for number of times we went over midnight
let daySpan = 0; let daySpan = 0;
@@ -46,6 +31,10 @@ export function loadRoll(
continue; continue;
} }
if (event.duration === 0) {
continue;
}
// we check if event crosses midnight // we check if event crosses midnight
if (event.timeStart > event.timeEnd) { if (event.timeStart > event.timeEnd) {
daySpan++; daySpan++;
@@ -247,7 +247,11 @@ function notifyChanges(options: { timer?: boolean | string[]; external?: boolean
if (options.external) { if (options.external) {
// advice socket subscribers of change // advice socket subscribers of change
sendRefetch(Array.isArray(options.timer) ? options.timer : undefined); const payload = {
changes: Array.isArray(options.timer) ? options.timer : undefined,
revision: cache.getMetadata().revision,
};
sendRefetch(payload);
} }
} }
@@ -1,249 +1,243 @@
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';
describe('apply() ', () => { /**
describe('in a rundown without the delay field, persisted rundown', () => { * Small utility to fill in the necessary data for the test
it('applies delays', () => { */
const delayId = '1'; function makeOntimeEvent(event: Partial<OntimeEvent>): OntimeEvent {
const testRundown: OntimeRundown = [ return { ...event, type: SupportedEvent.Event, revision: 1 } as OntimeEvent;
{ id: delayId, type: SupportedEvent.Delay, duration: 10 } as OntimeDelay, }
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
{ 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 = [ /**
{ id: '2', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } as OntimeEvent, * Small utility to make a delay event
{ id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } as OntimeEvent, */
{ id: '4', type: SupportedEvent.Block } as OntimeBlock, function makeOntimeDelay(duration: number): OntimeDelay {
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, return { id: 'delay', type: SupportedEvent.Delay, duration } as OntimeDelay;
]; }
const updatedRundown = apply(delayId, testRundown); describe('apply()', () => {
expect(updatedRundown).toStrictEqual(expected); it('applies a positive delay to the rundown', () => {
}); const testRundown = [
it('applies negative delays', () => { makeOntimeDelay(10),
const delayId = '1'; makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
const testRundown: OntimeRundown = [ makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
{ id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay, { id: '3', type: SupportedEvent.Block } as OntimeBlock,
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
{ id: '3', type: SupportedEvent.Event, timeStart: 20, timeEnd: 40, duration: 20, revision: 1 } as OntimeEvent, makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
{ 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 updatedRundown = apply('delay', testRundown);
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 2 } as OntimeEvent, expect(updatedRundown).not.toBe(testRundown);
{ id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 30, duration: 20, revision: 2 } as OntimeEvent, expect(updatedRundown).toMatchObject([
{ id: '4', type: SupportedEvent.Block } as OntimeBlock, { id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 },
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, { 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 },
const updatedRundown = apply(delayId, testRundown); { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: '4' },
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 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, timeStart: 20, timeEnd: 40, duration: 20, revision: 1 } as OntimeEvent,
];
const expected = [
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 2 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, timeStart: 0, timeEnd: 20, duration: 20, revision: 2 } as OntimeEvent,
];
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 = [
{ 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: 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 = [ it('applies negative delays', () => {
{ const testRundown = [
id: '2', makeOntimeDelay(-10),
type: SupportedEvent.Event, makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
timeStart: 10, makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
timeEnd: 20, { id: '3', type: SupportedEvent.Block } as OntimeBlock,
duration: 10, makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
revision: 2, makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
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); const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toStrictEqual(expected); expect(updatedRundown).toMatchObject([
}); { id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 },
it('applies negative delays', () => { { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null },
const delayId = '1'; { id: '3' },
const testRundown: OntimeRundown = [ { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: null },
{ id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay, { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '4' },
{ ]);
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 = [ it('should account for minimum duration and start when applying negative delays', () => {
{ const testRundown: OntimeRundown = [
id: '2', makeOntimeDelay(-50),
type: SupportedEvent.Event, makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
timeStart: 0, makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: '1' }),
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); const expected = [
expect(updatedRundown).toStrictEqual(expected); { id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 2 } as OntimeEvent,
}); {
it('maintains constant duration', () => { id: '2',
const delayId = '1'; type: SupportedEvent.Event,
const testRundown: OntimeRundown = [ timeStart: 50,
{ id: delayId, type: SupportedEvent.Delay, duration: -30 } as OntimeDelay, timeEnd: 100,
{ duration: 50,
id: '2', linkStart: null,
type: SupportedEvent.Event, revision: 2,
timeStart: 0, } as OntimeEvent,
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 = [ const updatedRundown = apply('delay', testRundown);
{ expect(updatedRundown).toMatchObject(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); it('unlinks events to maintain gaps when applying positive delays', () => {
expect(updatedRundown).toStrictEqual(expected); 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 = [
makeOntimeDelay(100),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
// gap 50
makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50 }),
// gap 50
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);
expect(updatedRundown).toMatchObject([
{ id: '1', timeStart: 0 + 100, timeEnd: 100 + 100, duration: 100, revision: 2 },
// gap 50 (100 - 50)
{ id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 },
// 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 testRundown: OntimeRundown = [
makeOntimeDelay(2 * MILLIS_PER_HOUR),
makeOntimeEvent({
id: '1',
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);
expect(updatedRundown).toMatchObject([
{ id: '1', timeStart: 54000000 /* 16 */, revision: 2 },
// gap 1h (2h - 1h)
{ id: '2', timeStart: 57600000 /* 16 */, revision: 2 },
]);
});
it('removes empty delays without applying changes', () => {
const testRundown: OntimeRundown = [
makeOntimeDelay(0),
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',
type: SupportedEvent.Event,
timeStart: 150,
timeEnd: 200,
duration: 50,
linkStart: null,
revision: 2,
} as OntimeEvent,
]);
}); });
}); });
@@ -1,6 +1,5 @@
import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent } from 'ontime-types'; import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent, OntimeEvent } 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
@@ -92,10 +91,7 @@ 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);
@@ -109,27 +105,71 @@ 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');
} }
const updatedRundown = [...rundown]; // if the delay is empty, or the last element, we can just delete it
const delayValue = delayEvent.duration; if (delayEvent.duration === 0 || delayIndex === rundown.length - 1) {
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++) { /**
const currentEvent = updatedRundown[i]; * We apply the delay to the rundown
* 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;
if (isOntimeBlock(currentEvent)) { for (let i = delayIndex + 1; i < updatedRundown.length; i++) {
break; const currentEntry = updatedRundown[i];
} else if (isOntimeEvent(currentEvent)) {
currentEvent.timeStart = Math.max(0, currentEvent.timeStart + delayValue); // we don't do operation on other event types
currentEvent.timeEnd = Math.max(currentEvent.duration, currentEvent.timeEnd + delayValue); if (!isOntimeEvent(currentEntry)) {
if (currentEvent.delay) { continue;
currentEvent.delay = currentEvent.delay - delayValue;
}
currentEvent.revision += 1;
} }
// 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;
}
}
// 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);
@@ -215,6 +215,7 @@ export function getMetadata() {
lastEnd, lastEnd,
totalDelay, totalDelay,
totalDuration, totalDuration,
revision,
}; };
} }
@@ -243,6 +244,12 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
const { newEvent, newRundown, didMutate } = mutation({ ...params, persistedRundown }); const { newEvent, newRundown, didMutate } = mutation({ ...params, persistedRundown });
// early return without calling side effects
if (!didMutate) {
isStale = false;
return { newEvent, newRundown, didMutate };
}
revision = revision + 1; revision = revision + 1;
persistedRundown = newRundown; persistedRundown = newRundown;
@@ -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 = typeof affectedIds === 'undefined'; const safeOption = 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);
+7 -2
View File
@@ -120,8 +120,10 @@ export function clear() {
runtimeState.clock = clock.timeNow(); runtimeState.clock = clock.timeNow();
runtimeState.timer = { ...initialTimer }; runtimeState.timer = { ...initialTimer };
// we maintain the total delay // when clearing, we maintain the total delay from the rundown
runtimeState._timer.forceFinish = null;
runtimeState._timer.pausedAt = null; runtimeState._timer.pausedAt = null;
runtimeState._timer.secondaryTarget = null;
} }
/** /**
@@ -264,6 +266,9 @@ 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
@@ -495,7 +500,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) { if (runtimeState.timer.duration === null) {
throw new Error('runtimeState.update: invalid state received'); throw new Error('runtimeState.update: invalid state received');
} }
} }
+151 -19
View File
@@ -729,26 +729,37 @@ describe('test import of v2 datamodel', () => {
describe('makeString()', () => { describe('makeString()', () => {
it('converts variables to string', () => { it('converts variables to string', () => {
let val = 2; const cases = [
let expected = '2'; {
let converted = makeString(val); val: 2,
expect(converted).toBe(expected); 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',
},
];
val = 2.22222222; cases.forEach(({ val, expected }) => {
expected = '2.22222222'; const converted = makeString(val, 'fallback');
converted = makeString(val); expect(converted).toBe(expected);
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');
}); });
}); });
@@ -1222,6 +1233,7 @@ 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 = [
[ [
@@ -1388,6 +1400,126 @@ 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'],
+4 -3
View File
@@ -195,11 +195,12 @@ 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) {
if (column === 'block') { const maybeTimeType = makeString(column, '');
if (maybeTimeType === 'block') {
event.type = SupportedEvent.Block; event.type = SupportedEvent.Block;
} else if (column === '' || isKnownTimerType(column)) { } else if (maybeTimeType === '' || isKnownTimerType(maybeTimeType)) {
event.type = SupportedEvent.Event; event.type = SupportedEvent.Event;
event.timerType = validateTimerType(column); event.timerType = validateTimerType(maybeTimeType);
} 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;
+2 -2
View File
@@ -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; if (typeof val === 'string') return val.trim();
else if (val == null || val.constructor === Object) return fallback; else if (val == null || val.constructor === Object) return fallback;
return val.toString(); return val.toString().trim();
}; };
/** /**
@@ -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('Timer').click(); await editorPage.getByPlaceholder('Message shown fullscreen in stage timer').click();
await editorPage.getByPlaceholder('Timer').fill('testing stage'); await editorPage.getByPlaceholder('Message shown fullscreen in stage timer').fill('testing stage');
await editorPage.getByRole('button', { name: /toggle timer/i }).click({ timeout: 5000 }); await editorPage.getByRole('button', { name: /toggle timer message/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 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "3.5.0", "version": "3.6.1",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"keywords": [ "keywords": [
"ontime", "ontime",
@@ -1,14 +1,12 @@
export type Message = { export type TimerMessage = {
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: Message; external: string;
}; };
+2 -2
View File
@@ -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 { Message, TimerMessage, MessageState } from './definitions/runtime/MessageControl.type.js'; export type { 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 { DeepPartial, MaybeNumber, MaybeString } from './utils/utils.type.js'; export type { MaybeNumber, MaybeString } from './utils/utils.type.js';
-4
View File
@@ -1,6 +1,2 @@
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];
};
+1
View File
@@ -42,6 +42,7 @@ export {
millisToHours, millisToHours,
millisToMinutes, millisToMinutes,
millisToSeconds, millisToSeconds,
secondsInMillis,
} from './src/date-utils/conversionUtils.js'; } from './src/date-utils/conversionUtils.js';
export { isTimeString } from './src/date-utils/isTimeString.js'; export { isTimeString } from './src/date-utils/isTimeString.js';
export { export {
@@ -57,4 +57,11 @@ 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,8 +24,9 @@ export function checkIsNextDay(previousStart: number, timeStart: number, previou
return false; return false;
} }
if (timeStart <= previousStart) { const cappedStart = previousStart % dayInMs;
const normalisedPreviousEnd = previousStart + previousDuration; if (timeStart <= cappedStart) {
const normalisedPreviousEnd = cappedStart + previousDuration;
if (normalisedPreviousEnd === dayInMs) { if (normalisedPreviousEnd === dayInMs) {
return true; return true;
} }
@@ -1,4 +1,4 @@
import { millisToHours, millisToMinutes, millisToSeconds } from "./conversionUtils"; import { millisToHours, millisToMinutes, millisToSeconds, secondsInMillis } from './conversionUtils';
describe('millisToSecond()', () => { describe('millisToSecond()', () => {
test('null values', () => { test('null values', () => {
@@ -112,3 +112,14 @@ describe('millisToHours()', () => {
expect(millisToHours(t.val)).toBe(t.result); expect(millisToHours(t.val)).toBe(t.result);
}); });
}); });
describe('secondsInMillis()', () => {
it('return 0 if value is null', () => {
expect(secondsInMillis(null)).toBe(0);
});
it('returns the seconds value of a millis date', () => {
const date = 1686255053619; // Thu Jun 08 2023 20:10:53
const seconds = secondsInMillis(date);
expect(seconds).toBe(53);
});
});
@@ -64,3 +64,15 @@ export function secondsToMinutes(seconds: number): number {
export function secondsToHours(seconds: number): number { export function secondsToHours(seconds: number): number {
return Math.floor(seconds / 3600); return Math.floor(seconds / 3600);
} }
/**
* @description Returns amount of seconds in a date given in milliseconds. For studio clock second indicator
* @param {MaybeNumber} millis time to format
* @returns amount of elapsed seconds
*/
export function secondsInMillis(millis: MaybeNumber): number {
if (!millis) {
return 0;
}
return Math.floor((millis % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
}