Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: timeline view #1131

Merged
merged 1 commit into from
Aug 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -184,3 +184,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 */
}
cpvalente marked this conversation as resolved.
Show resolved Hide resolved
cpvalente marked this conversation as resolved.
Show resolved Hide resolved
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