feat: operator view (#440)

* feat: operator view

* chore: smoke test operator

* refactor: small improvements from deepsource

---------

Co-authored-by: arihanv <arihanvaranasi@gmail.com>
Co-authored-by: arihanv <63890951+arihanv@users.noreply.github.com>
This commit is contained in:
Carlos Valente
2023-09-11 21:03:11 +02:00
committed by GitHub
parent 1de3e01216
commit 93fb48ea1c
53 changed files with 1100 additions and 157 deletions
@@ -0,0 +1,9 @@
@use '../../../theme/v2Styles' as *;
.delaySymbol {
svg {
font-size: 1.5rem;
color: $ontime-delay;
margin: 0 auto;
}
}
@@ -0,0 +1,40 @@
import { Tooltip } from '@chakra-ui/react';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { millisToDelayString } from '../../utils/dateConfig';
import style from './DelayIndicator.module.scss';
interface DelayIndicatorProps {
delayValue?: number;
}
export default function DelayIndicator(props: DelayIndicatorProps) {
const { delayValue } = props;
if (typeof delayValue === 'number') {
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
</Tooltip>
);
}
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
</Tooltip>
);
}
}
return null;
}
@@ -58,7 +58,7 @@ function NavigationMenu() {
const handleMirror = () => toggleMirror();
const showEditFormDrawer = () => {
searchParams.append('edit', 'true');
searchParams.set('edit', 'true');
setSearchParams(searchParams);
};
@@ -116,6 +116,10 @@ function NavigationMenu() {
Cuesheet
<IoArrowUp className={style.linkIcon} />
</Link>
<Link to='/op' className={style.link} tabIndex={0}>
Operator
<IoArrowUp className={style.linkIcon} />
</Link>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<Link
@@ -0,0 +1,42 @@
import { Tooltip } from '@chakra-ui/react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { Playback } from 'ontime-types';
import { tooltipDelayFast } from '../../../ontimeConfig';
interface PlaybackIconProps {
state: Playback;
skipTooltip?: boolean;
className?: string;
}
export default function PlaybackIcon(props: PlaybackIconProps) {
const { state, skipTooltip, className } = props;
// if timer is Pause or Armed
let label = 'Timer Paused';
let Icon = IoPause;
if (state === Playback.Roll) {
label = 'Timer Rolling';
Icon = IoPlay;
} else if (state === Playback.Play) {
label = 'Timer Playing';
Icon = IoPlay;
} else if (state === Playback.Stop) {
label = 'Timer Stopped';
Icon = IoStop;
}
if (skipTooltip) {
return <Icon className={className} />;
}
return (
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
<Icon className={className} />
</Tooltip>
);
}
@@ -1,4 +1,5 @@
import { CSSProperties } from 'react';
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
import style from './Empty.module.scss';
@@ -2,7 +2,7 @@
@use '../../../theme/ontimeColours' as *;
.drawerContent {
background-color: $gray-1200;
background-color: $gray-1250;
}
.drawerHeader {
@@ -1,3 +1,5 @@
import { UserFields } from 'ontime-types';
import { ParamField } from './types';
export const TIME_FORMAT_OPTION: ParamField = {
@@ -201,3 +203,61 @@ export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
type: 'boolean',
},
];
export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
return [
TIME_FORMAT_OPTION,
{
id: 'showseconds',
title: 'Show seconds',
description: 'Schedule shows hh:mm:ss',
type: 'boolean',
},
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
},
{
id: 'main',
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
},
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
},
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a field to highlight',
type: 'option',
values: {
user0: userFields.user0 || 'user0',
user1: userFields.user1 || 'user1',
user2: userFields.user2 || 'user2',
user3: userFields.user3 || 'user3',
user4: userFields.user4 || 'user4',
user5: userFields.user5 || 'user5',
user6: userFields.user6 || 'user6',
user7: userFields.user7 || 'user7',
user8: userFields.user8 || 'user8',
user9: userFields.user9 || 'user9',
},
},
];
};
@@ -0,0 +1,32 @@
// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts
import { useCallback, useEffect, useState } from 'react';
function getMatches(query: string): boolean {
return window.matchMedia(query).matches;
}
// TODO: debounce handleChange
export default function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(getMatches(query));
const handleChange = useCallback(() => {
setMatches(getMatches(query));
}, [query]);
useEffect(() => {
const matchMedia = window.matchMedia(query);
// Triggered at the first client-side load and if query changes
handleChange();
// Listen matchMedia
matchMedia.addEventListener('change', handleChange);
return () => {
matchMedia.removeEventListener('change', handleChange);
};
}, [handleChange, query]);
return matches;
}
@@ -0,0 +1,63 @@
import { MutableRefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: MutableRefObject<ComponentRef>,
scrollRef: MutableRefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
return;
}
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
interface UseFollowComponentProps {
followRef: MutableRefObject<HTMLElement | null>;
scrollRef: MutableRefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
setScrollFlag?: () => void;
}
export default function useFollowComponent(props: UseFollowComponentProps) {
const { followRef, scrollRef, doFollow, topOffset = 100, setScrollFlag } = props;
// when cursor moves, view should follow
useEffect(() => {
if (!doFollow) {
return;
}
if (followRef.current && scrollRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
setScrollFlag?.();
scrollToComponent(
followRef as MutableRefObject<HTMLElement>,
scrollRef as MutableRefObject<HTMLElement>,
topOffset,
);
});
}
// eslint-disable-next-line -- the prompt seems incorrect
}, [followRef?.current, scrollRef?.current]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
if (componentRef.current && containerRef.current) {
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, scrollRef!, offset);
}
},
[followRef, scrollRef, topOffset],
);
return scrollToRefComponent;
}
+8 -1
View File
@@ -4,10 +4,17 @@ import { deepCompare, useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket';
export const useRundownEditor = () => {
const featureSelector = (state: RuntimeStore) => ({
selectedEventId: state.loaded.selectedEventId,
});
return useRuntimeStore(featureSelector, deepCompare);
};
export const useOperator = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
nextEventId: state.loaded.nextEventId,
});
return useRuntimeStore(featureSelector, deepCompare);
+12
View File
@@ -0,0 +1,12 @@
export function debounce(callback: () => void, wait: number) {
let timeout: NodeJS.Timeout | null;
return () => {
if (timeout) {
return;
}
timeout = setTimeout(() => {
timeout = null;
callback();
}, wait);
};
}
+3 -3
View File
@@ -3,7 +3,7 @@ import Color from 'color';
type ColourCombination = {
backgroundColor: string;
color: string;
}
};
/**
* @description Selects text colour to maintain accessible contrast
@@ -19,11 +19,11 @@ export const getAccessibleColour = (bgColour: string): ColourCombination => {
console.log(`Unable to parse colour: ${bgColour}`);
}
}
return { backgroundColor: '#000', color: "#fffffa" };
return { backgroundColor: '#000', color: '#fffffa' };
};
/**
* @description Creates a list of classnames from array of css module conditions
* @param classNames - css modules objects
*/
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(" ");
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(' ');
+2 -1
View File
@@ -51,6 +51,7 @@ export const formatTime = (milliseconds: number | null, options?: FormatOptions,
return '...';
}
const timeFormat = resolver();
const { showSeconds = false, format: formatString = 'hh:mm a' } = options || {};
const fallback = options?.showSeconds ? 'hh:mm:ss a' : 'hh:mm a';
const { showSeconds = false, format: formatString = fallback } = options || {};
return timeFormat === '12' ? formatFromMillis(milliseconds, formatString) : millisToString(milliseconds, showSeconds);
};