mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 14:39:06 +00:00
wip: operator data and structure
This commit is contained in:
@@ -201,3 +201,31 @@ export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const OPERATOR_OPTIONS: ParamField[] = [
|
||||||
|
TIME_FORMAT_OPTION,
|
||||||
|
{
|
||||||
|
id: 'hidepast',
|
||||||
|
title: 'Hide Past Events',
|
||||||
|
description: 'Whether to events that have passed',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'subscribe',
|
||||||
|
title: 'Highlight Field',
|
||||||
|
description: 'Choose a field to highlight',
|
||||||
|
type: 'option',
|
||||||
|
values: {
|
||||||
|
user0: 'user0',
|
||||||
|
user1: 'user1',
|
||||||
|
user2: 'user2',
|
||||||
|
user3: 'user3',
|
||||||
|
user4: 'user4',
|
||||||
|
user5: 'user5',
|
||||||
|
user6: 'user6',
|
||||||
|
user7: 'user7',
|
||||||
|
user8: 'user8',
|
||||||
|
user9: 'user9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import { deepCompare, useRuntimeStore } from '../stores/runtime';
|
|||||||
import { socketSendJson } from '../utils/socket';
|
import { socketSendJson } from '../utils/socket';
|
||||||
|
|
||||||
export const useRundownEditor = () => {
|
export const useRundownEditor = () => {
|
||||||
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
|
selectedEventId: state.loaded.selectedEventId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useRuntimeStore(featureSelector, deepCompare);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOperator = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
playback: state.playback,
|
playback: state.playback,
|
||||||
selectedEventId: state.loaded.selectedEventId,
|
selectedEventId: state.loaded.selectedEventId,
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
// remember we are targeting phones and tablets
|
|
||||||
|
|
||||||
@use '../../../src/theme/v2Styles' as *;
|
@use '../../../src/theme/v2Styles' as *;
|
||||||
@use '../../../src/theme/ontimeColours' as *;
|
@use '../../../src/theme/ontimeColours' as *;
|
||||||
|
|
||||||
.operatorContainer {
|
.operatorContainer {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
padding: 2rem;
|
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -20,29 +17,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@mixin event-block() {
|
@mixin event-block() {
|
||||||
padding: 0.2rem;
|
height: 40px;
|
||||||
|
padding: 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 0.1rem;
|
margin: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduledEvent {
|
|
||||||
@include event-block();
|
|
||||||
background-color: $gray-1300;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activeEvent {
|
|
||||||
border-top: 1.5px white;
|
|
||||||
border-style: solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.runningTimer {
|
|
||||||
border-top: 0.5px white;
|
|
||||||
border-style: solid;
|
|
||||||
background-color: $green-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block {
|
.block {
|
||||||
@include event-block();
|
@include event-block();
|
||||||
|
|||||||
@@ -1,25 +1,30 @@
|
|||||||
import React from 'react';
|
import { UIEvent, useState } from 'react';
|
||||||
import { SupportedEvent } from 'ontime-types';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { SupportedEvent, UserFields } from 'ontime-types';
|
||||||
|
|
||||||
import NavigationMenu from '../.././common/components/navigation-menu/NavigationMenu';
|
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
||||||
|
import { OPERATOR_OPTIONS } from '../../common/components/view-params-editor/constants';
|
||||||
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
|
import { useOperator } from '../../common/hooks/useSocket';
|
||||||
import useRundown from '../../common/hooks-query/useRundown';
|
import useRundown from '../../common/hooks-query/useRundown';
|
||||||
|
|
||||||
import FocusBlock from './focus-block/focus-block';
|
import FocusBlock from './focus-block/focus-block';
|
||||||
import OpBlock from './op-block/OpBlock';
|
import OpBlock from './op-block/OpBlock';
|
||||||
import OpEvent from './op-event/OpEvent';
|
import OpEvent from './op-event/OpEvent';
|
||||||
import TimeBlock from './time-block/TimeBlock';
|
import TimeBlock from './time-block/TimeBlock';
|
||||||
|
import { getRundownEnd } from './operator.utils';
|
||||||
|
|
||||||
import style from './Operator.module.scss';
|
import style from './Operator.module.scss';
|
||||||
|
|
||||||
export default function Operator() {
|
export default function Operator() {
|
||||||
// this is the data that you need, the status flag should give you possibility to create a loading state
|
|
||||||
// for debugging data use the react query dev tools (flower thing in the bottom left corner)
|
|
||||||
const { data, status } = useRundown();
|
const { data, status } = useRundown();
|
||||||
const [showChild, setShowChild] = React.useState(false);
|
const featureData = useOperator();
|
||||||
|
const [showChild, setShowChild] = useState(false);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const handleScroll = (event: any) => {
|
const handleScroll = (event: UIEvent<HTMLElement>) => {
|
||||||
const scrollThreshold = 50; // Set the scroll threshold here
|
const scrollThreshold = 50;
|
||||||
const scrollPosition = event.target.scrollTop;
|
const scrollPosition = (event.target as HTMLElement).scrollTop;
|
||||||
|
|
||||||
setShowChild(scrollPosition > scrollThreshold);
|
setShowChild(scrollPosition > scrollThreshold);
|
||||||
};
|
};
|
||||||
@@ -28,32 +33,35 @@ export default function Operator() {
|
|||||||
return <>loading</>;
|
return <>loading</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const subscribe = searchParams.get('subscribe') as keyof UserFields | null;
|
||||||
|
const lastEvent = getRundownEnd(data);
|
||||||
|
let eventIndex = 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.operatorContainer}>
|
<div className={style.operatorContainer}>
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
|
<ViewParamsEditor paramFields={OPERATOR_OPTIONS} />
|
||||||
|
|
||||||
<div className={style.operatorEvents} onScroll={handleScroll}>
|
<div className={style.operatorEvents} onScroll={handleScroll}>
|
||||||
{data.map((entry, i) => {
|
{data.map((entry) => {
|
||||||
// there are three types of events, you a filter them by using the type property
|
|
||||||
// for this view, we do not show the delay event
|
|
||||||
|
|
||||||
// this is a scheduled event
|
|
||||||
if (entry.type === SupportedEvent.Event) {
|
if (entry.type === SupportedEvent.Event) {
|
||||||
|
eventIndex += 1;
|
||||||
return (
|
return (
|
||||||
<div
|
<OpEvent
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className={`${style.scheduledEvent} ${i % 3 == 0 ? style.activeEvent : ''} ${
|
index={eventIndex}
|
||||||
i == 4 ? style.runningTimer : ''
|
data={entry}
|
||||||
}`}
|
isSelected={featureData.selectedEventId === entry.id}
|
||||||
>
|
subscribed={subscribe || undefined}
|
||||||
<OpEvent id={i} data={entry} />
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// this is a block entry (like a section title)
|
|
||||||
if (entry.type === SupportedEvent.Block) {
|
if (entry.type === SupportedEvent.Block) {
|
||||||
return (
|
return (
|
||||||
|
// @arihanv
|
||||||
|
// we prefer moving these wrapper divs to the block
|
||||||
|
// so that the styles and all attributes can be self-contained
|
||||||
<div key={entry.id} className={style.block}>
|
<div key={entry.id} className={style.block}>
|
||||||
<OpBlock data={entry} />
|
<OpBlock data={entry} />
|
||||||
</div>
|
</div>
|
||||||
@@ -63,7 +71,7 @@ export default function Operator() {
|
|||||||
})}
|
})}
|
||||||
{showChild && <FocusBlock />}
|
{showChild && <FocusBlock />}
|
||||||
</div>
|
</div>
|
||||||
<TimeBlock />
|
<TimeBlock playback={featureData.playback} lastEvent={lastEvent} selectedEventId={featureData.selectedEventId} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { BiTargetLock } from '@react-icons/all-files/bi/BiTargetLock';
|
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
||||||
|
|
||||||
import style from './focusBlock.module.scss';
|
import style from './focusBlock.module.scss';
|
||||||
|
|
||||||
export default function FocusBlock() {
|
export default function FocusBlock() {
|
||||||
|
const handleClick = () => console.log('click follow');
|
||||||
|
|
||||||
|
// @arihavn, can we find a way to have a single button here, no need for the div
|
||||||
return (
|
return (
|
||||||
<button className={style.focusBlock}>
|
<div className={style.focusBlock}>
|
||||||
<div className={style.focusButton}>
|
<button className={style.focusButton} onClick={handleClick}>
|
||||||
<BiTargetLock size={20} />
|
<IoLocate size={16} />
|
||||||
Follow
|
Follow
|
||||||
</div>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
@use '.../../../src/theme/ontimeColours' as *;
|
@use '../../../../src/theme/ontimeColours' as *;
|
||||||
|
|
||||||
.focusBlock {
|
.focusBlock {
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
position: -webkit-sticky;
|
|
||||||
position: sticky;
|
position: sticky;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: -53px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.focusButton {
|
.focusButton {
|
||||||
margin: 10px;
|
margin: 10px;
|
||||||
background-color: $blue-500;
|
background-color: $blue-500;
|
||||||
width: fit-content;
|
padding: 0.25rem 1rem;
|
||||||
padding: 3px 15px 3px 15px;
|
border-radius: 99px;
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 0.5rem;
|
||||||
font-size: medium;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
.alias {
|
@use '../../../../src/theme/ontimeColours' as *;
|
||||||
background-color: red;
|
@import '../Operator.module.scss';
|
||||||
width: fit-content;
|
|
||||||
height: fit-content;
|
.scheduledEvent {
|
||||||
|
@include event-block();
|
||||||
|
background-color: $gray-1300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runningTimer {
|
||||||
|
background-color: $green-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cue {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background-color: $gray-1050; // to override inline
|
||||||
padding: 0.2rem;
|
padding: 0.2rem;
|
||||||
align-items: center;
|
min-width: 4em; // we do not want these to resize
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 2px;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: '\200b';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0.02px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event {
|
.event {
|
||||||
@@ -21,7 +39,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fields {
|
.fields {
|
||||||
|
display: block;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
color: $orange-500;
|
||||||
|
letter-spacing: 0.02px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
@@ -43,10 +64,3 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
color: yellow;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,38 @@
|
|||||||
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
|
import { OntimeEvent, UserFields } from 'ontime-types';
|
||||||
import { OntimeEvent } from 'ontime-types';
|
|
||||||
|
|
||||||
|
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
||||||
|
import { useTimer } from '../../../common/hooks/useSocket';
|
||||||
|
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import { formatTime } from '../../../common/utils/time';
|
import { formatTime } from '../../../common/utils/time';
|
||||||
|
|
||||||
import style from './OpEvent.module.scss';
|
import style from './OpEvent.module.scss';
|
||||||
|
|
||||||
type OpEventProps = {
|
type OpEventProps = {
|
||||||
data: OntimeEvent;
|
data: OntimeEvent;
|
||||||
id: number;
|
index: number;
|
||||||
|
isSelected: boolean;
|
||||||
|
subscribed?: keyof UserFields;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function OpEvent({ data, id }: OpEventProps) {
|
function RollingTime() {
|
||||||
|
const timer = useTimer();
|
||||||
|
return <>{formatTime(timer.current, { showSeconds: true, format: 'hh:mm:ss' })}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OpEvent({ data, index, isSelected, subscribed }: OpEventProps) {
|
||||||
const start = formatTime(data.timeStart, { format: 'hh:mm' });
|
const start = formatTime(data.timeStart, { format: 'hh:mm' });
|
||||||
const end = formatTime(data.timeEnd, { format: 'hh:mm' });
|
const end = formatTime(data.timeEnd, { format: 'hh:mm' });
|
||||||
|
|
||||||
|
const cueColours = data.colour && getAccessibleColour(data.colour);
|
||||||
|
const subscribedData = (subscribed ? data?.[subscribed] : undefined) || '';
|
||||||
|
|
||||||
|
// @arihanv when selected, the whole row should become green
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={`${isSelected ? style.runningTimer : undefined}`}>
|
||||||
<div className={style.alias}>{data.note}</div>
|
<div className={style.scheduledEvent}>
|
||||||
<div className={style.block}>
|
<div className={style.cue} style={{ ...cueColours }}>
|
||||||
|
{index}
|
||||||
|
</div>
|
||||||
<div className={style.event}>
|
<div className={style.event}>
|
||||||
<div className={style.title}>
|
<div className={style.title}>
|
||||||
{data.title} - {data.subtitle}
|
{data.title} - {data.subtitle}
|
||||||
@@ -26,15 +42,14 @@ export default function OpEvent({ data, id }: OpEventProps) {
|
|||||||
{start} - {end}
|
{start} - {end}
|
||||||
</div>
|
</div>
|
||||||
<div className={style.indicator}>
|
<div className={style.indicator}>
|
||||||
<div className={style.chevron}>
|
<DelayIndicator delayValue={data.delay} />
|
||||||
<IoChevronUp />
|
{isSelected ? <RollingTime /> : formatTime(data.duration, { showSeconds: true, format: 'hh:mm:ss' })}
|
||||||
</div>
|
|
||||||
--:--:--
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{id % 3 == 0 && <div className={style.fields}>CAM 5 Slow pan to SL</div>}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
{/** @arihanv we likely want to animate the height of the fields div */}
|
||||||
|
<div className={style.fields}>{subscribedData}</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
|
||||||
|
|
||||||
|
export function getRundownEnd(rundown: OntimeRundown): OntimeEvent | null {
|
||||||
|
if (rundown.length < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = rundown.length - 1; i > 0; i--) {
|
||||||
|
if (rundown[i].type === SupportedEvent.Event) {
|
||||||
|
return rundown[i] as OntimeEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -2,6 +2,15 @@
|
|||||||
padding: 0.65rem;
|
padding: 0.65rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock {
|
.clock {
|
||||||
@@ -9,3 +18,8 @@
|
|||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
margin-right: 1rem;
|
margin-right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,67 @@
|
|||||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
import { OntimeEvent, Playback } from 'ontime-types';
|
||||||
|
|
||||||
|
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||||
import { useTimer } from '../../../common/hooks/useSocket';
|
import { useTimer } from '../../../common/hooks/useSocket';
|
||||||
import { formatTime } from '../../../common/utils/time';
|
import { formatTime } from '../../../common/utils/time';
|
||||||
|
|
||||||
import styles from './TimeBlock.module.scss';
|
import styles from './TimeBlock.module.scss';
|
||||||
|
|
||||||
export default function TimeBlock() {
|
export default function TimeBlock({
|
||||||
|
playback,
|
||||||
|
lastEvent,
|
||||||
|
selectedEventId,
|
||||||
|
}: {
|
||||||
|
playback: Playback;
|
||||||
|
lastEvent: OntimeEvent | null;
|
||||||
|
selectedEventId: string | null;
|
||||||
|
}) {
|
||||||
const timer = useTimer();
|
const timer = useTimer();
|
||||||
|
|
||||||
|
const getTimeEnd = () => {
|
||||||
|
if (lastEvent === null) {
|
||||||
|
return '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeEnd = lastEvent.id === selectedEventId ? timer.expectedFinish : lastEvent.timeEnd;
|
||||||
|
return formatTime(timeEnd, { showSeconds: true, format: 'hh:mm:ss' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: format should be user defined
|
||||||
const timeNow = formatTime(timer.clock, {
|
const timeNow = formatTime(timer.clock, {
|
||||||
showSeconds: true,
|
showSeconds: true,
|
||||||
format: 'hh:mm:ss a',
|
format: 'hh:mm:ss',
|
||||||
|
});
|
||||||
|
|
||||||
|
const runningTime = formatTime(timer.current, {
|
||||||
|
showSeconds: true,
|
||||||
|
format: 'hh:mm:ss',
|
||||||
|
});
|
||||||
|
|
||||||
|
const elapsedTime = formatTime(timer.elapsed, {
|
||||||
|
showSeconds: true,
|
||||||
|
format: 'hh:mm:ss',
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.TimeBlock}>
|
<div className={styles.TimeBlock}>
|
||||||
<IoPlay size={17} />
|
<PlaybackIcon state={playback} />
|
||||||
<div className={styles.clock}>
|
<div className={styles.clock}>
|
||||||
<span className={styles.timer}>{timeNow}</span> <span className={styles.timer}>00:10:00</span>
|
<div className={styles.column}>
|
||||||
|
<span className={styles.label}>Time now</span>
|
||||||
|
<span className={styles.timer}>{timeNow}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.column}>
|
||||||
|
<span className={styles.label}>Time to end</span>
|
||||||
|
<span className={styles.timer}>{getTimeEnd()}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.column}>
|
||||||
|
<span className={styles.label}>Elapsed time</span>
|
||||||
|
<span className={styles.timer}>{elapsedTime}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.column}>
|
||||||
|
<span className={styles.label}>Running timer</span>
|
||||||
|
<span className={styles.timer}>{runningTime}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user