Skip to content

Commit

Permalink
feat: timeline view
Browse files Browse the repository at this point in the history
  • Loading branch information
cpvalente committed Jul 11, 2024
1 parent 6bfa11d commit 9e9d57f
Show file tree
Hide file tree
Showing 21 changed files with 780 additions and 11 deletions.
14 changes: 5 additions & 9 deletions apps/client/src/AppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const ClockView = lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));

const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
const Timeline = lazy(() => import('./features/viewers/timeline/TimelinePage'));
const Public = lazy(() => import('./features/viewers/public/Public'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
Expand All @@ -28,6 +29,7 @@ const SBackstage = withPreset(withData(Backstage));
const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline));

const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
Expand All @@ -43,26 +45,20 @@ export default function AppRouter() {
<Routes>
<Route path='/' element={<Navigate to='/timer' />} />
<Route path='/timer' element={<STimer />} />

<Route path='/public' element={<SPublic />} />
<Route path='/minimal' element={<SMinimalTimer />} />

<Route path='/clock' element={<SClock />} />

<Route path='/countdown' element={<SCountdown />} />

<Route path='/backstage' element={<SBackstage />} />

<Route path='/public' element={<SPublic />} />

<Route path='/studio' element={<SStudio />} />

<Route path='/lower' element={<SLowerThird />} />

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

{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/op' element={<Operator />} />

{/*/!* Protected Routes - Elements *!/*/}
<Route
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ interface EditFormDrawerProps {
viewOptions: ViewOption[];
}

// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onClose, onOpen } = useDisclosure();
Expand Down
13 changes: 13 additions & 0 deletions apps/client/src/common/utils/styleUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,16 @@ export const enDash = '–';

export const timerPlaceholder = '––:––:––';
export const timerPlaceholderMin = '––:––';

/**
* Adds opacity to a given colour if possible
*/
export function alpha(colour: string, amount: number): string {
try {
const withAlpha = Color(colour).alpha(amount).hexa();
return withAlpha;
} catch (_error) {
/* we do not handle errors here */
}
return colour;
}
23 changes: 23 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,26 @@ 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 {
// durations should never be negative, we handle it here to flag if there is an issue in future
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 Expand Up @@ -99,7 +99,6 @@
display: flex;
flex-wrap: wrap;


.field {
font-weight: 600;
padding-inline: 0.25rem;
Expand Down
75 changes: 75 additions & 0 deletions apps/client/src/features/viewers/timeline/Timeline.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
$timeline-entry-height: 20px;

.timeline {
flex: 1;
}

.timelineEvents {
position: relative;
height: 100%;
}

.entryColumn {
position: absolute;
background-color: var(--lighter, $gray-500);
border-bottom: 0.25rem solid var(--color, $ui-white);
border-right: 1px solid $ui-black;
min-height: 80px;

&.fullHeight {
height: calc(100% - 4rem);
}
}

.entryContent {
padding-top: var(--top, 0);

&.lastElement {
text-align: right;
transform: translateX(-100%);
}
}

.entryText {
position: relative;
z-index: 2;
color: $ui-white;
padding-inline: 0.5em;
width: fit-content;
white-space: nowrap;

&.textBg {
background-color: var(--bg, $black-10);
}
}

.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;
}
}
128 changes: 128 additions & 0 deletions apps/client/src/features/viewers/timeline/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { memo } from 'react';
import { useSearchParams } from 'react-router-dom';
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 { isStringBoolean } from '../../viewers/common/viewUtils';

import { TimelineMarkers } from './timeline-markers/TimelineMarkers';
import { ProgressBar } from './timeline-progress-bar/TimelineProgressBar';
import { getElementPosition, getEndHour, getEstimatedWidth, getLaneLevel, getStartHour } from './timeline.utils';
import { ProgressStatus, TimelineEntry } from './TimelineEntry';

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();
const [searchParams] = useSearchParams();
const fullHeight = isStringBoolean(searchParams.get('fullHeight'));

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 />
<ProgressBar startHour={startHour} endHour={endHour} />
<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 estimatedWidth = getEstimatedWidth(event.title);
const estimatedRightPosition = elementLeftPosition + estimatedWidth;
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}
mayGrow={elementWidth < estimatedWidth}
fullHeight={fullHeight}
/>
);
})}
</div>
</div>
);
}
Loading

0 comments on commit 9e9d57f

Please sign in to comment.