Skip to content

Commit

Permalink
feat: progress view
Browse files Browse the repository at this point in the history
  • Loading branch information
cpvalente committed Jun 22, 2024
1 parent b7c48f3 commit 8aeb4c7
Show file tree
Hide file tree
Showing 20 changed files with 736 additions and 1 deletion.
4 changes: 4 additions & 0 deletions apps/client/src/AppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand All @@ -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));

Expand Down Expand Up @@ -60,6 +62,8 @@ export default function AppRouter() {

<Route path='/op' element={<Operator />} />

<Route path='/timeline' element={<STimeline />} />

{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)];
};
22 changes: 22 additions & 0 deletions apps/client/src/common/utils/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
72 changes: 72 additions & 0 deletions apps/client/src/features/timeline/Timeline/Timeline.module.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
121 changes: 121 additions & 0 deletions apps/client/src/features/timeline/Timeline/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -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<number, number> = {};

return (
<div className={style.timeline}>
<TimelineMarkers />
<div className={style.timelineEvents}>
{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 (
<TimelineEntry
key={eventId}
colour={event.colour}
duration={event.duration}
isLast={eventId === order[order.length - 1]}
lane={laneLevel}
left={elementLeftPosition}
status={eventStatus}
start={event.timeStart}
title={event.title}
width={elementWidth}
/>
);
})}
</div>
<ProgressBar startHour={startHour} endHour={endHour} />
</div>
);
}
78 changes: 78 additions & 0 deletions apps/client/src/features/timeline/Timeline/TimelineEntry.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div
className={style.entryIndicator}
style={{
'--color': colour,
left: `${left}px`,
width: `${width}px`,
}}
/>
<div
className={contentClasses}
data-status={status}
style={{
'--color': colour,
'--top': `${lane * laneHeight}px`,
zIndex: 5 - lane,
left: `${left}px`,
}}
>
<div className={style.start}>{formattedStartTime}</div>
<div className={style.title}>{title}</div>
<div className={style.duration}>{formattedDuration}</div>
<TimelineEntryStatus status={status} start={start} />
</div>
</>
);
}

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 <div className={style.status}>{statusText}</div>;
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
23 changes: 23 additions & 0 deletions apps/client/src/features/timeline/Timeline/TimelineMarkers.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={style.markers}>
{elements.map((tag, index) => {
return <span key={index}>{tag}</span>;
})}
</div>
);
}
Loading

0 comments on commit 8aeb4c7

Please sign in to comment.