mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-07 08:23:55 +00:00
refactor: operator design review
This commit is contained in:
committed by
Carlos Valente
parent
fdd81354cc
commit
629204810f
@@ -108,7 +108,7 @@ For production views
|
||||
-------------------------------------------------------------
|
||||
IP.ADDRESS:4001/editor > the control interface, same as the app
|
||||
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
||||
IP.ADDRESS:4001/operator > automated views for operators
|
||||
IP.ADDRESS:4001/op > automated views for operators
|
||||
```
|
||||
|
||||
More information is available [in our docs](https://docs.getontime.no)
|
||||
|
||||
@@ -20,11 +20,6 @@ export const useRundownEditor = createSelector((state: RuntimeStore) => ({
|
||||
nextEventId: state.eventNext?.id ?? null,
|
||||
}));
|
||||
|
||||
export const useOperator = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
}));
|
||||
|
||||
export const useTimerViewControl = createSelector((state: RuntimeStore) => ({
|
||||
blackout: state.message.timer.blackout,
|
||||
blink: state.message.timer.blink,
|
||||
|
||||
@@ -15,10 +15,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
min-height: 95vh;
|
||||
padding-bottom: 95vh;
|
||||
}
|
||||
|
||||
.editPrompt {
|
||||
@@ -28,10 +25,10 @@
|
||||
transform: translate(-50%, 0);
|
||||
text-align: center;
|
||||
|
||||
background: rgba(black, 0.6);
|
||||
background: rgba(0,0,0, 0.7);
|
||||
border-radius: 2px;
|
||||
padding: 0.5em 2em;
|
||||
color: gold;
|
||||
color: $orange-500;
|
||||
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
|
||||
@@ -1,36 +1,32 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { isOntimeEvent, OntimeEvent, SupportedEntry } from 'ontime-types';
|
||||
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { isOntimeBlock, isOntimeEvent } from 'ontime-types';
|
||||
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import { useOperator } from '../../common/hooks/useSocket';
|
||||
import { useSelectedEventId } from '../../common/hooks/useSocket';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import useSettings from '../../common/hooks-query/useSettings';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import { throttle } from '../../common/utils/throttle';
|
||||
import { getDefaultFormat } from '../../common/utils/time';
|
||||
import { getPropertyValue, isStringBoolean } from '../viewers/common/viewUtils';
|
||||
|
||||
import EditModal from './edit-modal/EditModal';
|
||||
import FollowButton from './follow-button/FollowButton';
|
||||
import OperatorBlock from './operator-block/OperatorBlock';
|
||||
import OperatorEvent from './operator-event/OperatorEvent';
|
||||
import StatusBar from './status-bar/StatusBar';
|
||||
import { getOperatorOptions } from './operator.options';
|
||||
import { getOperatorOptions, useOperatorOptions } from './operator.options';
|
||||
import type { EditEvent } from './operator.types';
|
||||
import { getEventData, makeOperatorMetadata } from './operator.utils';
|
||||
|
||||
import style from './Operator.module.scss';
|
||||
|
||||
const selectedOffset = 50;
|
||||
|
||||
export type Subscribed = { id: string; label: string; colour: string; value: string }[];
|
||||
type TitleFields = Pick<OntimeEvent, 'title'>;
|
||||
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { subscriptions: Subscribed };
|
||||
|
||||
export default function Operator() {
|
||||
const { data, status } = useRundown();
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
@@ -38,8 +34,8 @@ export default function Operator() {
|
||||
|
||||
const timeoutId = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const featureData = useOperator();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast } = useOperatorOptions();
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
const [showEditPrompt, setShowEditPrompt] = useState(false);
|
||||
@@ -59,15 +55,15 @@ export default function Operator() {
|
||||
|
||||
// reset scroll if nothing is selected
|
||||
useEffect(() => {
|
||||
if (!featureData?.selectedEventId) {
|
||||
if (!selectedEventId) {
|
||||
if (!lockAutoScroll) {
|
||||
scrollRef.current?.scrollTo(0, 0);
|
||||
}
|
||||
}
|
||||
}, [featureData?.selectedEventId, lockAutoScroll, scrollRef]);
|
||||
}, [selectedEventId, lockAutoScroll, scrollRef]);
|
||||
|
||||
const handleOffset = () => {
|
||||
if (featureData.selectedEventId) {
|
||||
if (selectedEventId) {
|
||||
scrollToComponent();
|
||||
}
|
||||
setLockAutoScroll(false);
|
||||
@@ -115,68 +111,38 @@ export default function Operator() {
|
||||
return <EmptyPage text='Loading...' />;
|
||||
}
|
||||
|
||||
// get fields which the user subscribed to
|
||||
const shouldEdit = searchParams.get('shouldEdit');
|
||||
|
||||
// subscriptions is a MultiSelect and may have multiple values
|
||||
const subscriptions = searchParams.getAll('subscribe').filter((value) => Object.hasOwn(customFields, value));
|
||||
const canEdit = shouldEdit && subscriptions.length;
|
||||
|
||||
const main = searchParams.get('main') as keyof TitleFields | null;
|
||||
const secondary = searchParams.get('secondary');
|
||||
|
||||
let isPast = Boolean(featureData.selectedEventId);
|
||||
const hidePast = isStringBoolean(searchParams.get('hidepast'));
|
||||
|
||||
const { firstEvent } = getFirstEventNormal(data.entries, data.order);
|
||||
const { lastEvent } = getLastEventNormal(data.entries, data.order);
|
||||
const canEdit = shouldEdit && subscribe.length;
|
||||
const { process } = makeOperatorMetadata(selectedEventId);
|
||||
|
||||
return (
|
||||
<div className={style.operatorContainer}>
|
||||
<ViewParamsEditor viewOptions={operatorOptions} />
|
||||
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
|
||||
|
||||
<StatusBar
|
||||
projectTitle={projectData.title}
|
||||
playback={featureData.playback}
|
||||
selectedEventId={featureData.selectedEventId}
|
||||
firstStart={firstEvent?.timeStart}
|
||||
firstId={firstEvent?.id}
|
||||
lastEnd={lastEvent?.timeEnd}
|
||||
lastId={lastEvent?.id}
|
||||
/>
|
||||
<StatusBar />
|
||||
|
||||
{canEdit && (
|
||||
<div className={`${style.editPrompt} ${showEditPrompt ? style.show : undefined}`}>
|
||||
Press and hold to edit user field
|
||||
</div>
|
||||
<div className={cx([style.editPrompt, showEditPrompt && style.show])}>Press and hold to edit user field</div>
|
||||
)}
|
||||
|
||||
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||
{data.order.map((eventId) => {
|
||||
const entry = data.entries[eventId];
|
||||
{data.order.map((entryId) => {
|
||||
const entry = data.entries[entryId];
|
||||
if (isOntimeEvent(entry)) {
|
||||
const isSelected = featureData.selectedEventId === entry.id;
|
||||
if (isSelected) {
|
||||
isPast = false;
|
||||
}
|
||||
const { isPast, isSelected, isLinkedToLoaded, totalGap } = process(entry);
|
||||
|
||||
// hide past events (if setting) and skipped events
|
||||
if ((hidePast && isPast) || entry.skip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mainField = main ? getPropertyValue(entry, main) ?? '' : entry.title;
|
||||
const secondaryField = getPropertyValue(entry, secondary) ?? '';
|
||||
const subscribedData = subscriptions
|
||||
? subscriptions.flatMap((id) => {
|
||||
if (!customFields[id]) {
|
||||
return [];
|
||||
}
|
||||
const { label, colour } = customFields[id];
|
||||
return [{ id, label, colour, value: entry.custom[id] }];
|
||||
})
|
||||
: null;
|
||||
const { mainField, secondaryField, subscribedData } = getEventData(
|
||||
entry,
|
||||
mainSource,
|
||||
secondarySource,
|
||||
subscribe,
|
||||
customFields,
|
||||
);
|
||||
|
||||
return (
|
||||
<OperatorEvent
|
||||
@@ -187,24 +153,72 @@ export default function Operator() {
|
||||
main={mainField}
|
||||
secondary={secondaryField}
|
||||
timeStart={entry.timeStart}
|
||||
timeEnd={entry.timeEnd}
|
||||
duration={entry.duration}
|
||||
delay={entry.delay}
|
||||
dayOffset={entry.dayOffset}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
isSelected={isSelected}
|
||||
subscribed={subscribedData}
|
||||
isPast={isPast}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
subscribed={subscribedData}
|
||||
totalGap={totalGap}
|
||||
onLongPress={canEdit ? handleEdit : () => undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.type === SupportedEntry.Block) {
|
||||
return <OperatorBlock key={entry.id} title={entry.title} />;
|
||||
if (isOntimeBlock(entry)) {
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
<OperatorBlock key={entry.id} title={entry.title} />
|
||||
{entry.events.map((nestedEventId) => {
|
||||
const nestedEvent = data.entries[nestedEventId];
|
||||
if (!isOntimeEvent(nestedEvent)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isPast, isSelected, isLinkedToLoaded, totalGap } = process(nestedEvent);
|
||||
|
||||
// hide past events (if setting) and skipped events
|
||||
if ((hidePast && isPast) || entry.skip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { mainField, secondaryField, subscribedData } = getEventData(
|
||||
nestedEvent,
|
||||
mainSource,
|
||||
secondarySource,
|
||||
subscribe,
|
||||
customFields,
|
||||
);
|
||||
|
||||
return (
|
||||
<OperatorEvent
|
||||
key={nestedEvent.id}
|
||||
id={nestedEvent.id}
|
||||
colour={nestedEvent.colour}
|
||||
cue={nestedEvent.cue}
|
||||
main={mainField}
|
||||
secondary={secondaryField}
|
||||
timeStart={nestedEvent.timeStart}
|
||||
duration={nestedEvent.duration}
|
||||
delay={nestedEvent.delay}
|
||||
dayOffset={nestedEvent.dayOffset}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
isSelected={isSelected}
|
||||
isPast={isPast}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
subscribed={subscribedData}
|
||||
totalGap={totalGap}
|
||||
onLongPress={canEdit ? handleEdit : () => undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
<div className={style.spacer} />
|
||||
</div>
|
||||
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { makeOperatorMetadata } from '../operator.utils';
|
||||
|
||||
describe('makeOperatorMetadata()', () => {
|
||||
it('should track past, selected states, gaps and linking', () => {
|
||||
const event1 = { id: 'event1', gap: 5, linkStart: false } as OntimeEvent;
|
||||
const event2 = { id: 'event2', gap: 10, linkStart: true } as OntimeEvent;
|
||||
const event3 = { id: 'event3', gap: 15, linkStart: true } as OntimeEvent;
|
||||
const { process } = makeOperatorMetadata('event2');
|
||||
|
||||
expect(process(event1)).toEqual({
|
||||
isPast: true,
|
||||
isSelected: false,
|
||||
totalGap: 5,
|
||||
isLinkedToLoaded: false,
|
||||
});
|
||||
expect(process(event2)).toEqual({
|
||||
isPast: false,
|
||||
isSelected: true,
|
||||
totalGap: 15,
|
||||
isLinkedToLoaded: false,
|
||||
});
|
||||
expect(process(event3)).toEqual({
|
||||
isPast: false,
|
||||
isSelected: false,
|
||||
totalGap: 30,
|
||||
isLinkedToLoaded: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null selectedId', () => {
|
||||
const event1 = { id: 'event1', gap: 5, linkStart: true } as OntimeEvent;
|
||||
const event2 = { id: 'event2', gap: 10, linkStart: false } as OntimeEvent;
|
||||
const event3 = { id: 'event3', gap: 15, linkStart: true } as OntimeEvent;
|
||||
const { process } = makeOperatorMetadata(null);
|
||||
|
||||
expect(process(event1)).toEqual({
|
||||
isPast: false,
|
||||
isSelected: false,
|
||||
totalGap: 5,
|
||||
isLinkedToLoaded: true,
|
||||
});
|
||||
expect(process(event2)).toEqual({
|
||||
isPast: false,
|
||||
isSelected: false,
|
||||
totalGap: 15,
|
||||
isLinkedToLoaded: false,
|
||||
});
|
||||
expect(process(event3)).toEqual({
|
||||
isPast: false,
|
||||
isSelected: false,
|
||||
totalGap: 30,
|
||||
isLinkedToLoaded: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should break linking chain on countToEnd events', () => {
|
||||
const event1 = { id: 'event1', gap: 5, linkStart: true, countToEnd: false } as OntimeEvent;
|
||||
const event2 = { id: 'event2', gap: 10, linkStart: true, countToEnd: true } as OntimeEvent;
|
||||
const event3 = { id: 'event3', gap: 15, linkStart: true, countToEnd: false } as OntimeEvent;
|
||||
const { process } = makeOperatorMetadata(null);
|
||||
|
||||
expect(process(event1)).toEqual({
|
||||
isPast: false,
|
||||
isSelected: false,
|
||||
totalGap: 5,
|
||||
isLinkedToLoaded: true,
|
||||
});
|
||||
expect(process(event2)).toEqual({
|
||||
isPast: false,
|
||||
isSelected: false,
|
||||
totalGap: 15,
|
||||
isLinkedToLoaded: true,
|
||||
});
|
||||
expect(process(event3)).toEqual({
|
||||
isPast: false,
|
||||
isSelected: false,
|
||||
totalGap: 30,
|
||||
isLinkedToLoaded: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,5 @@
|
||||
@import '../Operator.module.scss';
|
||||
|
||||
@mixin clock-size {
|
||||
font-size: calc(1rem - 2px);
|
||||
@media (min-width: $min-tablet) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.event {
|
||||
opacity: 1;
|
||||
border-top: 1px solid $white-1;
|
||||
@@ -30,7 +23,7 @@
|
||||
|
||||
&.running {
|
||||
border-top: 1px solid $gray-1300;
|
||||
background-color: var(--operator-running-bg-override, $active-red);
|
||||
background-color: var(--operator-running-bg-override, $active-green);
|
||||
}
|
||||
|
||||
&.past {
|
||||
@@ -63,7 +56,6 @@
|
||||
.mainField {
|
||||
grid-area: main;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.5px;
|
||||
color: $ui-white;
|
||||
|
||||
@include ellipsis-text;
|
||||
@@ -72,21 +64,29 @@
|
||||
.secondaryField {
|
||||
grid-area: secondary;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: 0.5px;
|
||||
@include ellipsis-text;
|
||||
line-height: 1em;
|
||||
// allow multi-line text but trim before
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.schedule {
|
||||
@include clock-size;
|
||||
.timeUntil {
|
||||
font-size: calc(1rem - 2px);
|
||||
line-height: 1em;
|
||||
grid-area: schedule;
|
||||
justify-self: end;
|
||||
background-color: $gray-1000;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.runningTime {
|
||||
@include clock-size;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1em;
|
||||
grid-area: running;
|
||||
justify-self: end;
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
row-gap: 0.25em;
|
||||
line-height: 1.25em;
|
||||
|
||||
.field {
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { memo, RefObject, SyntheticEvent } from 'react';
|
||||
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
||||
import useLongPress from '../../../common/hooks/useLongPress';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import ClockTime from '../../viewers/common/clock-time/ClockTime';
|
||||
import { formatDuration, useTimeUntilStart } from '../../../common/utils/time';
|
||||
import RunningTime from '../../viewers/common/running-time/RunningTime';
|
||||
import type { EditEvent, Subscribed } from '../Operator';
|
||||
import type { EditEvent, Subscribed } from '../operator.types';
|
||||
|
||||
import style from './OperatorEvent.module.scss';
|
||||
|
||||
@@ -17,40 +17,37 @@ interface OperatorEventProps {
|
||||
main: string;
|
||||
secondary: string;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
delay?: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
isSelected: boolean;
|
||||
subscribed: Subscribed | null;
|
||||
isPast: boolean;
|
||||
selectedRef?: RefObject<HTMLDivElement>;
|
||||
subscribed: Subscribed;
|
||||
totalGap: number;
|
||||
onLongPress: (event: EditEvent) => void;
|
||||
}
|
||||
|
||||
// extract this to contain re-renders
|
||||
function RollingTime() {
|
||||
const { current } = useTimer();
|
||||
return <RunningTime value={current} />;
|
||||
}
|
||||
|
||||
function OperatorEvent(props: OperatorEventProps) {
|
||||
const {
|
||||
id,
|
||||
colour,
|
||||
cue,
|
||||
main,
|
||||
secondary,
|
||||
timeStart,
|
||||
timeEnd,
|
||||
duration,
|
||||
delay,
|
||||
isSelected,
|
||||
subscribed,
|
||||
isPast,
|
||||
selectedRef,
|
||||
onLongPress,
|
||||
} = props;
|
||||
|
||||
export default memo(OperatorEvent);
|
||||
function OperatorEvent({
|
||||
id,
|
||||
colour,
|
||||
cue,
|
||||
main,
|
||||
secondary,
|
||||
timeStart,
|
||||
duration,
|
||||
delay,
|
||||
dayOffset,
|
||||
isLinkedToLoaded,
|
||||
isSelected,
|
||||
isPast,
|
||||
selectedRef,
|
||||
subscribed,
|
||||
totalGap,
|
||||
onLongPress,
|
||||
}: OperatorEventProps) {
|
||||
const handleLongPress = (event?: SyntheticEvent) => {
|
||||
// we dont have an event out of useLongPress
|
||||
event?.preventDefault();
|
||||
@@ -76,38 +73,93 @@ function OperatorEvent(props: OperatorEventProps) {
|
||||
</div>
|
||||
|
||||
<span className={style.mainField}>{main}</span>
|
||||
<span className={style.schedule}>
|
||||
<ClockTime value={timeStart} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
→
|
||||
<ClockTime value={timeEnd} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
</span>
|
||||
|
||||
<span className={style.secondaryField}>{secondary}</span>
|
||||
|
||||
<OperatorEventSchedule
|
||||
timeStart={timeStart}
|
||||
isPast={isPast}
|
||||
isSelected={isSelected}
|
||||
delay={delay}
|
||||
dayOffset={dayOffset}
|
||||
totalGap={totalGap}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
/>
|
||||
<span className={style.runningTime}>
|
||||
<DelayIndicator delayValue={delay} />
|
||||
{isSelected ? <RollingTime /> : <RunningTime value={duration} hideLeadingZero />}
|
||||
<RunningTime className={cx([isSelected && style.muted])} value={duration} hideLeadingZero />
|
||||
</span>
|
||||
|
||||
<div className={style.fields}>
|
||||
{subscribed &&
|
||||
subscribed
|
||||
.filter((field) => field.value)
|
||||
.map((field) => {
|
||||
const fieldClasses = cx([style.field, !field.colour ? style.noColour : null]);
|
||||
return (
|
||||
<div key={field.id}>
|
||||
<span className={fieldClasses} style={{ backgroundColor: field.colour }}>
|
||||
{field.label}
|
||||
</span>
|
||||
<span className={style.value} style={{ color: field.colour }}>
|
||||
{field.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{subscribed
|
||||
.filter((field) => field.value)
|
||||
.map((field) => {
|
||||
const fieldClasses = cx([style.field, !field.colour ? style.noColour : null]);
|
||||
return (
|
||||
<div key={field.id}>
|
||||
<span className={fieldClasses} style={{ backgroundColor: field.colour }}>
|
||||
{field.label}
|
||||
</span>
|
||||
<span className={style.value} style={{ color: field.colour }}>
|
||||
{field.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(OperatorEvent);
|
||||
interface OperatorEventScheduleProps {
|
||||
timeStart: number;
|
||||
isPast: boolean;
|
||||
isSelected: boolean;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
function OperatorEventSchedule({
|
||||
timeStart,
|
||||
isPast,
|
||||
isSelected,
|
||||
delay,
|
||||
dayOffset,
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
}: OperatorEventScheduleProps) {
|
||||
if (isPast) {
|
||||
return <span className={style.timeUntil}>DONE</span>;
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
return <span className={style.timeUntil}>LIVE</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TimeUntil
|
||||
timeStart={timeStart}
|
||||
delay={delay}
|
||||
dayOffset={dayOffset}
|
||||
totalGap={totalGap}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface TimeUntilProps {
|
||||
timeStart: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }: TimeUntilProps) {
|
||||
// we isolate this to avoid unnecessary re-renders
|
||||
const timeUntil = useTimeUntilStart({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded });
|
||||
|
||||
const isDue = timeUntil < MILLIS_PER_SECOND;
|
||||
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
|
||||
|
||||
return <span className={style.timeUntil}>{timeUntilString}</span>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { CustomFields } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { CustomFields, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
|
||||
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
||||
@@ -7,6 +9,7 @@ import {
|
||||
makeCustomFieldSelectOptions,
|
||||
makeOptionsFromCustomFields,
|
||||
} from '../../common/components/view-params-editor/viewParams.utils';
|
||||
import { isStringBoolean } from '../viewers/common/viewUtils';
|
||||
|
||||
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
|
||||
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
||||
@@ -27,7 +30,7 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
||||
defaultValue: 'title',
|
||||
},
|
||||
{
|
||||
id: 'secondary',
|
||||
id: 'secondary-src',
|
||||
title: 'Secondary data field',
|
||||
description: 'Field to be shown in the second line of text',
|
||||
type: 'option',
|
||||
@@ -55,7 +58,7 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'hidepast',
|
||||
id: 'hidePast',
|
||||
title: 'Hide Past Events',
|
||||
description: 'Whether to hide events that have passed',
|
||||
type: 'boolean',
|
||||
@@ -65,3 +68,35 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
type OperatorOptions = {
|
||||
mainSource: keyof OntimeEvent | null;
|
||||
secondarySource: keyof OntimeEvent | null;
|
||||
subscribe: string[];
|
||||
shouldEdit: boolean;
|
||||
hidePast: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility extract the view options from URL Params
|
||||
* the names and fallback are manually matched with timerOptions
|
||||
*/
|
||||
function getOptionsFromParams(searchParams: URLSearchParams): OperatorOptions {
|
||||
// we manually make an object that matches the key above
|
||||
return {
|
||||
mainSource: searchParams.get('main') as keyof OntimeEvent | null,
|
||||
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
|
||||
subscribe: searchParams.getAll('subscribe'),
|
||||
shouldEdit: isStringBoolean(searchParams.get('shouldEdit')),
|
||||
hidePast: isStringBoolean(searchParams.get('hidePast')),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook exposes the operator view options
|
||||
*/
|
||||
export function useOperatorOptions(): OperatorOptions {
|
||||
const [searchParams] = useSearchParams();
|
||||
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
export type Subscribed = { id: string; label: string; colour: string; value: string }[];
|
||||
export type TitleFields = Pick<OntimeEvent, 'title'>;
|
||||
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { subscriptions: Subscribed };
|
||||
@@ -0,0 +1,74 @@
|
||||
import { CustomFields, EntryId, MaybeString, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { getPropertyValue } from '../viewers/common/viewUtils';
|
||||
|
||||
import type { Subscribed } from './operator.types';
|
||||
|
||||
type OperatorMetadata = {
|
||||
isLinkedToLoaded: boolean;
|
||||
isPast: boolean;
|
||||
isSelected: boolean;
|
||||
totalGap: number;
|
||||
};
|
||||
|
||||
export function makeOperatorMetadata(selectedId: EntryId | null) {
|
||||
const hasSelection = Boolean(selectedId);
|
||||
let hasSeenSelected = false;
|
||||
let totalGap = 0;
|
||||
/** if the event can link all the way back to the currently playing event */
|
||||
let isLinkedToLoaded = false;
|
||||
let previousEvent: OntimeEvent | null = null;
|
||||
|
||||
function process(event: OntimeEvent): Readonly<OperatorMetadata> {
|
||||
const isSelected = event.id === selectedId;
|
||||
if (isSelected) {
|
||||
hasSeenSelected = true;
|
||||
}
|
||||
|
||||
// is past if we havent yet seen the selected event
|
||||
const isPast = hasSelection && !hasSeenSelected;
|
||||
totalGap += event.gap;
|
||||
|
||||
if (!isPast && !isSelected) {
|
||||
/**
|
||||
* isLinkToLoaded is a chain value that we maintain until we
|
||||
* a) find an unlinked event
|
||||
* b) find a countToEnd event
|
||||
*/
|
||||
isLinkedToLoaded = event.linkStart && !previousEvent?.countToEnd;
|
||||
}
|
||||
|
||||
previousEvent = event;
|
||||
return { isPast, isSelected, totalGap, isLinkedToLoaded };
|
||||
}
|
||||
|
||||
return { process };
|
||||
}
|
||||
|
||||
export function getEventData(
|
||||
event: OntimeEvent,
|
||||
main: MaybeString,
|
||||
secondary: MaybeString,
|
||||
subscriptions: string[],
|
||||
customFields: CustomFields,
|
||||
) {
|
||||
const mainField = main ? getPropertyValue(event, main) ?? '' : event.title;
|
||||
const secondaryField = getPropertyValue(event, secondary) ?? '';
|
||||
|
||||
// remove subscriptions that are not in customFields
|
||||
const sanitisedSubscriptions = subscriptions.filter((field) => Object.hasOwn(customFields, field));
|
||||
const subscribedData = sanitisedSubscriptions.reduce<Subscribed>((acc, id) => {
|
||||
const field = customFields[id];
|
||||
if (field) {
|
||||
acc.push({
|
||||
id,
|
||||
label: field.label,
|
||||
colour: field.colour,
|
||||
value: event.custom[id],
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return { mainField, secondaryField, subscribedData };
|
||||
}
|
||||
@@ -1,110 +1,56 @@
|
||||
@mixin column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
background-color: $gray-1350;
|
||||
z-index: 2;
|
||||
|
||||
background-color: $gray-1350;
|
||||
border-bottom: 1px solid $white-10;
|
||||
box-shadow: $large-top-drawer-shadow;
|
||||
}
|
||||
|
||||
.timers {
|
||||
display: grid;
|
||||
padding: 0.5rem 1rem;
|
||||
grid-template-areas:
|
||||
"playback timer1B timer2B timer3B";
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
column-gap: 1.5rem;
|
||||
align-items: center;
|
||||
padding-block: 0.75rem 0.5rem;
|
||||
padding-inline: 1rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-areas: '. timers clock';
|
||||
}
|
||||
|
||||
.playbackIcon {
|
||||
grid-area: playback;
|
||||
font-size: 2rem;
|
||||
color: $gray-700;
|
||||
|
||||
&.active {
|
||||
color: $ui-white;
|
||||
}
|
||||
.runningTimer {
|
||||
grid-area: timers;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-self: center;
|
||||
color: $muted-gray;
|
||||
}
|
||||
|
||||
.timeNow {
|
||||
grid-area: timer1B;
|
||||
@include column;
|
||||
}
|
||||
|
||||
.elapsedTime {
|
||||
grid-area: timer2B;
|
||||
@include column;
|
||||
}
|
||||
|
||||
.runningTime {
|
||||
grid-area: timer3B;
|
||||
@include column;
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
font-size: 1.25rem;
|
||||
padding-left: 0.25rem;
|
||||
display: none;
|
||||
line-height: 1.25em;
|
||||
}
|
||||
|
||||
.startTime {
|
||||
grid-area: timer2A;
|
||||
@include column;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.endTime {
|
||||
grid-area: timer3A;
|
||||
@include column;
|
||||
display: none;
|
||||
grid-area: clock;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-self: right;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $gray-700;
|
||||
line-height: 0.9em;
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// tablet
|
||||
@media (min-width: $min-tablet) {
|
||||
.timers {
|
||||
grid-template-areas:
|
||||
"playback timer1B timer2A timer3A"
|
||||
"title title timer2B timer3B";
|
||||
row-gap: 0.25rem;
|
||||
column-gap: 2rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.startTime {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.endTime {
|
||||
display: flex;
|
||||
}
|
||||
.active {
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.progressOverride {
|
||||
grid-area: bar;
|
||||
height: 1rem;
|
||||
--progress-bar-br: 0;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Playback } from 'ontime-types';
|
||||
|
||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||
|
||||
import StatusBarProgress from './StatusBarProgress';
|
||||
@@ -7,32 +5,12 @@ import StatusBarTimers from './StatusBarTimers';
|
||||
|
||||
import styles from './StatusBar.module.scss';
|
||||
|
||||
interface StatusBarProps {
|
||||
projectTitle: string;
|
||||
playback: Playback;
|
||||
selectedEventId: string | null;
|
||||
firstStart?: number;
|
||||
firstId?: string;
|
||||
lastEnd?: number;
|
||||
lastId?: string;
|
||||
}
|
||||
|
||||
export default function StatusBar(props: StatusBarProps) {
|
||||
const { projectTitle, playback, selectedEventId, firstStart, firstId, lastEnd, lastId } = props;
|
||||
|
||||
export default function StatusBar() {
|
||||
const { data } = useViewSettings();
|
||||
|
||||
return (
|
||||
<div className={styles.statusBar}>
|
||||
<StatusBarTimers
|
||||
projectTitle={projectTitle}
|
||||
playback={playback}
|
||||
selectedEventId={selectedEventId}
|
||||
firstStart={firstStart}
|
||||
firstId={firstId}
|
||||
lastEnd={lastEnd}
|
||||
lastId={lastId}
|
||||
/>
|
||||
<StatusBarTimers />
|
||||
{data && <StatusBarProgress viewSettings={data} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,8 +9,7 @@ interface StatusBarProgressProps {
|
||||
viewSettings: ViewSettings;
|
||||
}
|
||||
|
||||
export default function StatusBarProgress(props: StatusBarProgressProps) {
|
||||
const { viewSettings } = props;
|
||||
export default function StatusBarProgress({ viewSettings }: StatusBarProgressProps) {
|
||||
const { current, duration, timeWarning, timeDanger } = useProgressData();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { MaybeNumber, Playback } from 'ontime-types';
|
||||
import { isPlaybackActive } from 'ontime-utils';
|
||||
|
||||
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||
import { useClock, useTimer } from '../../../common/hooks/useSocket';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import ClockTime from '../../viewers/common/clock-time/ClockTime';
|
||||
@@ -9,79 +7,27 @@ import RunningTime from '../../viewers/common/running-time/RunningTime';
|
||||
|
||||
import styles from './StatusBar.module.scss';
|
||||
|
||||
interface StatusBarTimersProps {
|
||||
projectTitle: string;
|
||||
playback: Playback;
|
||||
selectedEventId: string | null;
|
||||
firstStart?: number;
|
||||
firstId?: string;
|
||||
lastEnd?: number;
|
||||
lastId?: string;
|
||||
}
|
||||
|
||||
export default function StatusBarTimers(props: StatusBarTimersProps) {
|
||||
const { projectTitle, playback, selectedEventId, firstStart, firstId, lastEnd, lastId } = props;
|
||||
|
||||
export default function StatusBarTimers() {
|
||||
const timer = useTimer();
|
||||
const { clock } = useClock();
|
||||
|
||||
const getTimeStart = (): MaybeNumber => {
|
||||
if (firstStart === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selectedEventId) {
|
||||
if (firstId === selectedEventId) {
|
||||
return timer.expectedFinish;
|
||||
}
|
||||
}
|
||||
return firstStart;
|
||||
};
|
||||
|
||||
const getTimeEnd = (): MaybeNumber => {
|
||||
if (lastEnd === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selectedEventId) {
|
||||
if (lastId === selectedEventId) {
|
||||
return timer.expectedFinish;
|
||||
}
|
||||
}
|
||||
return lastEnd;
|
||||
};
|
||||
|
||||
const PlaybackIconComponent = useMemo(() => {
|
||||
const isPlaying = playback === Playback.Play || playback === Playback.Roll;
|
||||
const classes = cx([styles.playbackIcon, isPlaying ? styles.active : null]);
|
||||
return <PlaybackIcon state={playback} skipTooltip className={classes} />;
|
||||
}, [playback]);
|
||||
const playbackActive = isPlaybackActive(timer.playback);
|
||||
|
||||
return (
|
||||
<div className={styles.timers}>
|
||||
{PlaybackIconComponent}
|
||||
<div className={styles.runningTimer}>
|
||||
<span className={styles.label}>Running timer</span>
|
||||
<RunningTime
|
||||
className={cx([styles.timer, playbackActive && styles.active])}
|
||||
value={timer.current}
|
||||
hideLeadingZero
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.timeNow}>
|
||||
<span className={styles.label}>Time now</span>
|
||||
<ClockTime className={styles.timer} value={clock} />
|
||||
</div>
|
||||
<div className={styles.elapsedTime}>
|
||||
<span className={styles.label}>Elapsed time</span>
|
||||
<RunningTime className={styles.timer} value={timer.elapsed} />
|
||||
</div>
|
||||
<div className={styles.runningTime}>
|
||||
<span className={styles.label}>Running timer</span>
|
||||
<RunningTime className={styles.timer} value={timer.current} />
|
||||
</div>
|
||||
|
||||
<span className={styles.title}>{projectTitle}</span>
|
||||
<div className={styles.startTime}>
|
||||
<span className={styles.label}>Scheduled start</span>
|
||||
<ClockTime className={styles.timer} value={getTimeStart()} />
|
||||
</div>
|
||||
<div className={styles.endTime}>
|
||||
<span className={styles.label}>Scheduled end</span>
|
||||
<ClockTime className={styles.timer} value={getTimeEnd()} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
import { dayInMs, millisToString } from 'ontime-utils';
|
||||
|
||||
import { enDash, timerPlaceholder } from '../../common/utils/styleUtils';
|
||||
import { enDash, timerPlaceholder, timerPlaceholderMin } from '../../common/utils/styleUtils';
|
||||
|
||||
/**
|
||||
* Encapsulates the logic for formatting time in overview
|
||||
* @param time
|
||||
* @returns
|
||||
*/
|
||||
export function formatedTime(time: MaybeNumber) {
|
||||
return millisToString(time, { fallback: timerPlaceholder });
|
||||
export function formatedTime(time: MaybeNumber, segments: number = 3): string {
|
||||
return millisToString(time, { fallback: segments === 3 ? timerPlaceholder : timerPlaceholderMin });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,7 +35,7 @@ $skip-opacity: 0.2;
|
||||
}
|
||||
|
||||
&.play {
|
||||
background-color: $green-700;
|
||||
background-color: $active-green;
|
||||
@include declare-overrides;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*/
|
||||
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
import { millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils';
|
||||
import { removeLeadingZero, removeSeconds } from 'ontime-utils';
|
||||
|
||||
import { formatedTime } from '../../../overview/overviewUtils';
|
||||
|
||||
interface RunningTimeProps {
|
||||
value: MaybeNumber;
|
||||
@@ -14,7 +16,7 @@ interface RunningTimeProps {
|
||||
|
||||
export default function RunningTime(props: RunningTimeProps) {
|
||||
const { value, hideSeconds, hideLeadingZero, className } = props;
|
||||
let formattedTime = millisToString(value);
|
||||
let formattedTime = formatedTime(value, hideSeconds || hideLeadingZero ? 2 : 3);
|
||||
|
||||
if (hideLeadingZero) {
|
||||
formattedTime = removeLeadingZero(formattedTime);
|
||||
|
||||
@@ -16,6 +16,7 @@ $warning-orange: $orange-500;
|
||||
$info-blue: $blue-500;
|
||||
$opacity-disabled: 0.4;
|
||||
$active-red: $red-700;
|
||||
$active-green: $green-700;
|
||||
|
||||
// playback colours
|
||||
$playback-start: $green-600;
|
||||
|
||||
@@ -79,7 +79,7 @@ function getOptionsFromParams(searchParams: URLSearchParams): CountdownOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook exposes the backstage view options
|
||||
* Hook exposes the countdown view options
|
||||
*/
|
||||
export function useCountdownOptions(): CountdownOptions {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
Reference in New Issue
Block a user