From 8aeb4c7a0162ac12df75b9502ff666909a9adbd3 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Fri, 21 Jun 2024 15:10:38 +0200 Subject: [PATCH] feat: progress view --- apps/client/src/AppRouter.tsx | 4 + .../view-params-editor/ViewParamsEditor.tsx | 1 + .../view-params-editor/constants.ts | 4 + apps/client/src/common/utils/time.ts | 22 +++ .../operator-event/OperatorEvent.module.scss | 2 +- .../timeline/Timeline/Timeline.module.scss | 72 ++++++++ .../features/timeline/Timeline/Timeline.tsx | 121 ++++++++++++++ .../timeline/Timeline/TimelineEntry.tsx | 78 +++++++++ .../Timeline/TimelineMarkers.module.scss | 19 +++ .../timeline/Timeline/TimelineMarkers.tsx | 23 +++ .../Timeline/TimelineProgressBar.module.scss | 8 + .../timeline/Timeline/TimelineProgressBar.tsx | 19 +++ .../Timeline/__tests__/timelineUtils.test.ts | 84 ++++++++++ .../timeline/Timeline/timelineUtils.ts | 154 ++++++++++++++++++ .../timeline/TimelinePage.module.scss | 51 ++++++ .../src/features/timeline/TimelinePage.tsx | 63 +++++++ apps/client/src/theme/_ontimeStyles.scss | 1 + apps/client/src/viewerConfig.ts | 1 + packages/utils/index.ts | 1 + .../utils/src/rundown-utils/rundownUtils.ts | 9 + 20 files changed, 736 insertions(+), 1 deletion(-) create mode 100644 apps/client/src/features/timeline/Timeline/Timeline.module.scss create mode 100644 apps/client/src/features/timeline/Timeline/Timeline.tsx create mode 100644 apps/client/src/features/timeline/Timeline/TimelineEntry.tsx create mode 100644 apps/client/src/features/timeline/Timeline/TimelineMarkers.module.scss create mode 100644 apps/client/src/features/timeline/Timeline/TimelineMarkers.tsx create mode 100644 apps/client/src/features/timeline/Timeline/TimelineProgressBar.module.scss create mode 100644 apps/client/src/features/timeline/Timeline/TimelineProgressBar.tsx create mode 100644 apps/client/src/features/timeline/Timeline/__tests__/timelineUtils.test.ts create mode 100644 apps/client/src/features/timeline/Timeline/timelineUtils.ts create mode 100644 apps/client/src/features/timeline/TimelinePage.module.scss create mode 100644 apps/client/src/features/timeline/TimelinePage.tsx diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx index ba146279a..613303e80 100644 --- a/apps/client/src/AppRouter.tsx +++ b/apps/client/src/AppRouter.tsx @@ -17,6 +17,7 @@ const Countdown = lazy(() => import('./features/viewers/countdown/Countdown')); const Backstage = lazy(() => import('./features/viewers/backstage/Backstage')); const Public = lazy(() => import('./features/viewers/public/Public')); +const Timeline = lazy(() => import('./features/timeline/TimelinePage')); const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird')); const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock')); @@ -26,6 +27,7 @@ const SClock = withPreset(withData(ClockView)); const SCountdown = withPreset(withData(Countdown)); const SBackstage = withPreset(withData(Backstage)); const SPublic = withPreset(withData(Public)); +const STimeline = withPreset(withData(Timeline)); const SLowerThird = withPreset(withData(Lower)); const SStudio = withPreset(withData(StudioClock)); @@ -60,6 +62,8 @@ export default function AppRouter() { } /> + } /> + {/*/!* Protected Routes *!/*/} } /> } /> diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx index c2e629197..ae9f81dfe 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx +++ b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx @@ -40,6 +40,7 @@ interface EditFormDrawerProps { paramFields: ParamField[]; } +// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) { const [searchParams, setSearchParams] = useSearchParams(); const { isOpen, onClose, onOpen } = useDisclosure(); diff --git a/apps/client/src/common/components/view-params-editor/constants.ts b/apps/client/src/common/components/view-params-editor/constants.ts index 5f35063ba..ee7c9bce7 100644 --- a/apps/client/src/common/components/view-params-editor/constants.ts +++ b/apps/client/src/common/components/view-params-editor/constants.ts @@ -509,3 +509,7 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin }; export const getCountdownOptions = (timeFormat: string): ParamField[] => [getTimeOption(timeFormat), hideTimerSeconds]; + +export const getProgressOptions = (timeFormat: string): ParamField[] => { + return [getTimeOption(timeFormat)]; +}; diff --git a/apps/client/src/common/utils/time.ts b/apps/client/src/common/utils/time.ts index f41af9e3b..2c43dfe3a 100644 --- a/apps/client/src/common/utils/time.ts +++ b/apps/client/src/common/utils/time.ts @@ -95,3 +95,25 @@ export const formatTime = ( const isNegative = milliseconds < 0; return `${isNegative ? '-' : ''}${display}`; }; + +/** + * Handles case for formatting a duration time + * @param duration + * @returns + */ +export function formatDuration(duration: number): string { + if (duration === 0) { + return '0h 0m'; + } + + const hours = Math.floor(duration / 3600000); + const minutes = Math.floor((duration % 3600000) / 60000); + let result = ''; + if (hours > 0) { + result += `${hours}h `; + } + if (minutes > 0) { + result += `${minutes}m`; + } + return result; +} 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 f5066be13..d7e76942c 100644 --- a/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss +++ b/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss @@ -30,7 +30,7 @@ &.running { border-top: 1px solid $gray-1300; - background-color: var(--operator-running-bg-override, $red-700); + background-color: var(--operator-running-bg-override, $active-red); } &.past { diff --git a/apps/client/src/features/timeline/Timeline/Timeline.module.scss b/apps/client/src/features/timeline/Timeline/Timeline.module.scss new file mode 100644 index 000000000..55a3288a3 --- /dev/null +++ b/apps/client/src/features/timeline/Timeline/Timeline.module.scss @@ -0,0 +1,72 @@ +$timeline-entry-height: 20px; + +.timeline { + flex: 1; +} + +.timelineEvents { + position: relative; +} + +.entryIndicator { + position: absolute; + background-color: var(--color, $ui-white); + height: $timeline-entry-height; +} + +.entryContent { + position: absolute; + margin-top: $timeline-entry-height; + padding-top: var(--top, 0); + border-left: 2px solid var(--color, $ui-white); + + color: $ui-white; + + white-space: nowrap; + + > div { + min-width: 100%; + padding-inline: 0.5em; + width: fit-content; + background-color: $ui-black; + } + + &.lastElement { + border-left: none; + border-right: 2px solid var(--color, $ui-black); + text-align: right; + + transform: translateX(-100%); + } +} + +.start, +.title { + font-weight: 600; +} + +// for elapsed events, we can hide some stuff +[data-status='finished'] { + color: $gray-500; + .status { + display: none + } +} + +[data-status='live'] { + font-weight: 600; + color: $ui-white; + + .status { + color: $active-red; + } +} + +[data-status='future'] { + font-weight: 600; + color: $gray-300; + + .status { + color: $green-500; + } +} diff --git a/apps/client/src/features/timeline/Timeline/Timeline.tsx b/apps/client/src/features/timeline/Timeline/Timeline.tsx new file mode 100644 index 000000000..187bca512 --- /dev/null +++ b/apps/client/src/features/timeline/Timeline/Timeline.tsx @@ -0,0 +1,121 @@ +import { memo } from 'react'; +import { useViewportSize } from '@mantine/hooks'; +import { isOntimeEvent, MaybeNumber } from 'ontime-types'; +import { dayInMs, getFirstEventNormal, getLastEventNormal, MILLIS_PER_HOUR } from 'ontime-utils'; + +import useRundown from '../../../common/hooks-query/useRundown'; + +import { type ProgressStatus, TimelineEntry } from './TimelineEntry'; +import { TimelineMarkers } from './TimelineMarkers'; +import { ProgressBar } from './TimelineProgressBar'; +import { getElementPosition, getEndHour, getEstimatedWidth, getLaneLevel, getStartHour } from './timelineUtils'; + +import style from './Timeline.module.scss'; + +export default memo(Timeline); + +function useTimeline() { + const { data } = useRundown(); + if (data.revision === -1) { + return null; + } + + const { firstEvent } = getFirstEventNormal(data.rundown, data.order); + const { lastEvent } = getLastEventNormal(data.rundown, data.order); + const firstStart = firstEvent?.timeStart ?? 0; + const lastEnd = lastEvent?.timeEnd ?? 0; + const normalisedLastEnd = lastEnd < firstStart ? lastEnd + dayInMs : lastEnd; + + // timeline is padded to nearest hours (floor and ceil) + const startHour = getStartHour(firstStart) * MILLIS_PER_HOUR; + const endHour = getEndHour(normalisedLastEnd) * MILLIS_PER_HOUR; + + return { + rundown: data.rundown, + order: data.order, + startHour, + endHour, + }; +} + +interface TimelineProps { + selectedEventId: string | null; +} + +function Timeline(props: TimelineProps) { + const { selectedEventId } = props; + const { width: screenWidth } = useViewportSize(); + const timelineData = useTimeline(); + + if (timelineData === null) { + return null; + } + + const { rundown, order, startHour, endHour } = timelineData; + + let hasTimelinePassedMidnight = false; + let previousEventStartTime: MaybeNumber = null; + let eventStatus: ProgressStatus = 'finished'; + // a list of the right most element for each lane + const rightMostElements: Record = {}; + + return ( +
+ +
+ {order.map((eventId) => { + // for now we dont render delays and blocks + const event = rundown[eventId]; + if (!isOntimeEvent(event)) { + return null; + } + + // keep track of progress of rundown + if (eventStatus === 'live') { + eventStatus = 'future'; + } + if (eventId === selectedEventId) { + eventStatus = 'live'; + } + + // we need to offset the start to account for midnight + if (!hasTimelinePassedMidnight) { + hasTimelinePassedMidnight = previousEventStartTime !== null && event.timeStart < previousEventStartTime; + } + const normalisedStart = hasTimelinePassedMidnight ? event.timeStart + dayInMs : event.timeStart; + previousEventStartTime = normalisedStart; + + const { left: elementLeftPosition, width: elementWidth } = getElementPosition( + startHour, + endHour, + normalisedStart, + event.duration, + screenWidth, + ); + const estimatedRightPosition = elementLeftPosition + getEstimatedWidth(event.title); + const laneLevel = getLaneLevel(rightMostElements, elementLeftPosition); + + if (rightMostElements[laneLevel] === undefined || rightMostElements[laneLevel] < estimatedRightPosition) { + rightMostElements[laneLevel] = estimatedRightPosition; + } + + return ( + + ); + })} +
+ +
+ ); +} diff --git a/apps/client/src/features/timeline/Timeline/TimelineEntry.tsx b/apps/client/src/features/timeline/Timeline/TimelineEntry.tsx new file mode 100644 index 000000000..5e155b30b --- /dev/null +++ b/apps/client/src/features/timeline/Timeline/TimelineEntry.tsx @@ -0,0 +1,78 @@ +import { useClock } from '../../../common/hooks/useSocket'; +import { cx } from '../../../common/utils/styleUtils'; +import { formatDuration, formatTime } from '../../../common/utils/time'; + +import { getStatusLabel } from './timelineUtils'; + +import style from './Timeline.module.scss'; + +export type ProgressStatus = 'finished' | 'live' | 'future'; + +interface TimelineEntry { + colour: string; + duration: number; + isLast: boolean; + lane: number; + left: number; + status: ProgressStatus; + start: number; + title: string; + width: number; +} + +const laneHeight = 120; +const formatOptions = { + format12: 'hh:mm a', + format24: 'HH:mm', +}; + +export function TimelineEntry(props: TimelineEntry) { + const { colour, duration, isLast, lane, left, status, start, title, width } = props; + + const formattedStartTime = formatTime(start, formatOptions); + const formattedDuration = `Dur ${formatDuration(duration)}`; + + const contentClasses = cx([style.entryContent, isLast && style.lastElement]); + + return ( + <> +
+
+
{formattedStartTime}
+
{title}
+
{formattedDuration}
+ +
+ + ); +} + +interface TimelineEntryStatusProps { + status: ProgressStatus; + start: number; +} +// we isolate this component to avoid re-rendering too many elements +function TimelineEntryStatus(props: TimelineEntryStatusProps) { + const { status, start } = props; + // TODO: account for offset instead of just using the clock + const { clock } = useClock(); + const statusText = getStatusLabel(start - clock, status); + + return
{statusText}
; +} diff --git a/apps/client/src/features/timeline/Timeline/TimelineMarkers.module.scss b/apps/client/src/features/timeline/Timeline/TimelineMarkers.module.scss new file mode 100644 index 000000000..fd4b933ee --- /dev/null +++ b/apps/client/src/features/timeline/Timeline/TimelineMarkers.module.scss @@ -0,0 +1,19 @@ +.markers { + width: 100%; + color: $ui-white; + background-color: $white-10; + display: flex; + height: 1rem; + line-height: 1rem; + font-size: calc(1rem - 2px); + justify-content: space-evenly; + text-align: center; + + & > * { + flex-grow: 1; + } + + :nth-child(odd) { + background-color: $white-20; + } +} diff --git a/apps/client/src/features/timeline/Timeline/TimelineMarkers.tsx b/apps/client/src/features/timeline/Timeline/TimelineMarkers.tsx new file mode 100644 index 000000000..adb805a54 --- /dev/null +++ b/apps/client/src/features/timeline/Timeline/TimelineMarkers.tsx @@ -0,0 +1,23 @@ +import useRundown from '../../../common/hooks-query/useRundown'; + +import { getTimelineSections } from './timelineUtils'; + +import style from './TimelineMarkers.module.scss'; + +export function TimelineMarkers() { + const { data } = useRundown(); + + if (data.revision === -1) { + return null; + } + + const elements = getTimelineSections(data.rundown, data.order); + + return ( +
+ {elements.map((tag, index) => { + return {tag}; + })} +
+ ); +} diff --git a/apps/client/src/features/timeline/Timeline/TimelineProgressBar.module.scss b/apps/client/src/features/timeline/Timeline/TimelineProgressBar.module.scss new file mode 100644 index 000000000..be77e7285 --- /dev/null +++ b/apps/client/src/features/timeline/Timeline/TimelineProgressBar.module.scss @@ -0,0 +1,8 @@ +.progressBar { + position: relative; + height: 100%; + border-right: 2px solid $active-red; + + transition-duration: 0.3s; + transition-property: left; +} diff --git a/apps/client/src/features/timeline/Timeline/TimelineProgressBar.tsx b/apps/client/src/features/timeline/Timeline/TimelineProgressBar.tsx new file mode 100644 index 000000000..f619ea5be --- /dev/null +++ b/apps/client/src/features/timeline/Timeline/TimelineProgressBar.tsx @@ -0,0 +1,19 @@ +import { useClock } from '../../../common/hooks/useSocket'; + +import { getRelativePositionX } from './timelineUtils'; + +import style from './TimelineProgressBar.module.scss'; + +interface ProgressBarProps { + startHour: number; + endHour: number; +} + +export function ProgressBar(props: ProgressBarProps) { + const { startHour, endHour } = props; + const { clock } = useClock(); + + const left = getRelativePositionX(startHour, endHour, clock); + + return
; +} diff --git a/apps/client/src/features/timeline/Timeline/__tests__/timelineUtils.test.ts b/apps/client/src/features/timeline/Timeline/__tests__/timelineUtils.test.ts new file mode 100644 index 000000000..5c97a44f1 --- /dev/null +++ b/apps/client/src/features/timeline/Timeline/__tests__/timelineUtils.test.ts @@ -0,0 +1,84 @@ +import { dayInMs } from 'ontime-utils'; + +import { getElementPosition, getLaneLevel } from '../timelineUtils'; + +describe('getCSSPosition()', () => { + it('accounts for rundown with one event', () => { + const scheduleStart = 0; + const scheduleEnd = dayInMs; + const eventStart = 0; + const eventDuration = dayInMs; + const containerWidth = 100; + + const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth); + expect(result.left).toBe(0); + expect(result.width).toBe(containerWidth); + }); + + it('accounts for an event that starts halfway and ends at end', () => { + const scheduleStart = 0; + const scheduleEnd = 100; + const eventStart = 50; + const eventDuration = 50; + const containerWidth = 100; + + const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth); + expect(result.left).toBe(50); + expect(result.width).toBe(50); + }); + + it('accounts for an event that starts first and ends halfway', () => { + const scheduleStart = 0; + const scheduleEnd = 100; + const eventStart = 0; + const eventDuration = 50; + const containerWidth = 100; + + const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth); + expect(result.left).toBe(0); + expect(result.width).toBe(50); + }); + + it('accounts for an event that is in the middle of the rundown', () => { + const scheduleStart = 7; + const scheduleEnd = 23; + const eventStart = 10; + const eventDuration = 1; + const containerWidth = 1000; + + // 16 hour event, this gives 62.5px per hour + const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth); + expect(result.left).toBe(187.5); // 3 * 62.5 + expect(result.width).toBe(62.5); + }); +}); + +describe('getLaneLevel()', () => { + it('should place in lane 0 if there is no overlap', () => { + const rightMostElements = { 0: 50 }; + const left = 60; + const result = getLaneLevel(rightMostElements, left); + expect(result).toBe(0); + }); + + it('should place in next available lane if there is overlap', () => { + const rightMostElements = { 0: 150, 1: 100 }; + const left = 120; + const result = getLaneLevel(rightMostElements, left); + expect(result).toBe(1); + }); + + it('should create a new lane if all existing lanes are overlapped', () => { + const rightMostElements = { 0: 200, 1: 250 }; + const left = 150; + const result = getLaneLevel(rightMostElements, left); + expect(result).toBe(2); + }); + + it('should place in lane 0 if it is the first event', () => { + const rightMostElements = {}; + const left = 0; + const result = getLaneLevel(rightMostElements, left); + expect(result).toBe(0); + }); +}); diff --git a/apps/client/src/features/timeline/Timeline/timelineUtils.ts b/apps/client/src/features/timeline/Timeline/timelineUtils.ts new file mode 100644 index 000000000..2e192eef0 --- /dev/null +++ b/apps/client/src/features/timeline/Timeline/timelineUtils.ts @@ -0,0 +1,154 @@ +import { isOntimeEvent, MaybeString, NormalisedRundown, OntimeEvent } from 'ontime-types'; +import { + dayInMs, + getByEventId, + getFirstEvent, + getFirstEventNormal, + getLastEventNormal, + getNextEvent, + MILLIS_PER_HOUR, + millisToString, + removeSeconds, +} from 'ontime-utils'; + +import { clamp } from '../../../common/utils/math'; +import { formatDuration } from '../../../common/utils/time'; + +import type { ProgressStatus } from './TimelineEntry'; + +type CSSPosition = { + left: number; + width: number; +}; + +/** + * Calculates the position (in %) of an element relative to a schedule + */ +export function getRelativePositionX(scheduleStart: number, scheduleEnd: number, now: number): number { + return clamp(((now - scheduleStart) / (scheduleEnd - scheduleStart)) * 100, 0, 100); +} + +/** + * Estimates the width of an element based on its content + */ +export function getEstimatedWidth(content: string): number { + // use 8 as minimum width to account for duration string + // 12 is a rough estimate of the width of a character at 15px font size + return Math.max(content.length, 8) * 12; +} +/** + * Calculates an absolute position of an element based on a schedule + */ +export function getElementPosition( + scheduleStart: number, + scheduleEnd: number, + eventStart: number, + eventDuration: number, + containerWidth: number, +): CSSPosition { + // TODO: events that finish at midnight have lastEnd 0 + const totalDuration = scheduleEnd - scheduleStart; + const width = (eventDuration * containerWidth) / totalDuration; + const left = ((eventStart - scheduleStart) * containerWidth) / totalDuration; + + return { left, width }; +} + +export function getStartHour(startTime: number): number { + const hours = Math.floor(startTime / MILLIS_PER_HOUR); + return hours; +} + +export function getEndHour(endTime: number): number { + const hours = Math.ceil(endTime / MILLIS_PER_HOUR); + return hours; +} + +export function makeTimelineSections(firstHour: number, lastHour: number) { + const timelineSections = []; + for (let i = firstHour; i < lastHour; i++) { + timelineSections.push(removeSeconds(millisToString(i * MILLIS_PER_HOUR))); + } + return timelineSections; +} + +// TODO: account for elapsed days +export function getTimelineSections(rundown: NormalisedRundown, order: string[]): string[] { + if (order.length === 0) { + return []; + } + const { firstEvent } = getFirstEventNormal(rundown, order); + const { lastEvent } = getLastEventNormal(rundown, order); + const firstStart = firstEvent?.timeStart ?? 0; + const lastEnd = lastEvent?.timeEnd ?? 0; + const normalisedLastEnd = lastEnd < firstStart ? lastEnd + dayInMs : lastEnd; + + const startHour = getStartHour(firstStart); + const endHour = getEndHour(normalisedLastEnd); + + const elements = makeTimelineSections(startHour, endHour); + return elements; +} + +const MAX_DEPTH = 5; + +/** + * Estimates the top offset for an element + */ +export function getLaneLevel(rightMostElements: Record, left: number): number { + for (let checkLane = 0; checkLane < MAX_DEPTH; checkLane++) { + if (rightMostElements[checkLane] === undefined) { + return checkLane; + } + if (rightMostElements[checkLane] < left) { + return checkLane; + } + } + return 0; +} + +/** + * Returns a formatted label for a progress status + */ +export function getStatusLabel(timeToStart: number, status: ProgressStatus): string { + if (status === 'finished' || status === 'live') { + return status; + } + + if (timeToStart < 0) { + return 'T - 0'; + } + + return `T - ${formatDuration(timeToStart)}`; +} + +type UpcomingEvents = { + now: MaybeString; + next: MaybeString; + followedBy: MaybeString; +}; + +/** + * Returns upcoming events from current: now, next and followedBy + */ +export function getUpcomingEvents(events: OntimeEvent[], selectedId: MaybeString): UpcomingEvents { + if (events.length === 0) { + return { now: null, next: null, followedBy: null }; + } + + const nowEvent = selectedId ? getByEventId(events, selectedId) : getFirstEvent(events)?.firstEvent; + + if (!isOntimeEvent(nowEvent)) { + return { now: null, next: null, followedBy: null }; + } + + const nextEvent = getNextEvent(events, nowEvent.id)?.nextEvent; + const followedByEvent = nextEvent ? getNextEvent(events, nextEvent.id)?.nextEvent : null; + + // Return the titles, handling nulls appropriately + return { + now: nowEvent.title, + next: nextEvent ? nextEvent.title : null, + followedBy: followedByEvent ? followedByEvent.title : null, + }; +} diff --git a/apps/client/src/features/timeline/TimelinePage.module.scss b/apps/client/src/features/timeline/TimelinePage.module.scss new file mode 100644 index 000000000..a2f93dfdb --- /dev/null +++ b/apps/client/src/features/timeline/TimelinePage.module.scss @@ -0,0 +1,51 @@ +.progress { + width: 100vw; + height: 100vh; + + background-color: $ui-black; + color: $ui-white; + text-transform: uppercase; + + display: flex; + flex-direction: column; + gap: 2rem; +} + +.title { + padding-inline: 2rem; + text-align: left; + font-size: 3.5rem; +} + +.sections { + padding-inline: 2rem; + display: grid; + grid-template-columns: 1fr 1fr; + row-gap: 1rem; + column-gap: 3rem; +} + +.sectionTitle { + line-height: 1.2em; + font-size: 1.5rem; + text-transform: uppercase; +} + +.sectionContent { + line-height: 1em; + font-size: 3rem; + text-transform: uppercase; + font-weight: 600; + + &.now { + color: $green-500; + } + + &.next { + color: $red-500; + } + + &.subdue { + opacity: $opacity-disabled; + } +} diff --git a/apps/client/src/features/timeline/TimelinePage.tsx b/apps/client/src/features/timeline/TimelinePage.tsx new file mode 100644 index 000000000..b63aeaf2d --- /dev/null +++ b/apps/client/src/features/timeline/TimelinePage.tsx @@ -0,0 +1,63 @@ +import { MaybeString, OntimeEvent, ProjectData, Settings } from 'ontime-types'; + +import { getProgressOptions } from '../../common/components/view-params-editor/constants'; +import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; +import { ViewExtendedTimer } from '../../common/models/TimeManager.type'; +import { cx } from '../../common/utils/styleUtils'; +import { formatTime, getDefaultFormat } from '../../common/utils/time'; + +import Timeline from './Timeline/Timeline'; +import { getUpcomingEvents } from './Timeline/timelineUtils'; + +import style from './TimelinePage.module.scss'; + +interface TimelinePageProps { + backstageEvents: OntimeEvent[]; + general: ProjectData; + selectedId: MaybeString; + settings: Settings | undefined; + time: ViewExtendedTimer; +} + +export default function TimelinePage(props: TimelinePageProps) { + const { backstageEvents, general, selectedId, settings, time } = props; + + const clock = formatTime(time.clock); + const { now, next, followedBy } = getUpcomingEvents(backstageEvents, selectedId); + + // populate options + const defaultFormat = getDefaultFormat(settings?.timeFormat); + const progressOptions = getProgressOptions(defaultFormat); + + return ( +
+ +
{general.title}
+
+
+
+
+
+
+ +
+ ); +} + +interface SectionProps { + category: 'now' | 'next'; + content: MaybeString; + title: string; +} + +function Section(props: SectionProps) { + const { category, content, title } = props; + + const contentClasses = cx([style.sectionContent, content != null ? style[category] : style.subdue]); + return ( +
+
{title}
+
{content ?? '-'}
+
+ ); +} diff --git a/apps/client/src/theme/_ontimeStyles.scss b/apps/client/src/theme/_ontimeStyles.scss index 46de7d8e4..c5227566f 100644 --- a/apps/client/src/theme/_ontimeStyles.scss +++ b/apps/client/src/theme/_ontimeStyles.scss @@ -15,6 +15,7 @@ $ontime-color: #ff7597; $error-red: $red-500; $warning-orange: $orange-500; $opacity-disabled: 0.4; +$active-red: $red-700; // playback colours $playback-start: $green-600; diff --git a/apps/client/src/viewerConfig.ts b/apps/client/src/viewerConfig.ts index c7aa393ac..b532ba149 100644 --- a/apps/client/src/viewerConfig.ts +++ b/apps/client/src/viewerConfig.ts @@ -4,6 +4,7 @@ export const navigatorConstants = [ { url: '/minimal', label: 'Minimal Timer' }, { url: '/backstage', label: 'Backstage' }, { url: '/public', label: 'Public' }, + { url: '/timeline', label: 'Timeline' }, { url: '/lower', label: 'Lower Thirds' }, { url: '/studio', label: 'Studio Clock' }, { url: '/countdown', label: 'Countdown' }, diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 45768cd06..6eadeb141 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -8,6 +8,7 @@ export { sanitiseCue } from './src/cue-utils/cueUtils.js'; export { getCueCandidate } from './src/cue-utils/cueUtils.js'; export { generateId } from './src/generate-id/generateId.js'; export { + getByEventId, getFirst, getFirstEvent, getFirstEventNormal, diff --git a/packages/utils/src/rundown-utils/rundownUtils.ts b/packages/utils/src/rundown-utils/rundownUtils.ts index d002bcd61..feb6a903b 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.ts @@ -3,6 +3,15 @@ import { isOntimeEvent } from 'ontime-types'; type IndexAndEntry = { entry: OntimeRundownEntry | null; index: number | null }; +/** + * Gets an event with given if if it exists + * @param {OntimeRundownEntry[]} rundown + * @return {OntimeRundownEntry | null} + */ +export function getByEventId(rundown: OntimeRundownEntry[], eventId: string): OntimeRundownEntry | null { + return rundown.find((event) => event.id === eventId) ?? null; +} + /** * Gets first event in rundown, if it exists * @param {OntimeRundownEntry[]} rundown