diff --git a/README.md b/README.md index ab81f4d79..131ef930f 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index d0f1636c2..ce2a9aad3 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -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, diff --git a/apps/client/src/features/operator/Operator.module.scss b/apps/client/src/features/operator/Operator.module.scss index 325e370db..26bc79976 100644 --- a/apps/client/src/features/operator/Operator.module.scss +++ b/apps/client/src/features/operator/Operator.module.scss @@ -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; diff --git a/apps/client/src/features/operator/Operator.tsx b/apps/client/src/features/operator/Operator.tsx index 57d8abf44..8e484bd78 100644 --- a/apps/client/src/features/operator/Operator.tsx +++ b/apps/client/src/features/operator/Operator.tsx @@ -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; -export type EditEvent = Pick & { 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(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 ; } - // 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 (
{editEvent && setEditEvent(null)} />} - + {canEdit && ( -
- Press and hold to edit user field -
+
Press and hold to edit user field
)}
- {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 ( undefined} /> ); } - if (entry.type === SupportedEntry.Block) { - return ; + if (isOntimeBlock(entry)) { + return ( + + + {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 ( + undefined} + /> + ); + })} + + ); } return null; })} -
diff --git a/apps/client/src/features/operator/__tests__/operator.utils.test.ts b/apps/client/src/features/operator/__tests__/operator.utils.test.ts new file mode 100644 index 000000000..3011ae877 --- /dev/null +++ b/apps/client/src/features/operator/__tests__/operator.utils.test.ts @@ -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, + }); + }); +}); diff --git a/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss b/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss index b65900133..d2453987e 100644 --- a/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss +++ b/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss @@ -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; diff --git a/apps/client/src/features/operator/operator-event/OperatorEvent.tsx b/apps/client/src/features/operator/operator-event/OperatorEvent.tsx index e90b98c86..3a1724051 100644 --- a/apps/client/src/features/operator/operator-event/OperatorEvent.tsx +++ b/apps/client/src/features/operator/operator-event/OperatorEvent.tsx @@ -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; + subscribed: Subscribed; + totalGap: number; onLongPress: (event: EditEvent) => void; } -// extract this to contain re-renders -function RollingTime() { - const { current } = useTimer(); - return ; -} - -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) {
{main} - - - → - - - {secondary} + + - {isSelected ? : } +
- {subscribed && - subscribed - .filter((field) => field.value) - .map((field) => { - const fieldClasses = cx([style.field, !field.colour ? style.noColour : null]); - return ( -
- - {field.label} - - - {field.value} - -
- ); - })} + {subscribed + .filter((field) => field.value) + .map((field) => { + const fieldClasses = cx([style.field, !field.colour ? style.noColour : null]); + return ( +
+ + {field.label} + + + {field.value} + +
+ ); + })}
); } -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 DONE; + } + + if (isSelected) { + return LIVE; + } + + return ( + + ); +} + +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 {timeUntilString}; +} diff --git a/apps/client/src/features/operator/operator.options.tsx b/apps/client/src/features/operator/operator.options.tsx index 24ba81aef..743f2cdb2 100644 --- a/apps/client/src/features/operator/operator.options.tsx +++ b/apps/client/src/features/operator/operator.options.tsx @@ -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; +} diff --git a/apps/client/src/features/operator/operator.types.ts b/apps/client/src/features/operator/operator.types.ts new file mode 100644 index 000000000..87464ad8a --- /dev/null +++ b/apps/client/src/features/operator/operator.types.ts @@ -0,0 +1,5 @@ +import { OntimeEvent } from 'ontime-types'; + +export type Subscribed = { id: string; label: string; colour: string; value: string }[]; +export type TitleFields = Pick; +export type EditEvent = Pick & { subscriptions: Subscribed }; diff --git a/apps/client/src/features/operator/operator.utils.ts b/apps/client/src/features/operator/operator.utils.ts new file mode 100644 index 000000000..fd73e3c56 --- /dev/null +++ b/apps/client/src/features/operator/operator.utils.ts @@ -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 { + 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((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 }; +} diff --git a/apps/client/src/features/operator/status-bar/StatusBar.module.scss b/apps/client/src/features/operator/status-bar/StatusBar.module.scss index de200bccf..544875f6e 100644 --- a/apps/client/src/features/operator/status-bar/StatusBar.module.scss +++ b/apps/client/src/features/operator/status-bar/StatusBar.module.scss @@ -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; } diff --git a/apps/client/src/features/operator/status-bar/StatusBar.tsx b/apps/client/src/features/operator/status-bar/StatusBar.tsx index c6504c950..c6f09e65c 100644 --- a/apps/client/src/features/operator/status-bar/StatusBar.tsx +++ b/apps/client/src/features/operator/status-bar/StatusBar.tsx @@ -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 (
- + {data && }
); diff --git a/apps/client/src/features/operator/status-bar/StatusBarProgress.tsx b/apps/client/src/features/operator/status-bar/StatusBarProgress.tsx index 74d757c73..2cc44b5c0 100644 --- a/apps/client/src/features/operator/status-bar/StatusBarProgress.tsx +++ b/apps/client/src/features/operator/status-bar/StatusBarProgress.tsx @@ -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 ( diff --git a/apps/client/src/features/operator/status-bar/StatusBarTimers.tsx b/apps/client/src/features/operator/status-bar/StatusBarTimers.tsx index 607228a8e..031cc5057 100644 --- a/apps/client/src/features/operator/status-bar/StatusBarTimers.tsx +++ b/apps/client/src/features/operator/status-bar/StatusBarTimers.tsx @@ -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 ; - }, [playback]); + const playbackActive = isPlaybackActive(timer.playback); return (
- {PlaybackIconComponent} +
+ Running timer + +
+
Time now
-
- Elapsed time - -
-
- Running timer - -
- - {projectTitle} -
- Scheduled start - -
-
- Scheduled end - -
); } diff --git a/apps/client/src/features/overview/overviewUtils.ts b/apps/client/src/features/overview/overviewUtils.ts index 73561efae..9ac7da342 100644 --- a/apps/client/src/features/overview/overviewUtils.ts +++ b/apps/client/src/features/overview/overviewUtils.ts @@ -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 }); } /** diff --git a/apps/client/src/features/rundown/event-block/EventBlock.module.scss b/apps/client/src/features/rundown/event-block/EventBlock.module.scss index a8daba3c3..b83a9c5bb 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.module.scss +++ b/apps/client/src/features/rundown/event-block/EventBlock.module.scss @@ -35,7 +35,7 @@ $skip-opacity: 0.2; } &.play { - background-color: $green-700; + background-color: $active-green; @include declare-overrides; } diff --git a/apps/client/src/features/viewers/common/running-time/RunningTime.tsx b/apps/client/src/features/viewers/common/running-time/RunningTime.tsx index 2585ad767..ceeca4b1d 100644 --- a/apps/client/src/features/viewers/common/running-time/RunningTime.tsx +++ b/apps/client/src/features/viewers/common/running-time/RunningTime.tsx @@ -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); diff --git a/apps/client/src/theme/_ontimeStyles.scss b/apps/client/src/theme/_ontimeStyles.scss index 28b141686..b569915b3 100644 --- a/apps/client/src/theme/_ontimeStyles.scss +++ b/apps/client/src/theme/_ontimeStyles.scss @@ -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; diff --git a/apps/client/src/views/countdown/countdown.options.ts b/apps/client/src/views/countdown/countdown.options.ts index 517929673..a390cf8ea 100644 --- a/apps/client/src/views/countdown/countdown.options.ts +++ b/apps/client/src/views/countdown/countdown.options.ts @@ -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();