refactor: operator design review

This commit is contained in:
Carlos Valente
2025-06-20 22:24:41 +02:00
committed by Carlos Valente
parent fdd81354cc
commit 629204810f
19 changed files with 452 additions and 326 deletions
+1 -1
View File
@@ -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;
+79 -65
View File
@@ -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);
+1
View File
@@ -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();