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>
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
## Using Ontime?
## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
## Using Ontime?
Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
@@ -35,6 +37,16 @@ Ontime is made by entertainment and broadcast engineers and used by
- Theatres and opera houses
- Houses of worship
## Main features
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
... and a lot more ...
### For live environments
Ontime is designed for use in live environments. \
@@ -83,6 +95,7 @@ IP.ADDRESS:4001/clock > Simple clock view
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
IP.ADDRESS:4001/countdown > Countdown to anything
IP.ADDRESS:4001/studio > Studio Clock
IP.ADDRESS:4001/timeline > Timeline
```
```
@@ -100,26 +113,13 @@ IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/operator > automated views for operators
```
More documentation is available [in our docs](https://docs.getontime.no)
## Main features
- [x] Distribute data over network and render it in the browser
- [x] Collaborative
- [x] Extendable
- [x] Send messages to different screen types
- [x] Differentiate between backstage and public data
- [x] Workflow for managing delays
- [x] Rich protocol integrations for Control and Feedback
- [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
More information is available [in our docs](https://docs.getontime.no)
## Roadmap
### Continued development
Ontime is under active development. We continue adding and tweaking features in collaboration with users.
Ontime is under active development. We continue adding and improving features in collaboration with users.
Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
@@ -161,8 +161,9 @@ If you are a developer and would like to contribute with code, please open an is
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
## Links
See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no)
- [Ontime website](https://getontime.no)
- [Documentation](https://docs.getontime.no)
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
## License
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.5.0",
"version": "3.6.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.5.0",
"version": "3.6.1",
"private": true,
"type": "module",
"dependencies": {
Binary file not shown.
+1
View File
@@ -18,6 +18,7 @@ const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
export const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
// resolve port
const STATIC_PORT = 4001;
+1 -1
View File
@@ -11,7 +11,7 @@ const dbPath = `${apiEntryUrl}/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 });
}
@@ -28,9 +28,11 @@ class ErrorBoundary extends React.Component {
});
Sentry.withScope((scope) => {
scope.setExtras('error', error);
scope.setExtras('store', runtimeStore.getState());
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
scope.setExtras({
error,
store: runtimeStore.getState(),
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
});
const eventId = Sentry.captureException(error);
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;
padding: 0.75rem 1.5rem;
gap: 0.5rem;
width: 100%;
cursor: pointer;
&:hover {
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 { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import {
Drawer,
DrawerBody,
@@ -19,9 +19,12 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import useElectronEvent from '../../hooks/useElectronEvent';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import { RenameClientModal } from '../client-modal/RenameClientModal';
import style from './NavigationMenu.module.scss';
@@ -40,6 +43,7 @@ function NavigationMenu(props: NavigationMenuProps) {
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore();
const location = useLocation();
const menuRef = useRef<HTMLDivElement | null>(null);
@@ -96,38 +100,29 @@ function NavigationMenu(props: NavigationMenuProps) {
<hr className={style.separator} />
<Link
to='/editor'
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
tabIndex={0}
className={`${style.link} ${location.pathname === '/editor' && style.current}`}
>
<IoLockClosedOutline />
Editor
<IoArrowUp className={style.linkIcon} />
</Link>
<Link
to='/cuesheet'
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
tabIndex={0}
>
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
<IoLockClosedOutline />
Cuesheet
<IoArrowUp className={style.linkIcon} />
</Link>
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
</ClientLink>
<ClientLink to='op' current={location.pathname === '/op'}>
<IoLockClosedOutline />
Operator
<IoArrowUp className={style.linkIcon} />
</Link>
</ClientLink>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<Link
key={route.url}
to={route.url}
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
tabIndex={0}
>
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
{route.label}
<IoArrowUp className={style.linkIcon} />
</Link>
</ClientLink>
))}
</DrawerBody>
</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);
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { CustomFields } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CUSTOM_FIELDS } from '../api/constants';
import { getCustomFields } from '../api/customFields';
@@ -14,7 +14,7 @@ export default function useCustomFields() {
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants';
import { fetchNormalisedRundown } from '../api/rundown';
@@ -16,7 +16,7 @@ export default function useRundown() {
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
+34 -6
View File
@@ -9,6 +9,8 @@ import {
parseUserTime,
reorderArray,
swapEventData,
validateEndAction,
validateTimerType,
} from 'ontime-utils';
import { RUNDOWN } from '../api/constants';
@@ -32,7 +34,15 @@ import { useEditorSettings } from '../stores/editorSettings';
*/
export const useEventAction = () => {
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
@@ -46,17 +56,17 @@ export const useEventAction = () => {
networkMode: 'always',
});
// options to any new block (event / delay / block)
type BaseOptions = {
after?: string;
};
// options to blocks of type OntimeEvent
type EventOptions = BaseOptions &
Partial<{
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: string;
defaultWarnTime: number;
defaultDangerTime: number;
}>;
/**
@@ -68,13 +78,12 @@ export const useEventAction = () => {
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEvent)) {
// merge creation time options with event settings
const applicationOptions = {
after: options?.after,
defaultPublic: options?.defaultPublic ?? defaultPublic,
lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious,
defaultWarnTime,
defaultDangerTime,
};
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
@@ -89,6 +98,7 @@ export const useEventAction = () => {
}
}
// Override event with options from editor settings
if (applicationOptions.defaultPublic) {
newEvent.isPublic = true;
}
@@ -104,6 +114,14 @@ export const useEventAction = () => {
if (newEvent.timeWarning === undefined) {
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
@@ -117,7 +135,17 @@ export const useEventAction = () => {
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 { socketSendJson } from '../utils/socket';
@@ -28,11 +28,43 @@ export const useOperator = () => {
return useRuntimeStore(featureSelector);
};
export const useMessageControl = () => {
export const useTimerViewControl = () => {
const featureSelector = (state: RuntimeStore) => ({
timer: state.message.timer,
external: state.message.external,
onAir: state.onAir,
blackout: state.message.timer.blackout,
blink: state.message.timer.blink,
secondarySource: state.message.timer.secondarySource,
});
return useRuntimeStore(featureSelector);
};
export const useTimerMessageInput = () => {
const featureSelector = (state: RuntimeStore) => ({
text: state.message.timer.text,
visible: state.message.timer.visible,
});
return useRuntimeStore(featureSelector);
};
export const useExternalMessageInput = () => {
const featureSelector = (state: RuntimeStore) => ({
text: state.message.external,
visible: state.message.timer.secondarySource === 'external',
});
return useRuntimeStore(featureSelector);
};
export const useMessagePreview = () => {
const featureSelector = (state: RuntimeStore) => ({
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
phase: state.timer.phase,
showAuxTimer: state.message.timer.secondarySource === 'aux',
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null,
});
return useRuntimeStore(featureSelector);
@@ -41,8 +73,11 @@ export const useMessageControl = () => {
export const setMessage = {
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
externalText: (payload: string) => socketSendJson('message', { external: payload }),
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
timerSecondary: (payload: TimerMessage['secondarySource']) =>
socketSendJson('message', { timer: { secondarySource: payload } }),
};
export const usePlaybackControl = () => {
@@ -141,6 +176,14 @@ export const useTimer = () => {
return useRuntimeStore(featureSelector);
};
export const useTimerPhase = () => {
const featureSelector = (state: RuntimeStore) => ({
phase: state.timer.phase,
});
return useRuntimeStore(featureSelector);
};
export const useClock = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
@@ -187,15 +230,6 @@ export const useRuntimePlaybackOverview = () => {
return useRuntimeStore(featureSelector);
};
export const useTimelineOverview = () => {
const featureSelector = (state: RuntimeStore) => ({
plannedStart: state.runtime.plannedStart,
plannedEnd: state.runtime.plannedEnd,
});
return useRuntimeStore(featureSelector);
};
export const useTimelineStatus = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
@@ -1,3 +1,5 @@
import { EndAction, TimerType } from 'ontime-types';
import { validateEndAction, validateTimerType } from 'ontime-utils';
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
@@ -8,11 +10,15 @@ type EditorSettingsStore = {
defaultWarnTime: string;
defaultDangerTime: string;
defaultPublic: boolean;
defaultTimerType: TimerType;
defaultEndAction: EndAction;
setDefaultDuration: (defaultDuration: string) => void;
setLinkPrevious: (linkPrevious: boolean) => void;
setWarnTime: (warnTime: string) => void;
setDangerTime: (dangerTime: string) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
setDefaultTimerType: (defaultTimerType: TimerType) => void;
setDefaultEndAction: (defaultEndAction: EndAction) => void;
};
export const editorSettingsDefaults = {
@@ -21,6 +27,8 @@ export const editorSettingsDefaults = {
warnTime: '00:02:00', // 120000 same as backend
dangerTime: '00:01:00', // 60000 same as backend
isPublic: true,
timerType: TimerType.CountDown,
endAction: EndAction.None,
};
enum EditorSettingsKeys {
@@ -29,6 +37,8 @@ enum EditorSettingsKeys {
DefaultWarnTime = 'ontime-default-warn-time',
DefaultDangerTime = 'ontime-default-danger-time',
DefaultPublic = 'ontime-default-public',
DefaultTimerType = 'ontime-default-timer-type',
DefaultEndAction = 'ontime-default-end-action',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => {
@@ -38,6 +48,14 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime,
defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime,
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
defaultTimerType: validateTimerType(
localStorage.getItem(EditorSettingsKeys.DefaultTimerType),
editorSettingsDefaults.timerType,
),
defaultEndAction: validateEndAction(
localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
editorSettingsDefaults.endAction,
),
setDefaultDuration: (defaultDuration) =>
set(() => {
@@ -65,5 +83,15 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { defaultPublic };
}),
setDefaultTimerType: (defaultTimerType) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultTimerType, String(defaultTimerType));
return { defaultTimerType };
}),
setDefaultEndAction: (defaultEndAction) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction));
return { defaultEndAction };
}),
};
});
+2 -4
View File
@@ -23,11 +23,9 @@ export const runtimeStorePlaceholder: RuntimeStore = {
visible: false,
blink: false,
blackout: false,
secondarySource: null,
},
external: {
text: '',
visible: false,
},
external: '',
},
runtime: {
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 {
getClientId,
@@ -176,6 +176,17 @@ export const connectSocket = () => {
updateDevTools({ auxtimer1: payload });
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 (_) {
// ignore unhandled
@@ -147,7 +147,7 @@ export default function ViewSettingsForm() {
variant='ontime-filled'
maxLength={150}
width='275px'
placeholder='Message shown when timer reaches end'
placeholder='Shown when timer reaches end'
{...register('endMessage')}
/>
</Panel.ListItem>
@@ -7,17 +7,26 @@ import { editorSettingsDefaults, useEditorSettings } from '../../../../common/st
import * as Panel from '../PanelUtils';
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 setLinkPrevious = eventSettings.setLinkPrevious;
const setWarnTime = eventSettings.setWarnTime;
const setDangerTime = eventSettings.setDangerTime;
const setDefaultPublic = eventSettings.setDefaultPublic;
const durationInMs = parseUserTime(eventSettings.defaultDuration);
const warnTimeInMs = parseUserTime(eventSettings.defaultWarnTime);
const dangerTimeInMs = parseUserTime(eventSettings.defaultDangerTime);
const durationInMs = parseUserTime(defaultDuration);
const warnTimeInMs = parseUserTime(defaultWarnTime);
const dangerTimeInMs = parseUserTime(defaultDangerTime);
return (
<Panel.Section>
@@ -44,13 +53,19 @@ export default function EditorSettingsForm() {
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.linkPrevious}
defaultChecked={linkPrevious}
onChange={(event) => setLinkPrevious(event.target.checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Timer type' description='Default type of timer for new events' />
<Select variant='ontime' size='sm' width='auto' isDisabled>
<Select
variant='ontime'
size='sm'
width='auto'
value={defaultTimerType}
onChange={(event) => setDefaultTimerType(event.target.value as TimerType)}
>
<option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.TimeToEnd}>Time to end</option>
@@ -59,7 +74,13 @@ export default function EditorSettingsForm() {
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='End Action' description='Default end action for new events' />
<Select variant='ontime' size='sm' width='auto' isDisabled>
<Select
variant='ontime'
size='sm'
width='auto'
value={defaultEndAction}
onChange={(event) => setDefaultEndAction(event.target.value as EndAction)}
>
<option value={EndAction.None}>None</option>
<option value={EndAction.Stop}>Stop</option>
<option value={EndAction.LoadNext}>Load next</option>
@@ -93,14 +114,14 @@ export default function EditorSettingsForm() {
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.defaultPublic}
defaultChecked={defaultPublic}
onChange={(event) => setDefaultPublic(event.target.checked)}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
<Panel.Section>
<Panel.Title>Play mode</Panel.Title>
<Panel.Title>Run mode</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
@@ -42,7 +42,7 @@ export default function ManageProjects() {
const errorMessage = maybeAxiosError(error);
setError(`Error uploading file: ${errorMessage}`);
} finally {
invalidateAllCaches();
await invalidateAllCaches();
}
setLoading(null);
@@ -78,7 +78,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
</div>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<div className={style.innerColumn}>
<Panel.Section className={style.innerColumn}>
<label>
Project title
<Input
@@ -145,7 +145,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
{...register('backstageUrl')}
/>
</label>
</div>
</Panel.Section>
</Panel.Section>
);
}
@@ -9,7 +9,7 @@ export type ProjectFormValues = {
};
interface ProjectFormProps {
action: 'duplicate' | 'rename';
action: 'duplicate' | 'rename' | 'merge';
filename: string;
onCancel: () => void;
onSubmit: (values: ProjectFormValues) => Promise<void>;
@@ -11,13 +11,15 @@ import {
renameProject,
} from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../PanelUtils';
import ProjectForm, { ProjectFormValues } from './ProjectForm';
import ProjectMergeForm from './ProjectMergeForm';
import style from './ProjectPanel.module.scss';
export type EditMode = 'rename' | 'duplicate' | null;
export type EditMode = 'rename' | 'duplicate' | 'merge' | null;
interface ProjectListItemProps {
current?: boolean;
@@ -100,8 +102,10 @@ export default function ProjectListItem({
handleToggleEditMode(null, null);
};
const isCurrentlyBeingEdited = editingMode && filename === editingFilename;
const classes = current && !isCurrentlyBeingEdited ? style.current : undefined;
const isCurrentlyBeingEdited = filename === editingFilename;
const showProjectForm = (editingMode === 'rename' || editingMode === 'duplicate') && filename === editingFilename;
const showMergeForm = editingMode === 'merge' && isCurrentlyBeingEdited;
const classes = cx([current && !isCurrentlyBeingEdited && style.current, isCurrentlyBeingEdited && style.isEditing]);
return (
<>
@@ -113,7 +117,7 @@ export default function ProjectListItem({
</tr>
)}
<tr key={filename} className={classes}>
{isCurrentlyBeingEdited ? (
{showProjectForm ? (
<td colSpan={99}>
<ProjectForm
action={editingMode}
@@ -125,7 +129,7 @@ export default function ProjectListItem({
) : (
<>
<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}>
<ActionMenu
current={current}
@@ -133,12 +137,20 @@ export default function ProjectListItem({
onChangeEditMode={handleToggleEditMode}
onDelete={handleDelete}
onLoad={handleLoad}
isDisabled={loading}
isDisabled={loading || showMergeForm}
onMerge={(filename) => handleToggleEditMode('merge', filename)}
/>
</td>
</>
)}
</tr>
{showMergeForm && (
<tr>
<td colSpan={99}>
<ProjectMergeForm onClose={handleCancel} fileName={filename} />
</td>
</tr>
)}
</>
);
}
@@ -148,11 +160,12 @@ interface ActionMenuProps {
filename: string;
isDisabled: boolean;
onChangeEditMode: (editMode: EditMode, filename: string) => void;
onDelete: (filename: string) => void;
onLoad: (filename: string) => void;
onDelete: (filename: string) => Promise<void>;
onLoad: (filename: string) => Promise<void>;
onMerge: (filename: string) => void;
}
function ActionMenu(props: ActionMenuProps) {
const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad } = props;
const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad, onMerge } = props;
const handleRename = () => {
onChangeEditMode('rename', filename);
@@ -185,6 +198,9 @@ function ActionMenu(props: ActionMenuProps) {
<MenuItem onClick={() => onLoad(filename)} isDisabled={current}>
Load
</MenuItem>
<MenuItem onClick={() => onMerge(filename)} isDisabled={current}>
Partial Load
</MenuItem>
<MenuItem onClick={handleRename}>Rename</MenuItem>
<MenuItem onClick={handleDuplicate}>Duplicate</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;
}
.isEditing {
color: $blue-500;
}
.actionButton {
flex: 1;
text-align: right;
@@ -50,3 +54,10 @@
flex-direction: column;
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,
} from '@chakra-ui/react';
import { isLocalhost } from '../../../../common/api/constants';
import useElectronEvent from '../../../../common/hooks/useElectronEvent';
import * as Panel from '../PanelUtils';
@@ -31,9 +32,10 @@ export default function ShutdownPanel() {
This will shutdown the Ontime server. <br />
The runtime state will be lost, but your project is kept for next time.
</Panel.Paragraph>
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!isElectron}>
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!(isElectron || isLocalhost)}>
Shutdown ontime
</Button>
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
<AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
<AlertDialogOverlay>
<AlertDialogContent>
@@ -1,10 +1,9 @@
import { useEffect, useRef } from 'react';
import { IconButton, Input } from '@chakra-ui/react';
import { Input } from '@chakra-ui/react';
import { IoEye } from '@react-icons/all-files/io5/IoEye';
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './InputRow.module.scss';
@@ -13,15 +12,13 @@ interface InputRowProps {
label: string;
placeholder: string;
text: string;
visible?: boolean;
readonly?: boolean;
actionHandler: (action: string, payload: object) => void;
visible: boolean;
actionHandler: () => void;
changeHandler: (newValue: string) => void;
className?: string;
}
export default function InputRow(props: InputRowProps) {
const { label, placeholder, text, visible, actionHandler, changeHandler, className, readonly } = props;
const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
const inputRef = useRef<HTMLInputElement>(null);
const cursorPositionRef = useRef(0);
@@ -39,41 +36,27 @@ export default function InputRow(props: InputRowProps) {
changeHandler(event.target.value);
};
const classes = cx([style.inputRow, className]);
return (
<div className={classes}>
<div className={style.inputRow}>
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
<div className={style.inputItems}>
<Input
ref={inputRef}
size='sm'
variant='ontime-filled'
readOnly={readonly}
disabled={readonly}
value={text}
onChange={handleInputChange}
placeholder={placeholder}
/>
{readonly ? (
<IconButton
size='sm'
isDisabled
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
aria-label={`Toggle ${label}`}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
/>
) : (
<TooltipActionBtn
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label={`Toggle ${label}`}
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
)}
<TooltipActionBtn
clickHandler={actionHandler}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label={`Toggle ${label}`}
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
</div>
</div>
);
@@ -1,23 +1,3 @@
.messageContainer {
display: flex;
flex-direction: column;
gap: $section-spacing;
}
.buttonSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $element-spacing;
margin-top: -0.5rem;
}
.singleAction {
display: flex;
flex-direction: column;
gap: $element-spacing;
margin-top: $element-inner-spacing;
}
.label {
font-size: $inner-section-text-size;
color: $label-gray;
@@ -26,3 +6,89 @@
color: $action-text-color;
}
}
.previewContainer {
display: grid;
gap: $element-spacing;
grid-template-columns: 2fr 1fr;
}
.preview {
background-color: $ui-black;
display: grid;
place-content: center;
text-align: center;
position: relative;
}
.corner {
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 { IoEye } from '@react-icons/all-files/io5/IoEye';
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
import { IoSunny } from '@react-icons/all-files/io5/IoSunny';
import { IoSunnyOutline } from '@react-icons/all-files/io5/IoSunnyOutline';
import { setMessage, useMessageControl } from '../../../common/hooks/useSocket';
import { enDash } from '../../../common/utils/styleUtils';
import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
import InputRow from './InputRow';
import style from './MessageControl.module.scss';
const noop = () => undefined;
import TimerControlsPreview from './TimerViewControl';
export default function MessageControl() {
const message = useMessageControl();
const blink = message.timer.blink;
const blackout = message.timer.blackout;
return (
<div className={style.messageContainer}>
<InputRow
label='Timer'
placeholder='Message shown in stage timer'
text={message.timer.text}
visible={message.timer.visible}
changeHandler={(newValue) => setMessage.timerText(newValue)}
actionHandler={() => setMessage.timerVisible(!message.timer.visible)}
/>
<div className={style.buttonSection}>
<Button
size='sm'
className={`${blink ? style.blink : ''}`}
variant={blink ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={blink ? <IoSunny size='1rem' /> : <IoSunnyOutline size='1rem' />}
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink
</Button>
<Button
size='sm'
className={style.blackoutButton}
variant={blackout ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={blackout ? <IoEye size='1rem' /> : <IoEyeOffOutline size='1rem' />}
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout'
>
Blackout screen
</Button>
</div>
<InputRow
label='External Message (read only)'
placeholder={enDash}
readonly
text={message.external.text}
visible={message.external.visible}
changeHandler={noop}
actionHandler={noop}
/>
</div>
<>
<TimerControlsPreview />
<TimerMessageInput />
<ExternalInput />
</>
);
}
function TimerMessageInput() {
const { text, visible } = useTimerMessageInput();
return (
<InputRow
label='Timer Message'
placeholder='Message shown fullscreen in stage timer'
text={text}
visible={visible}
changeHandler={(newValue) => setMessage.timerText(newValue)}
actionHandler={() => setMessage.timerVisible(!visible)}
/>
);
}
function ExternalInput() {
const { text, visible } = useExternalMessageInput();
const toggleExternal = () => {
if (visible) {
setMessage.timerSecondary(null);
} else {
setMessage.timerSecondary('external');
}
};
return (
<InputRow
label='External Message'
placeholder='Message shown as secondary text in stage timer'
text={text}
visible={visible}
changeHandler={(newValue) => setMessage.externalText(newValue)}
actionHandler={toggleExternal}
/>
);
}
@@ -3,16 +3,19 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils';
import MessageControl from './MessageControl';
import style from '../../editors/Editor.module.scss';
const MessageControlExport = () => {
const classes = cx([style.content, style.contentColumnLayout]);
return (
<div className={style.messages} data-testid='panel-messages-control'>
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'messagecontrol')} />
<div className={style.content}>
<div className={classes}>
<ErrorBoundary>
<MessageControl />
</ErrorBoundary>
@@ -0,0 +1,78 @@
import { Tooltip } from '@chakra-ui/react';
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoFlag } from '@react-icons/all-files/io5/IoFlag';
import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { TimerPhase, TimerType } from 'ontime-types';
import { useMessagePreview } from '../../../common/hooks/useSocket';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './MessageControl.module.scss';
export default function TimerPreview() {
const { blink, blackout, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } =
useMessagePreview();
const { data } = useViewSettings();
const contentClasses = cx([style.previewContent, blink && style.blink, blackout && style.blackout]);
const main = (() => {
if (showTimerMessage) return 'Message';
if (phase === TimerPhase.Pending) return 'Standby to start';
if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message';
return 'Timer';
})();
const secondary = (() => {
// message is a fullscreen overlay
if (showTimerMessage) return null;
// we need to check aux first since it takes priority
if (showAuxTimer) return 'Aux Timer';
if (showExternalMessage) return 'External message';
return null;
})();
const overrideColour = (() => {
// override fallback colours from starter project
if (phase === TimerPhase.Warning) return data.warningColor ?? '#FFAB33';
if (phase === TimerPhase.Danger) return data.dangerColor ?? '#ED3333';
return data.normalColor ?? '#FFFC';
})();
const showColourOverride = main == 'Timer';
return (
<div className={style.preview}>
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'timer')} />
<div className={contentClasses}>
<div
className={style.mainContent}
data-phase={phase}
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
>
{main}
</div>
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
</div>
<div className={style.eventStatus}>
<Tooltip label='Time type: Count down' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoArrowDown className={style.statusIcon} data-active={timerType === TimerType.CountDown} />
</Tooltip>
<Tooltip label='Time type: Count up' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoArrowUp className={style.statusIcon} data-active={timerType === TimerType.CountUp} />
</Tooltip>
<Tooltip label='Time type: Clock' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoTime className={style.statusIcon} data-active={timerType === TimerType.Clock} />
</Tooltip>
<Tooltip label='Time type: Time to end' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoFlag className={style.statusIcon} data-active={timerType === TimerType.TimeToEnd} />
</Tooltip>
</div>
</div>
);
}
@@ -0,0 +1,62 @@
import { Button } from '@chakra-ui/react';
import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket';
import TimerPreview from './TimerPreview';
import style from './MessageControl.module.scss';
export default function TimerControlsPreview() {
const { blackout, blink, secondarySource } = useTimerViewControl();
const toggleSecondary = (newValue: 'aux' | 'external' | null) => {
if (secondarySource === newValue) {
setMessage.timerSecondary(null);
} else {
setMessage.timerSecondary(newValue);
}
};
return (
<div className={style.previewContainer}>
<TimerPreview />
<div className={style.options}>
<Button
size='sm'
variant={secondarySource === 'aux' ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => toggleSecondary('aux')}
>
Show Aux timer
</Button>
<Button
size='sm'
variant={secondarySource === 'external' ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => toggleSecondary('external')}
>
Show external
</Button>
<hr className={style.divider} />
<Button
size='sm'
variant={blink ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink
</Button>
<Button
size='sm'
className={style.blackoutButton}
variant={blackout ? 'ontime-filled' : 'ontime-subtle'}
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout'
>
Blackout screen
</Button>
</div>
</div>
);
}
@@ -68,7 +68,7 @@ function AuxTimerInput() {
const handleTimeUpdate = (_field: string, value: string) => {
const newTime = parseUserTime(value);
setDuration(newTime / 1000); //frontend api is seconds based;
setDuration(newTime / 1000); // frontend api is seconds based
};
return (
@@ -50,9 +50,15 @@ $table-header-font-size: calc(1rem - 3px);
font-size: $table-header-font-size;
color: $gray-700;
}
th {
background-color: $gray-1300;
th {
background-color: $gray-1300;
&:hover {
.resizer {
width: 0.5rem;
}
}
}
@@ -126,30 +132,19 @@ $table-header-font-size: calc(1rem - 3px);
.resizer {
cursor: col-resize;
opacity: 0;
opacity: $opacity-disabled;
display: inline-block;
width: 3px;
width: 0;
height: 100%;
position: absolute;
right: 0;
top: 0;
transform: translateX(50%);
background-color: $action-blue;
user-select: none;
touch-action: none;
transition-duration: $transition-time-action;
transition-property: width, background-color;
&:hover {
opacity: $opacity-disabled;
width: 6px;
}
&.isResizing {
opacity: 1;
width: 6px;
background-color: $action-blue;
}
}
@@ -4,8 +4,6 @@ import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils';
import styles from '../Cuesheet.module.scss';
interface SortableCellProps {
@@ -29,8 +27,6 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
transition,
};
const resizerClasses = cx([styles.resizer, header.column.getIsResizing() ? styles.isResizing : null]);
return (
<th ref={setNodeRef} style={dragStyle} colSpan={colSpan}>
<div {...attributes} {...listeners}>
@@ -41,7 +37,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(),
}}
className={resizerClasses}
className={styles.resizer}
/>
</th>
);
@@ -40,7 +40,7 @@ $panel-gap: 0.5rem;
.playback,
.messages {
position: relative;
border-radius: 8px;
border-radius: var(--editor--panel__br);
background-color: $bg-container-l2;
padding: 1rem;
}
@@ -62,3 +62,10 @@ $panel-gap: 0.5rem;
.content {
padding-top: 1.5rem;
}
.contentColumnLayout {
display: flex;
flex-direction: column;
gap: $section-spacing;
color: $ui-white;
}
@@ -1,10 +1,18 @@
@use '../../theme/ontimeColours' as *;
@use '../../theme/ontimeStyles' as *;
@mixin absolute-top-right($distance) {
// declare editor specific styling constants
:root {
--editor--panel__br: 8px;
}
@mixin corner() {
display: none;
transform: rotate(45deg);
position: absolute;
top: $distance;
right: $distance;
top: 0.5rem;
right: 0.5rem;
cursor: pointer;
color: $ui-white;
transition-property: color;
@@ -15,17 +23,11 @@
}
}
@mixin corner() {
display: none;
@include absolute-top-right(0.5rem);
transform: rotate(45deg);
}
@mixin panel() {
display: flex;
position: relative;
border-radius: 8px;
border-radius: var(--editor--panel__br);
height: 100%;
background-color: $bg-container-l2;
padding: 1rem;
@@ -21,10 +21,6 @@ import EventBlockProgressBar from './composite/EventBlockProgressBar';
import style from './EventBlock.module.scss';
const tooltipProps = {
openDelay: tooltipDelayMid,
};
interface EventBlockInnerProps {
timeStart: number;
timeEnd: number;
@@ -98,11 +94,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
</div>
<div className={style.titleSection}>
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
{isNext && (
<Tooltip label='Next event' {...tooltipProps}>
<span className={style.nextTag}>UP NEXT</span>
</Tooltip>
)}
{isNext && <span className={style.nextTag}>UP NEXT</span>}
</div>
<EventBlockPlayback
eventId={eventId}
@@ -118,17 +110,17 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
{loaded && <EventBlockProgressBar />}
</div>
<div className={style.eventStatus} tabIndex={-1}>
<Tooltip label={`Time type: ${timerType}`} {...tooltipProps}>
<Tooltip label={`Time type: ${timerType}`} openDelay={tooltipDelayMid}>
<span>
<TimerIcon type={timerType} className={style.statusIcon} />
</span>
</Tooltip>
<Tooltip label={`End action: ${endAction}`} {...tooltipProps}>
<Tooltip label={`End action: ${endAction}`} openDelay={tooltipDelayMid}>
<span>
<EndActionIcon action={endAction} className={style.statusIcon} />
</span>
</Tooltip>
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} {...tooltipProps}>
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} openDelay={tooltipDelayMid}>
<span>
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
</span>
@@ -2,13 +2,13 @@ import { ComponentType, useMemo } from 'react';
import { ViewExtendedTimer } from 'common/models/TimeManager.type';
import {
CustomFields,
Message,
MessageState,
OntimeEvent,
ProjectData,
Runtime,
Settings,
SimpleTimerState,
SupportedEvent,
TimerMessage,
ViewSettings,
} from 'ontime-types';
import { useStore } from 'zustand';
@@ -23,17 +23,17 @@ import { runtimeStore } from '../../common/stores/runtime';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
type WithDataProps = {
auxTimer: SimpleTimerState;
backstageEvents: OntimeEvent[];
customFields: CustomFields;
eventNext: OntimeEvent | null;
eventNow: OntimeEvent | null;
events: OntimeEvent[];
external: Message;
general: ProjectData;
isMirrored: boolean;
message: MessageState;
nextId: string | null;
onAir: boolean;
pres: TimerMessage;
publicEventNext: OntimeEvent | null;
publicEventNow: OntimeEvent | null;
publicSelectedId: string | null;
@@ -68,7 +68,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
}, [rundownData]);
// websocket data
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime } =
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime, auxtimer1 } =
useStore(runtimeStore);
const publicSelectedId = publicEventNow?.id ?? null;
const selectedId = eventNow?.id ?? null;
@@ -96,17 +96,17 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
<ViewNavigationMenu />
<Component
{...props}
auxTimer={auxtimer1}
backstageEvents={rundownData}
customFields={customFields}
eventNext={eventNext}
eventNow={eventNow}
events={publicEvents}
external={message.external}
general={project}
isMirrored={isMirrored}
message={message}
nextId={nextId}
onAir={onAir}
pres={message.timer}
publicEventNext={publicEventNext}
publicEventNow={publicEventNow}
publicSelectedId={publicSelectedId}
@@ -42,6 +42,11 @@ $orange-active: #f60;
grid-template-areas: 'clock schedule';
text-transform: uppercase;
&.hide-right {
grid-template-columns: 1fr;
grid-template-areas: 'clock';
}
.clock-container {
grid-area: clock;
display: grid;
@@ -115,6 +120,9 @@ $orange-active: #f60;
}
.next-title {
height: 12.5vh;
width: 70%;
font-family: monospace;
font-weight: 400;
color: var(--studio-active-label, $cyan-active);
@@ -1,20 +1,19 @@
import { useSearchParams } from 'react-router-dom';
import type { OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types';
import { isOntimeEvent, Playback } from 'ontime-types';
import { millisToString, removeSeconds } from 'ontime-utils';
import type { MaybeString, OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types';
import { Playback } from 'ontime-types';
import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/constants';
import { FitText } from '../../../common/components/fit-text/FitText';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import useFitText from '../../../common/hooks/useFitText';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { isStringBoolean } from '../common/viewUtils';
import { getStudioClockOptions } from './studioClock.options';
import { secondsInMillis, trimRundown } from './studioClock.utils';
import StudioClockSchedule from './StudioClockSchedule';
import './StudioClock.scss';
@@ -23,8 +22,8 @@ interface StudioClockProps {
eventNext: OntimeEvent | null;
time: ViewExtendedTimer;
backstageEvents: OntimeRundown;
selectedId: string | null;
nextId: string | null;
selectedId: MaybeString;
nextId: MaybeString;
onAir: boolean;
viewSettings: ViewSettings;
settings: Settings | undefined;
@@ -33,21 +32,20 @@ interface StudioClockProps {
export default function StudioClock(props: StudioClockProps) {
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, viewSettings, settings } = props;
// TODO: can we prevent the Flash of Unstyled Content on the 7segment fonts?
// deferring rendering seems to affect styling (font and useFitText)
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ minFontSize: 150, maxFontSize: 500 });
const activeIndicators = [...Array(12).keys()];
const secondsIndicators = [...Array(60).keys()];
// TODO: fit titles on screen
const MAX_TITLES = 11;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
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 hasAmPm = '';
if (clock.includes('AM')) {
@@ -65,8 +63,7 @@ export default function StudioClock(props: StudioClockProps) {
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const studioClockOptions = getStudioClockOptions(defaultFormat);
const delayed = backstageEvents.filter((event) => isOntimeEvent(event)) as OntimeEvent[];
const trimmedRundown = trimRundown(delayed, selectedId, MAX_TITLES);
const hideRight = isStringBoolean(searchParams.get('hideRight'));
let timer = millisToString(time.current, { fallback: '---' });
const hideSeconds = isStringBoolean(searchParams.get('hideTimerSeconds'));
if (time.current != null && hideSeconds) {
@@ -74,18 +71,15 @@ export default function StudioClock(props: StudioClockProps) {
}
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} />
<div className='clock-container'>
{hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>}
<div className={`studio-timer ${!hideSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
<div
ref={titleRef}
className='next-title'
style={{ fontSize: titleFontSize, height: '12.5vh', width: '100%', maxWidth: '80%' }}
>
{eventNext?.title}
</div>
<FitText className='next-title'>{eventNext?.title}</FitText>
<div
className={`
next-countdown ${isNegative ? ' next-countdown--overtime' : ''} ${isPaused ? ' next-countdown--paused' : ''}
@@ -114,31 +108,9 @@ export default function StudioClock(props: StudioClockProps) {
))}
</div>
</div>
<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>
{!hideRight && (
<StudioClockSchedule rundown={backstageEvents} selectedId={selectedId} nextId={nextId} onAir={onAir} />
)}
</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 { secondsInMillis, trimRundown } from '../studioClock.utils';
import { trimRundown } from '../studioClock.utils';
describe('test trimEventlist function', () => {
const limit = 8;
@@ -118,14 +118,3 @@ describe('test trimEventlist function', () => {
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),
{ section: 'Timer Options' },
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 { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { OntimeEvent } from 'ontime-types';
/**
* @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);
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 { 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 { useTimelineOverview } from '../../../common/hooks/useSocket';
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 { ProgressStatus, TimelineEntry } from './TimelineEntry';
import style from './Timeline.module.scss';
interface TimelineProps {
firstStart: number;
rundown: OntimeRundown;
selectedEventId: string | null;
rundown: OntimeEvent[];
totalDuration: number;
}
export default memo(Timeline);
function Timeline(props: TimelineProps) {
const { selectedEventId, rundown } = props;
const { firstStart, rundown, selectedEventId, totalDuration } = props;
const { width: screenWidth } = useViewportSize();
const { plannedStart, plannedEnd } = useTimelineOverview();
if (plannedStart === null || plannedEnd === null) {
if (totalDuration === 0) {
return null;
}
const { lastEvent } = getLastEvent(rundown);
const startHour = getStartHour(plannedStart);
const endHour = getEndHour(plannedEnd + (lastEvent?.delay ?? 0));
const startHour = getStartHour(firstStart);
const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0));
let hasTimelinePassedMidnight = false;
let previousEventStartTime: MaybeNumber = null;
// we use selectedEventId as a signifier on whether the timeline is live
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
let elapsedDays = 0;
return (
<div className={style.timeline}>
<TimelineMarkers startHour={startHour} endHour={endHour} />
<ProgressBar startHour={startHour} endHour={endHour} />
<TimelineProgressBar startHour={startHour} endHour={endHour} />
<div className={style.timelineEvents}>
{rundown.map((event) => {
// for now we dont render delays and blocks
if (!isOntimeEvent(event)) {
if (!isOntimeEvent(event) || !isPlayableEvent(event)) {
return null;
}
@@ -56,17 +55,14 @@ function Timeline(props: TimelineProps) {
eventStatus = 'live';
}
if (!hasTimelinePassedMidnight) {
// we need to offset the start to account for midnight
hasTimelinePassedMidnight = previousEventStartTime !== null && event.timeStart < previousEventStartTime;
// we only need to check for next day if we have a previous event
if (
previousEventStartTime !== null &&
checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
) {
elapsedDays++;
}
// TODO: timeline must accumulate normalised time over days
const isNextDay =
previousEventStartTime !== null
? checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
: false;
const normalisedStart = hasTimelinePassedMidnight || isNextDay ? event.timeStart + dayInMs : event.timeStart;
previousEventStartTime = normalisedStart;
const normalisedStart = event.timeStart + elapsedDays * dayInMs;
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
startHour * MILLIS_PER_HOUR,
@@ -76,6 +72,9 @@ function Timeline(props: TimelineProps) {
screenWidth,
);
// prepare values for next iteration
previousEventStartTime = normalisedStart;
return (
<TimelineEntry
key={event.id}
@@ -13,7 +13,7 @@ import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import Section from './timeline-section/TimelineSection';
import Timeline from './Timeline';
import { getTimelineOptions } from './timeline.options';
import { getFormattedTimeToStart, getScopedRundown, getUpcomingEvents } from './timeline.utils';
import { getFormattedTimeToStart, getUpcomingEvents, useScopedRundown } from './timeline.utils';
import './TimelinePage.scss';
@@ -34,15 +34,11 @@ interface TimelinePageProps {
export default function TimelinePage(props: TimelinePageProps) {
const { backstageEvents, general, selectedId, settings, time, viewSettings } = props;
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 clock = formatTime(time.clock);
// holds copy of the rundown with only relevant events
const scopedRundown = useMemo(() => {
return getScopedRundown(backstageEvents, selectedId);
}, [backstageEvents, selectedId]);
const { now, next, followedBy } = useMemo(() => {
return getUpcomingEvents(scopedRundown, selectedId);
}, [scopedRundown, selectedId]);
@@ -86,7 +82,12 @@ export default function TimelinePage(props: TimelinePageProps) {
category='next'
/>
</div>
<Timeline selectedEventId={selectedId} rundown={scopedRundown} />
<Timeline
firstStart={firstStart}
rundown={scopedRundown}
selectedEventId={selectedId}
totalDuration={totalDuration}
/>
</div>
);
}
@@ -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 {
dayInMs,
getEventWithId,
getFirstEvent,
getNextEvent,
getTimeFromPrevious,
isNewLatest,
MILLIS_PER_HOUR,
millisToString,
removeSeconds,
@@ -87,29 +91,83 @@ export function getStatusLabel(timeToStart: number, status: ProgressStatus): str
return formatDuration(timeToStart);
}
export function getScopedRundown(rundown: OntimeEvent[], selectedEventId: MaybeString): OntimeEvent[] {
if (rundown.length === 0) {
return [];
}
interface ScopedRundownData {
scopedRundown: PlayableEvent[];
firstStart: number;
totalDuration: number;
}
const params = new URL(document.location.href).searchParams;
const hideBackstage = isStringBoolean(params.get('hideBackstage'));
const hidePast = isStringBoolean(params.get('hidePast'));
export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeString): ScopedRundownData {
const [searchParams] = useSearchParams();
let scopedRundown = [...rundown];
if (hidePast && selectedEventId) {
const currentIndex = rundown.findIndex((event) => event.id === selectedEventId);
if (currentIndex >= 0) {
scopedRundown = scopedRundown.slice(currentIndex);
const data = useMemo(() => {
if (rundown.length === 0) {
return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
}
}
if (hideBackstage) {
scopedRundown = scopedRundown.filter((event) => event.isPublic);
}
const hideBackstage = isStringBoolean(searchParams.get('hideBackstage'));
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 = {
@@ -121,18 +179,17 @@ type UpcomingEvents = {
/**
* 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) {
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)) {
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;
// Return the titles, handling nulls appropriately
@@ -129,20 +129,22 @@
}
}
.external {
.secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 0.25em;
padding-block: 0.25em;
font-weight: 600;
text-align: center;
color: var(--external-color-override, $external-color);
letter-spacing: 0.5px;
line-height: 0.9em;
padding-bottom: 0.2em;
transition-property: opacity, height;
transition-duration: $viewer-transition-time;
border-top: 1px solid rgba(white, 0.1);
border-top: 1px solid color-mix(in srgb, var(--external-color-override, $external-color) 10%, transparent);
&--hidden {
opacity: 0;
@@ -167,36 +169,28 @@
.message-overlay {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: $viewer-overlay-bg-color;
z-index: -1;
padding: 2vw;
background: var(--background-color-override, $viewer-background-color);
opacity: 0;
transition: $viewer-transition-time;
transition: opacity $viewer-transition-time;
z-index: 2;
&--active {
opacity: 1;
transition: $viewer-transition-time;
transition-property: opacity;
z-index: 2;
}
}
.message {
width: inherit;
padding: 2vw;
position: absolute;
top: 50%;
left: 50%;
color: $viewer-color;
transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
font-size: 15vw;
line-height: 30vh;
display: grid;
place-content: center;
height: 100%;
width: 100%;
color: var(--color-override, $viewer-color);
text-align: center;
font-weight: 600;
}
@@ -1,22 +1,27 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { usePrevious } from '@mantine/hooks';
import { AnimatePresence, motion } from 'framer-motion';
import {
CustomFields,
Message,
MessageState,
OntimeEvent,
Playback,
Settings,
TimerMessage,
SimpleTimerState,
TimerPhase,
TimerType,
ViewSettings,
} from 'ontime-types';
import sound from '../../../assets/sounds/buzzer.mp3';
import { overrideStylesURL } from '../../../common/api/constants';
import { FitText } from '../../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import TitleCard from '../../../common/components/title-card/TitleCard';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useTimerPhase } from '../../../common/hooks/useSocket';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
@@ -47,23 +52,39 @@ const titleVariants = {
export const MotionTitleCard = motion(TitleCard);
interface TimerProps {
auxTimer: SimpleTimerState;
customFields: CustomFields;
eventNext: OntimeEvent | null;
eventNow: OntimeEvent | null;
external: Message;
isMirrored: boolean;
pres: TimerMessage;
message: MessageState;
settings: Settings | undefined;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
}
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) {
const { customFields, isMirrored, pres, eventNow, eventNext, time, viewSettings, external, settings } = props;
const { auxTimer, customFields, eventNow, eventNext, isMirrored, message, settings, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams();
usePhaseEvent();
useWindowTitle('Timer');
@@ -113,7 +134,7 @@ export default function Timer(props: TimerProps) {
const mainFieldNow = (main ? getPropertyValue(eventNow, main) : eventNow?.title) ?? '';
const mainFieldNext = (main ? getPropertyValue(eventNext, main) : eventNext?.title) ?? '';
const showOverlay = pres.text !== '' && pres.visible;
const showOverlay = message.timer.text !== '' && message.timer.visible;
const isPlaying = time.playback !== Playback.Pause;
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
@@ -127,10 +148,20 @@ export default function Timer(props: TimerProps) {
const showFinished = finished && (shouldShowModifiers || showEndMessage);
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
const showBlinking = pres.blink;
const showBlackout = pres.blackout;
const showClock = time.timerType !== TimerType.Clock;
const showExternal = external.visible && external.text;
const secondaryContent = ((): string | undefined => {
if (message.timer.secondarySource === 'aux') {
return getFormattedTimer(auxTimer.current, TimerType.CountDown, getLocalizedString('common.minutes'), {
removeSeconds: userOptions.hideTimerSeconds,
removeLeadingZero: userOptions.removeLeadingZeros,
});
}
if (message.timer.secondarySource === 'external' && message.external) {
return message.external;
}
return;
})();
let timerColor = viewSettings.normalColor;
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
@@ -145,13 +176,14 @@ export default function Timer(props: TimerProps) {
const stageTimerCharacters = display.replace('/:/g', '').length;
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`;
let timerFontSize = 89 / (stageTimerCharacters - 1);
// we need to shrink the timer if the external is going to be there
if (showExternal) {
if (secondaryContent) {
timerFontSize *= 0.8;
}
const externalFontSize = timerFontSize * 0.4;
const timerContainerClasses = `timer-container ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`;
const timerContainerClasses = `timer-container ${message.timer.blink ? (showOverlay ? '' : 'blink') : ''}`;
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
const defaultFormat = getDefaultFormat(settings?.timeFormat);
@@ -160,10 +192,12 @@ export default function Timer(props: TimerProps) {
return (
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
<ViewParamsEditor viewOptions={timerOptions} />
<div className={showBlackout ? 'blackout blackout--active' : 'blackout'} />
<div className={message.timer.blackout ? 'blackout blackout--active' : 'blackout'} />
{!userOptions.hideMessage && (
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
<FitText mode='multi' min={32} max={256} className={`message ${message.timer.blink ? 'blink' : ''}`}>
{message.timer.text}
</FitText>
</div>
)}
@@ -189,10 +223,10 @@ export default function Timer(props: TimerProps) {
</div>
)}
<div
className={`external${showExternal ? '' : ' external--hidden'}`}
className={`secondary${secondaryContent ? '' : ' secondary--hidden'}`}
style={{ fontSize: `${externalFontSize}vw` }}
>
{external.text}
{secondaryContent}
</div>
</div>
+2 -2
View File
@@ -2,5 +2,5 @@ export const tooltipDelaySlow = 1000;
export const tooltipDelayMid = 500;
export const tooltipDelayFast = 300;
export const queryRefetchInterval = 10000;
export const queryRefetchIntervalSlow = 30000;
export const queryRefetchInterval = 10000; // 10 seconds
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-secondary-color: rgba(white, 45%); // --secondary-color-override
$viewer-card-bg-color: rgba(white, 7%); // --card-background-color-override
$viewer-overlay-bg-color: rgba(black, 90%);
$element-border-radius: 8px;
// Properties related to timer
@@ -27,7 +26,7 @@ $timer-color: rgba(white, 80%); // --timer-color-override
$timer-finished-color: $playback-negative;
$timer-bold-font-family: 'Arial Black', sans-serif; // --card-background-color-override
$external-color: rgba(white, 70%); // --external-color-override
$external-color: rgba(white, 85%); // --external-color-override
// properties of other timers (clock and countdown)
$timer-label-size: clamp(16px, 1.5vw, 24px);
+9 -9
View File
@@ -1,13 +1,13 @@
export const navigatorConstants = [
{ url: '/timer', label: 'Timer' },
{ url: '/clock', label: 'Clock' },
{ url: '/minimal', label: 'Minimal Timer' },
{ url: '/backstage', label: 'Backstage' },
{ url: '/timeline', label: 'Timeline (beta)' },
{ url: '/public', label: 'Public' },
{ url: '/lower', label: 'Lower Thirds' },
{ url: '/studio', label: 'Studio Clock' },
{ url: '/countdown', label: 'Countdown' },
{ url: 'timer', label: 'Timer' },
{ url: 'clock', label: 'Clock' },
{ url: 'minimal', label: 'Minimal Timer' },
{ url: 'backstage', label: 'Backstage' },
{ url: 'timeline', label: 'Timeline (beta)' },
{ url: 'public', label: 'Public' },
{ url: 'lower', label: 'Lower Thirds' },
{ url: 'studio', label: 'Studio Clock' },
{ url: 'countdown', label: 'Countdown' },
];
// default time format to use for users in 12 hour clocks
-7
View File
@@ -174,13 +174,6 @@ app.whenReady().then(() => {
createWindow();
// register global shortcuts
// (available regardless of whether app is in focus)
// bring focus to window
globalShortcut.register('Alt+1', () => {
bringToFront();
});
startBackend()
.then((port) => {
// Load page served by node or use React dev run
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.5.0",
"version": "3.6.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -6,7 +6,7 @@
function getTrayMenu(showApp, askToQuit) {
return [
{
label: 'Show App (Alt + 1)',
label: 'Show App',
click: () => showApp(),
},
{
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.5.0",
"version": "3.6.1",
"exports": "./src/index.js",
"dependencies": {
"@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 { DeepPartial } from 'ts-essentials';
import { ONTIME_VERSION } from '../ONTIME_VERSION.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 { runtimeService } from '../services/runtime-service/RuntimeService.js';
import { eventStore } from '../stores/EventStore.js';
@@ -14,6 +16,8 @@ import { socket } from '../adapters/WebsocketAdapter.js';
import { throttle } from '../utils/throttle.js';
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
import { handleLegacyMessageConversion } from './integration.legacy.js';
const throttledUpdateEvent = throttle(updateEvent, 20);
export function dispatchFromAdapter(type: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') {
@@ -78,9 +82,12 @@ const actionHandlers: Record<string, ActionHandler> = {
message: (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> = {
timer: 'timer' in payload ? validateTimerMessage(payload.timer) : undefined,
external: 'external' in payload ? validateMessage(payload.external) : undefined,
timer: 'timer' in migratedPayload ? validateTimerMessage(migratedPayload.timer) : undefined,
external: 'external' in migratedPayload ? validateMessage(migratedPayload.external) : undefined,
};
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 { runtimeService } from './services/runtime-service/RuntimeService.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 { getState } from './stores/runtimeState.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
logger.init(escalateErrorFn);
// initialise rundown service
// initialise rundown service
const persistedRundown = getDataProvider().getRundown();
const persistedCustomFields = getDataProvider().getCustomFields();
initRundown(persistedRundown, persistedCustomFields);
@@ -210,7 +210,7 @@ export const startServer = async (
runtimeService.init(maybeRestorePoint);
// 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', () => {
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', () => {
it('should prepare first event, if we are not yet in the rundown start', () => {
const now = 7 * MILLIS_PER_HOUR;
@@ -302,7 +375,7 @@ describe('loadRoll() handle edge cases with before and after start', () => {
index: 0,
};
const state = loadRoll(singleEventList, now);
expect(state.isPending).toBeUndefined();
expect(state.isPending).toBeUndefined(); // we are playing the event
expect(state).toStrictEqual(expected);
});
@@ -443,6 +516,7 @@ describe('loadRoll() test that roll behaviour multi day event edge cases', () =>
};
const state = loadRoll(eventlist, now);
expect(state.isPending).toBeUndefined(); // we are playing the event
expect(state).toStrictEqual(expected);
});
});
@@ -49,7 +49,7 @@ export class OscIntegration implements IIntegration<OscSubscription, OSCSettings
dispatch(action: TimerLifeCycleKey, state?: object) {
// noop
if (!this.oscClient) {
if (!this.oscClient || !this.enabledOut) {
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 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 {
timer: TimerMessage;
external: Message;
let timer = { ...defaultTimer };
let external = '';
private throttledSet: PublishFn;
private publish: PublishFn | null;
let throttledSet: PublishFn | null = null;
constructor() {
if (instance) {
throw new Error('There can be only one');
}
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
instance = this;
this.throttledSet = () => {
throw new Error('Published called before initialisation');
};
this.clear();
}
clear() {
this.timer = {
text: '',
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;
}
/**
* Initialises the message service with a publish function
* @param publishFn
*/
export function init(publishFn: PublishFn) {
throttledSet = throttle(publishFn, 100);
}
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', () => {
const publishFunction = () => {};
@@ -14,17 +14,14 @@ describe('MessageService', () => {
it('should patch the message state', () => {
const message = {
timer: { text: 'new text', visible: true },
external: { visible: true },
external: 'external',
};
const newState = messageService.patch(message);
expect(newState).toEqual({
timer: { text: 'new text', visible: true, blackout: false, blink: false },
external: {
text: '',
visible: true,
},
timer: { text: 'new text', visible: true, blackout: false, blink: false, secondarySource: null },
external: 'external',
});
});
@@ -36,11 +33,8 @@ describe('MessageService', () => {
const newState = messageService.patch(initialMessage);
expect(newState).toEqual({
timer: { text: 'initial text', visible: true, blackout: false, blink: false },
external: {
text: '',
visible: false,
},
timer: { text: 'initial text', visible: true, blackout: false, blink: false, secondarySource: null },
external: '',
});
});
});
@@ -2,26 +2,7 @@ import { validateMessage, validateTimerMessage } from '../messageUtils.js';
describe('validateMessage()', () => {
it('returns a valid Message object', () => {
const payload = {
text: '12312',
visible: 'true',
};
const expected = {
text: '12312',
visible: true,
};
expect(validateMessage(payload)).toEqual(expected);
});
it('skips keys not given', () => {
const payload = {
visible: 'true',
};
const expected = {
visible: true,
};
expect(validateMessage(payload)).toStrictEqual(expected);
expect(validateMessage('test')).toEqual('test');
});
});
@@ -1,4 +1,4 @@
import { Message, TimerMessage } from 'ontime-types';
import { TimerMessage } from 'ontime-types';
import * as assert from '../../utils/assert.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
* @throws if the payload is not an object
*/
export function validateMessage(message: unknown): Partial<Message> {
assert.isObject(message);
const result: Partial<Message> = {};
if ('text' in message) result.text = coerceString(message.text);
if ('visible' in message) result.visible = coerceBoolean(message.visible);
return result;
export function validateMessage(message: unknown): string {
return decodeURI(coerceString(message));
}
/**
@@ -26,10 +20,28 @@ export function validateTimerMessage(message: unknown): 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 ('blink' in message) result.blink = coerceBoolean(message.blink);
if ('blackout' in message) result.blackout = coerceBoolean(message.blackout);
if ('secondarySource' in message) result.secondarySource = coerceSecondary(message.secondarySource);
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 { normaliseEndTime } from './timerUtils.js';
@@ -15,26 +15,11 @@ export function loadRoll(
isPending?: boolean;
} {
const { firstEvent } = getFirstEvent(timedEvents);
const { lastEvent } = getLastEvent(timedEvents);
if (!firstEvent || !lastEvent) {
if (!firstEvent) {
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
// account for number of times we went over midnight
let daySpan = 0;
@@ -46,6 +31,10 @@ export function loadRoll(
continue;
}
if (event.duration === 0) {
continue;
}
// we check if event crosses midnight
if (event.timeStart > event.timeEnd) {
daySpan++;
@@ -247,7 +247,11 @@ function notifyChanges(options: { timer?: boolean | string[]; external?: boolean
if (options.external) {
// 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 { apply } from '../delayUtils.js';
import { MILLIS_PER_HOUR } from 'ontime-utils';
describe('apply() ', () => {
describe('in a rundown without the delay field, persisted 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 } 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,
];
/**
* Small utility to fill in the necessary data for the test
*/
function makeOntimeEvent(event: Partial<OntimeEvent>): OntimeEvent {
return { ...event, type: SupportedEvent.Event, revision: 1 } as OntimeEvent;
}
const expected = [
{ id: '2', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
];
/**
* Small utility to make a delay event
*/
function makeOntimeDelay(duration: number): OntimeDelay {
return { id: 'delay', type: SupportedEvent.Delay, duration } as OntimeDelay;
}
const updatedRundown = apply(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
it('applies negative delays', () => {
const delayId = '1';
const testRundown: OntimeRundown = [
{ id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay,
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, timeStart: 20, timeEnd: 40, duration: 20, 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,
];
describe('apply()', () => {
it('applies a positive delay to the rundown', () => {
const testRundown = [
makeOntimeDelay(10),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
{ id: '3', type: SupportedEvent.Block } as OntimeBlock,
makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
];
const expected = [
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 2 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 30, duration: 20, revision: 2 } 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 updatedRundown = apply(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
it('maintains constant duration', () => {
const delayId = '1';
const testRundown: OntimeRundown = [
{ id: delayId, type: SupportedEvent.Delay, duration: -30 } as OntimeDelay,
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } 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);
});
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).not.toBe(testRundown);
expect(updatedRundown).toMatchObject([
{ id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 },
{ id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '1' },
{ id: '3' },
{ id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: null },
{ id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: '4' },
]);
});
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 = [
{
id: '2',
type: SupportedEvent.Event,
timeStart: 10,
timeEnd: 20,
duration: 10,
revision: 2,
delay: 0,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 10,
timeEnd: 20,
duration: 10,
revision: 2,
delay: 0,
} as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{
id: '5',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 0,
} as OntimeEvent,
];
it('applies negative delays', () => {
const testRundown = [
makeOntimeDelay(-10),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
{ id: '3', type: SupportedEvent.Block } as OntimeBlock,
makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
];
const updatedRundown = apply(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
it('applies negative delays', () => {
const delayId = '1';
const testRundown: OntimeRundown = [
{ id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay,
{
id: '2',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: -10,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 20,
timeEnd: 40,
duration: 20,
revision: 1,
delay: -10,
} as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{
id: '5',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 0,
} as OntimeEvent,
];
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject([
{ id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 },
{ id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null },
{ id: '3' },
{ id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: null },
{ id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '4' },
]);
});
const expected = [
{
id: '2',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 2,
delay: 0,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 10,
timeEnd: 30,
duration: 20,
revision: 2,
delay: 0,
} as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{
id: '5',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 0,
} as OntimeEvent,
];
it('should account for minimum duration and start when applying negative delays', () => {
const testRundown: OntimeRundown = [
makeOntimeDelay(-50),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: '1' }),
];
const updatedRundown = apply(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
it('maintains constant duration', () => {
const delayId = '1';
const testRundown: OntimeRundown = [
{ id: delayId, type: SupportedEvent.Delay, duration: -30 } as OntimeDelay,
{
id: '2',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: -30,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 20,
timeEnd: 40,
duration: 20,
revision: 1,
delay: -30,
} as OntimeEvent,
];
const expected = [
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 2 } as OntimeEvent,
{
id: '2',
type: SupportedEvent.Event,
timeStart: 50,
timeEnd: 100,
duration: 50,
linkStart: null,
revision: 2,
} as OntimeEvent,
];
const expected = [
{
id: '2',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 2,
delay: 0,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 20,
duration: 20,
revision: 2,
delay: 0,
} as OntimeEvent,
];
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject(expected);
});
const updatedRundown = apply(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
it('unlinks events to maintain gaps when applying positive 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: 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 { deleteAtIndex } from '../../../../../packages/utils/src/array-utils/arrayUtils.js';
import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent, OntimeEvent } from 'ontime-types';
import { getTimeFromPrevious, deleteAtIndex } from 'ontime-utils';
/**
* 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
* @param eventId
* @param rundown
* @throws {Error} if event ID not found or is not a delay
* @returns
*/
export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
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');
}
const updatedRundown = [...rundown];
const delayValue = delayEvent.duration;
if (delayValue === 0 || delayIndex === rundown.length - 1) {
// nothing to apply
return updatedRundown;
// if the delay is empty, or the last element, we can just delete it
if (delayEvent.duration === 0 || delayIndex === rundown.length - 1) {
return deleteAtIndex(delayIndex, rundown);
}
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)) {
break;
} else if (isOntimeEvent(currentEvent)) {
currentEvent.timeStart = Math.max(0, currentEvent.timeStart + delayValue);
currentEvent.timeEnd = Math.max(currentEvent.duration, currentEvent.timeEnd + delayValue);
if (currentEvent.delay) {
currentEvent.delay = currentEvent.delay - delayValue;
}
currentEvent.revision += 1;
for (let i = delayIndex + 1; i < updatedRundown.length; i++) {
const currentEntry = updatedRundown[i];
// we don't do operation on other event types
if (!isOntimeEvent(currentEntry)) {
continue;
}
// we need to remove the link in the first event to maintain the gap
let shouldUnlink = isFirstEvent;
isFirstEvent = false;
// if the event is not linked, we try and maintain gaps
if (lastEntry !== null) {
const timeFromPrevious: number = getTimeFromPrevious(
currentEntry.timeStart,
lastEntry.timeStart,
lastEntry.timeEnd,
lastEntry.duration,
);
// when applying negative delays, we need to unlink the event
// if the previous event was fully consumed by the delay
if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) {
shouldUnlink = true;
}
if (timeFromPrevious > 0) {
delayValue = Math.max(delayValue - timeFromPrevious, 0);
}
if (delayValue === 0) {
// we can bail from continuing if there are no further delays to apply
break;
}
}
// 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);
@@ -215,6 +215,7 @@ export function getMetadata() {
lastEnd,
totalDelay,
totalDuration,
revision,
};
}
@@ -243,6 +244,12 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
const { newEvent, newRundown, didMutate } = mutation({ ...params, persistedRundown });
// early return without calling side effects
if (!didMutate) {
isStale = false;
return { newEvent, newRundown, didMutate };
}
revision = revision + 1;
persistedRundown = newRundown;
@@ -232,7 +232,7 @@ class RuntimeService {
// 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)
const safeOption = typeof affectedIds === 'undefined';
const safeOption = affectedIds === undefined;
// 2. the edited event is in memory (now or next) running
// behind conditional to avoid doing unnecessary work
const eventInMemory = safeOption ? false : this.affectsLoaded(affectedIds);
+7 -2
View File
@@ -120,8 +120,10 @@ export function clear() {
runtimeState.clock = clock.timeNow();
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.secondaryTarget = null;
}
/**
@@ -264,6 +266,9 @@ export function loadNext(
return;
}
// temporarily reset this value to simplify loop logic
runtimeState.eventNext = null;
for (let i = eventIndex + 1; i < timedEvents.length; i++) {
const event = timedEvents[i];
// 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
DEV: {
if (!runtimeState.timer.duration) {
if (runtimeState.timer.duration === null) {
throw new Error('runtimeState.update: invalid state received');
}
}
+151 -19
View File
@@ -729,26 +729,37 @@ describe('test import of v2 datamodel', () => {
describe('makeString()', () => {
it('converts variables to string', () => {
let val = 2;
let expected = '2';
let converted = makeString(val);
expect(converted).toBe(expected);
const cases = [
{
val: 2,
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;
expected = '2.22222222';
converted = makeString(val);
expect(converted).toBe(expected);
// @ts-expect-error -- we know this is wrong, testing imports outside domain
val = ['testing'];
expected = 'testing';
converted = makeString(val);
expect(converted).toBe(expected);
// @ts-expect-error -- we know this is wrong, testing imports outside domain
val = { doing: 'testing' };
converted = makeString(val, 'fallback');
expect(converted).toBe('fallback');
cases.forEach(({ val, expected }) => {
const converted = makeString(val, 'fallback');
expect(converted).toBe(expected);
});
});
});
@@ -1222,6 +1233,7 @@ describe('parseExcel()', () => {
expect(result.rundown.length).toBe(1);
expect((result.rundown.at(0) as OntimeEvent).title).toBe('A song from the hearth');
});
it('imports blocks', () => {
const testdata = [
[
@@ -1388,6 +1400,126 @@ describe('parseExcel()', () => {
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', () => {
const testData = [
['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];
// 1. we check if we have set a flag for a known field
if (j === timerTypeIndex) {
if (column === 'block') {
const maybeTimeType = makeString(column, '');
if (maybeTimeType === 'block') {
event.type = SupportedEvent.Block;
} else if (column === '' || isKnownTimerType(column)) {
} else if (maybeTimeType === '' || isKnownTimerType(maybeTimeType)) {
event.type = SupportedEvent.Event;
event.timerType = validateTimerType(column);
event.timerType = validateTimerType(maybeTimeType);
} else {
// if it is not a block or a known type, we dont import it
return;
+2 -2
View File
@@ -8,9 +8,9 @@ import { deepmerge } from 'ontime-utils';
* @returns {string} - value as string or fallback if not possible
*/
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;
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');
// stage timer message
await editorPage.getByPlaceholder('Timer').click();
await editorPage.getByPlaceholder('Timer').fill('testing stage');
await editorPage.getByRole('button', { name: /toggle timer/i }).click({ timeout: 5000 });
await editorPage.getByPlaceholder('Message shown fullscreen in stage timer').click();
await editorPage.getByPlaceholder('Message shown fullscreen in stage timer').fill('testing stage');
await editorPage.getByRole('button', { name: /toggle timer message/i }).click({ timeout: 5000 });
await featurePage.goto('http://localhost:4001/timer');
await featurePage.waitForLoadState('load', { timeout: 5000 });
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.5.0",
"version": "3.6.1",
"description": "Time keeping for live events",
"keywords": [
"ontime",
@@ -1,14 +1,12 @@
export type Message = {
export type TimerMessage = {
text: string;
visible: boolean;
};
export type TimerMessage = Message & {
blink: boolean;
blackout: boolean;
secondarySource: 'aux' | 'external' | null;
};
export type MessageState = {
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 { Playback } from './definitions/runtime/Playback.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 { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
@@ -80,4 +80,4 @@ export {
isOntimeCycle,
isKeyOfType,
} 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 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,
millisToMinutes,
millisToSeconds,
secondsInMillis,
} from './src/date-utils/conversionUtils.js';
export { isTimeString } from './src/date-utils/isTimeString.js';
export {
@@ -57,4 +57,11 @@ describe('checkIsNextDay', () => {
const timeStart = 2 * MILLIS_PER_HOUR;
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;
}
if (timeStart <= previousStart) {
const normalisedPreviousEnd = previousStart + previousDuration;
const cappedStart = previousStart % dayInMs;
if (timeStart <= cappedStart) {
const normalisedPreviousEnd = cappedStart + previousDuration;
if (normalisedPreviousEnd === dayInMs) {
return true;
}
@@ -1,4 +1,4 @@
import { millisToHours, millisToMinutes, millisToSeconds } from "./conversionUtils";
import { millisToHours, millisToMinutes, millisToSeconds, secondsInMillis } from './conversionUtils';
describe('millisToSecond()', () => {
test('null values', () => {
@@ -112,3 +112,14 @@ describe('millisToHours()', () => {
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 {
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);
}