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 22, 2024
1 parent 1ddcec9 commit f8f1006
Show file tree
Hide file tree
Showing 33 changed files with 803 additions and 17 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 @@ -183,3 +183,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
71 changes: 71 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,71 @@
@use '../../../theme/viewerDefs' as *;

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

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

.timelineEvents {
position: relative;
top: 0.5rem;
height: 100%;
}

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

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

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

.delay {
margin-top: -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;
}
114 changes: 114 additions & 0 deletions apps/client/src/features/viewers/timeline/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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 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() {
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;
const accumulatedDelay = lastEvent?.delay ?? 0;

return {
rundown: data.rundown,
order: data.order,
startHour,
endHour,
accumulatedDelay,
};
}

interface TimelineProps {
selectedEventId: string | null;
}

export default memo(Timeline);

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

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

const { rundown, order, startHour, endHour, accumulatedDelay } = timelineData;

let hasTimelinePassedMidnight = false;
let previousEventStartTime: MaybeNumber = null;
let eventStatus: ProgressStatus = 'done';

return (
<div className={style.timeline}>
<TimelineMarkers />
<ProgressBar startHour={startHour} endHour={endHour + accumulatedDelay} />
<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 + accumulatedDelay,
normalisedStart + (event.delay ?? 0),
event.duration,
screenWidth,
);

return (
<TimelineEntry
key={eventId}
colour={event.colour}
delay={event.delay ?? 0}
duration={event.duration}
left={elementLeftPosition}
status={eventStatus}
start={event.timeStart}
title={event.title}
width={elementWidth}
/>
);
})}
</div>
</div>
);
}
Loading

0 comments on commit f8f1006

Please sign in to comment.