Skip to content

Commit

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

const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
const Timeline = React.lazy(() => import('./features/viewers/timeline/TimelinePage'));
const Public = React.lazy(() => import('./features/viewers/public/Public'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
Expand All @@ -39,6 +40,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 = React.lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
Expand Down Expand Up @@ -74,26 +76,20 @@ export default function AppRouter() {
<SentryRoutes>
<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
9 changes: 9 additions & 0 deletions apps/client/src/common/hooks/useSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,12 @@ export const useRuntimePlaybackOverview = () => {

return useRuntimeStore(featureSelector);
};

export const useTimelineStatus = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offset,
});

return useRuntimeStore(featureSelector);
};
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;
}
35 changes: 29 additions & 6 deletions apps/client/src/common/utils/time.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
import { formatFromMillis } from 'ontime-utils';
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';

import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/constants';
Expand All @@ -9,17 +9,17 @@ import { ontimeQueryClient } from '../queryClient';
* Returns current time in milliseconds
* @returns {number}
*/
export const nowInMillis = () => {
export function nowInMillis(): number {
const now = new Date();

// extract milliseconds since midnight
let elapsed = now.getHours() * 3600000;
elapsed += now.getMinutes() * 60000;
elapsed += now.getSeconds() * 1000;
let elapsed = now.getHours() * MILLIS_PER_HOUR;
elapsed += now.getMinutes() * MILLIS_PER_MINUTE;
elapsed += now.getSeconds() * MILLIS_PER_SECOND;
elapsed += now.getMilliseconds();

return elapsed;
};
}

/**
* @description Resolves format from url and store
Expand Down 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 / MILLIS_PER_HOUR);
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
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
2 changes: 1 addition & 1 deletion apps/client/src/features/operator/operator.options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
Expand Down
111 changes: 111 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,111 @@
@use '../../../theme/viewerDefs' as *;

$timeline-entry-height: 20px;
$lane-height: 120px;
$timeline-height: 1rem;

.timeline {
flex: 1;
font-weight: 600;
color: $ui-white;
}

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

.column {
display: flex;
flex-direction: column;
position: absolute;
border-left: 1px solid $ui-black;
// avoiding content being larger than the view
height: calc(100% - 3rem);

// decorate timeline element
&::before {
content: '';
position: absolute;
box-sizing: content-box;
top: -$timeline-height;
left: 0;
right: 0;
height: $timeline-height;
background-color: $white-40;
}
}

.smallArea {
.content {
gap: 0rem;
writing-mode: vertical-rl;
}

.timeOverview {
opacity: 0;
}
}

.hide {
// hide text elements
& > div {
display: none;
}
}

.content {
flex: 1;
display: flex;
flex-direction: column;
gap: 2rem;
padding-top: 0.25rem;
padding-inline-start: 0.25rem;
overflow: hidden;
line-height: 1rem;

background-color: var(--lighter, $viewer-card-bg-color);
border-bottom: 2px solid $ui-black;
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);

&[data-status='done'] {
opacity: $opacity-disabled;
}

&[data-status='live'] {
box-shadow: 0 0.25rem 0 0 $active-red;
}
}

.delay {
margin-top: -2rem;
margin-bottom: -1rem;
}

.timeOverview {
padding-top: 0.25rem;
padding-inline-start: 0.25em;
text-transform: capitalize;
white-space: normal;
height: 6rem;

&[data-status='done'] {
opacity: $opacity-disabled;
}

&[data-status='live'] {
.status {
color: $active-red;
}
}

&[data-status='future'] {
.status {
color: $green-500;
}
}
}

.cross {
text-decoration: line-through;
}
107 changes: 107 additions & 0 deletions apps/client/src/features/viewers/timeline/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { memo } from 'react';
import { useViewportSize } from '@mantine/hooks';
import { isOntimeEvent, MaybeNumber, OntimeEvent } from 'ontime-types';
import { dayInMs, getFirstEvent, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';

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

import style from './Timeline.module.scss';

function useTimeline(rundown: OntimeEvent[]) {
const { firstEvent } = getFirstEvent(rundown);
const { lastEvent } = getLastEvent(rundown);
const firstStart = firstEvent?.timeStart ?? 0;
const lastEnd = lastEvent?.timeEnd ?? 0;
const normalisedLastEnd = lastEnd < firstStart ? lastEnd + dayInMs : lastEnd;

// we make sure the end accounts for delays
const accumulatedDelay = lastEvent?.delay ?? 0;
// timeline is padded to nearest hours (floor and ceil)
const startHour = getStartHour(firstStart);
const endHour = getEndHour(normalisedLastEnd + accumulatedDelay);

return {
rundown: rundown,
startHour,
endHour,
};
}

interface TimelineProps {
selectedEventId: string | null;
rundown: OntimeEvent[];
}

export default memo(Timeline);

function Timeline(props: TimelineProps) {
const { selectedEventId, rundown: baseRundown } = props;
const { width: screenWidth } = useViewportSize();
const timelineData = useTimeline(baseRundown);

if (timelineData === null) {
return null;
}

const { rundown, startHour, endHour } = timelineData;

let hasTimelinePassedMidnight = false;
let previousEventStartTime: MaybeNumber = null;
// we use selectedEventId as a signifier on whether the timeline is live
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';

return (
<div className={style.timeline}>
<TimelineMarkers startHour={startHour} endHour={endHour} />
<ProgressBar startHour={startHour} endHour={endHour} />
<div className={style.timelineEvents}>
{rundown.map((event) => {
// for now we dont render delays and blocks
if (!isOntimeEvent(event)) {
return null;
}

// keep track of progress of rundown
if (eventStatus === 'live') {
eventStatus = 'future';
}
if (event.id === selectedEventId) {
eventStatus = 'live';
}

if (!hasTimelinePassedMidnight) {
// we need to offset the start to account for midnight
hasTimelinePassedMidnight = previousEventStartTime !== null && event.timeStart < previousEventStartTime;
}
const normalisedStart = hasTimelinePassedMidnight ? event.timeStart + dayInMs : event.timeStart;
previousEventStartTime = normalisedStart;

const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
startHour * MILLIS_PER_HOUR,
endHour * MILLIS_PER_HOUR,
normalisedStart + (event.delay ?? 0),
event.duration,
screenWidth,
);

return (
<TimelineEntry
key={event.id}
colour={event.colour}
delay={event.delay ?? 0}
duration={event.duration}
left={elementLeftPosition}
status={eventStatus}
start={normalisedStart} // solve issues related to crossing midnight
title={event.title}
width={elementWidth}
/>
);
})}
</div>
</div>
);
}
Loading

0 comments on commit 3012d73

Please sign in to comment.