mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
2 Commits
cleanup
...
v3.5.0-beta.1
| Author | SHA1 | Date | |
|---|---|---|---|
| b5075e8d18 | |||
| 2105a2af2a |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.4.0",
|
||||
"version": "3.5.0-beta.1",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.4.0",
|
||||
"version": "3.5.0-beta.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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'));
|
||||
@@ -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'));
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -99,7 +99,6 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
|
||||
.field {
|
||||
font-weight: 600;
|
||||
padding-inline: 0.25rem;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
@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: 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, $ui-white);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useTimelineStatus } from '../../../common/hooks/useSocket';
|
||||
import { alpha } from '../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
|
||||
import { getStatusLabel } from './timeline.utils';
|
||||
|
||||
import style from './Timeline.module.scss';
|
||||
|
||||
export type ProgressStatus = 'done' | 'live' | 'future';
|
||||
|
||||
interface TimelineEntryProps {
|
||||
colour: string;
|
||||
delay: number;
|
||||
duration: number;
|
||||
left: number;
|
||||
status: ProgressStatus;
|
||||
start: number;
|
||||
title: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const formatOptions = {
|
||||
format12: 'hh:mm a',
|
||||
format24: 'HH:mm',
|
||||
};
|
||||
|
||||
export function TimelineEntry(props: TimelineEntryProps) {
|
||||
const { colour, delay, duration, left, status, start, title, width } = props;
|
||||
|
||||
const formattedStartTime = formatTime(start, formatOptions);
|
||||
const formattedDuration = formatDuration(duration);
|
||||
const delayedStart = start + delay;
|
||||
const hasDelay = delay > 0;
|
||||
|
||||
const lighterColour = alpha(colour, 0.7);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={style.column}
|
||||
style={{
|
||||
'--color': colour,
|
||||
'--lighter': lighterColour ?? '',
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={style.content}
|
||||
data-status={status}
|
||||
style={{
|
||||
'--color': colour,
|
||||
}}
|
||||
>
|
||||
<div className={hasDelay ? style.cross : undefined}>{formattedStartTime}</div>
|
||||
{hasDelay && <div className={style.delay}>{formatTime(delayedStart, formatOptions)}</div>}
|
||||
<div>{title}</div>
|
||||
</div>
|
||||
<div className={style.timeOverview} data-status={status}>
|
||||
<div className={style.duration}>{formattedDuration}</div>
|
||||
<TimelineEntryStatus status={status} start={delayedStart} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TimelineEntryStatusProps {
|
||||
status: ProgressStatus;
|
||||
start: number;
|
||||
}
|
||||
|
||||
// we isolate this component to avoid isolate re-renders provoked by the clock changes
|
||||
function TimelineEntryStatus(props: TimelineEntryStatusProps) {
|
||||
const { status, start } = props;
|
||||
const { clock, offset } = useTimelineStatus();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
let statusText = getStatusLabel(start - clock + offset, status);
|
||||
if (statusText === 'live') {
|
||||
statusText = getLocalizedString('timeline.live');
|
||||
} else if (statusText === 'pending') {
|
||||
statusText = getLocalizedString('timeline.due');
|
||||
} else if (statusText === 'done') {
|
||||
statusText = getLocalizedString('timeline.done');
|
||||
}
|
||||
|
||||
return <div className={style.status}>{statusText}</div>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.timeline {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
background-color: $ui-black;
|
||||
color: $ui-white;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding-inline: 2rem;
|
||||
font-size: 3.5rem;
|
||||
}
|
||||
|
||||
.sections {
|
||||
padding-inline: 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
row-gap: 1rem;
|
||||
column-gap: 3rem;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMemo } from 'react';
|
||||
import { MaybeString, OntimeEvent, ProjectData, Settings } from 'ontime-types';
|
||||
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
|
||||
import Section from './timeline-section/TimelineSection';
|
||||
import Timeline from './Timeline';
|
||||
import { getTimelineOptions } from './timeline.options';
|
||||
import { getFormattedTimeToStart, getUpcomingEvents } from './timeline.utils';
|
||||
|
||||
import style from './TimelinePage.module.scss';
|
||||
|
||||
interface TimelinePageProps {
|
||||
backstageEvents: OntimeEvent[];
|
||||
general: ProjectData;
|
||||
selectedId: MaybeString;
|
||||
settings: Settings | undefined;
|
||||
time: ViewExtendedTimer;
|
||||
}
|
||||
|
||||
/**
|
||||
* since we inherit from viewPage
|
||||
* which refreshes at least once a second
|
||||
* There is little point splitting or memoising top level elements
|
||||
*/
|
||||
export default function TimelinePage(props: TimelinePageProps) {
|
||||
const { backstageEvents, general, selectedId, settings, time } = props;
|
||||
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const clock = formatTime(time.clock);
|
||||
|
||||
const { now, next, followedBy } = useMemo(() => {
|
||||
return getUpcomingEvents(backstageEvents, selectedId);
|
||||
}, [backstageEvents, selectedId]);
|
||||
|
||||
// populate options
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
const progressOptions = getTimelineOptions(defaultFormat);
|
||||
|
||||
const titleNow = now?.title ?? '-';
|
||||
const dueText = getLocalizedString('timeline.due');
|
||||
const nextText = next !== null ? `${next.title} · ${getFormattedTimeToStart(next, time.clock, dueText)}` : '-';
|
||||
const followedByText =
|
||||
followedBy !== null ? `${followedBy.title} · ${getFormattedTimeToStart(followedBy, time.clock, dueText)}` : '-';
|
||||
|
||||
return (
|
||||
<div className={style.timeline}>
|
||||
<ViewParamsEditor viewOptions={progressOptions} />
|
||||
<div className={style.title}>{general.title}</div>
|
||||
<div className={style.sections}>
|
||||
<Section title={getLocalizedString('common.time_now')} content={clock} category='now' />
|
||||
<Section title={getLocalizedString('common.next')} content={nextText} category='next' />
|
||||
<Section title={getLocalizedString('timeline.live')} content={titleNow} category='now' />
|
||||
<Section title={getLocalizedString('timeline.followedby')} content={followedByText} category='next' />
|
||||
</div>
|
||||
<Timeline selectedEventId={selectedId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import { getElementPosition, makeTimelineSections } from '../timeline.utils';
|
||||
|
||||
describe('getCSSPosition()', () => {
|
||||
it('accounts for rundown with one event', () => {
|
||||
const scheduleStart = 0;
|
||||
const scheduleEnd = dayInMs;
|
||||
const eventStart = 0;
|
||||
const eventDuration = dayInMs;
|
||||
const containerWidth = 100;
|
||||
|
||||
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
|
||||
expect(result.left).toBe(0);
|
||||
expect(result.width).toBe(containerWidth);
|
||||
});
|
||||
|
||||
it('accounts for an event that starts halfway and ends at end', () => {
|
||||
const scheduleStart = 0;
|
||||
const scheduleEnd = 100;
|
||||
const eventStart = 50;
|
||||
const eventDuration = 50;
|
||||
const containerWidth = 100;
|
||||
|
||||
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
|
||||
expect(result.left).toBe(50);
|
||||
expect(result.width).toBe(50);
|
||||
});
|
||||
|
||||
it('accounts for an event that starts first and ends halfway', () => {
|
||||
const scheduleStart = 0;
|
||||
const scheduleEnd = 100;
|
||||
const eventStart = 0;
|
||||
const eventDuration = 50;
|
||||
const containerWidth = 100;
|
||||
|
||||
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
|
||||
expect(result.left).toBe(0);
|
||||
expect(result.width).toBe(50);
|
||||
});
|
||||
|
||||
it('accounts for an event that is in the middle of the rundown', () => {
|
||||
const scheduleStart = 7;
|
||||
const scheduleEnd = 23;
|
||||
const eventStart = 10;
|
||||
const eventDuration = 1;
|
||||
const containerWidth = 1000;
|
||||
|
||||
// 16 hour event, this gives 62.5px per hour
|
||||
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
|
||||
expect(result.left).toBe(187.5); // 3 * 62.5
|
||||
expect(result.width).toBe(62.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeTmelineSections', () => {
|
||||
it('creates an array between the hours given, end excluded', () => {
|
||||
const result = makeTimelineSections(11, 17);
|
||||
expect(result).toEqual(['11:00', '12:00', '13:00', '14:00', '15:00', '16:00']);
|
||||
});
|
||||
|
||||
it('wraps around midnight', () => {
|
||||
const result = makeTimelineSections(22, 26);
|
||||
expect(result).toEqual(['22:00', '23:00', '00:00', '01:00']);
|
||||
});
|
||||
});
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
.markers {
|
||||
width: 100%;
|
||||
color: $ui-white;
|
||||
display: flex;
|
||||
height: 1rem;
|
||||
line-height: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: calc(1rem - 2px);
|
||||
justify-content: space-evenly;
|
||||
|
||||
& > span {
|
||||
flex-grow: 1;
|
||||
border-left: 1px solid $white-7;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import useRundown from '../../../../common/hooks-query/useRundown';
|
||||
import { getTimelineSections } from '../timeline.utils';
|
||||
|
||||
import style from './TimelineMarkers.module.scss';
|
||||
|
||||
export default function TimelineMarkers() {
|
||||
const { data } = useRundown();
|
||||
|
||||
if (!data || data.revision === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elements = getTimelineSections(data.rundown, data.order);
|
||||
|
||||
return (
|
||||
<div className={style.markers}>
|
||||
{elements.map((tag) => {
|
||||
return <span key={tag}>{tag}</span>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
.progressBar {
|
||||
width: 100%;
|
||||
height: 0.5rem;
|
||||
|
||||
transition-duration: 0.3s;
|
||||
transition-property: left;
|
||||
background-color: $gray-1000;
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
background-color: $active-red;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { useClock } from '../../../../common/hooks/useSocket';
|
||||
import { getRelativePositionX } from '../timeline.utils';
|
||||
|
||||
import style from './TimelineProgressBar.module.scss';
|
||||
|
||||
interface ProgressBarProps {
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
}
|
||||
|
||||
export default function ProgressBar(props: ProgressBarProps) {
|
||||
const { startHour, endHour } = props;
|
||||
const { clock } = useClock();
|
||||
|
||||
const width = getRelativePositionX(startHour, endHour, clock);
|
||||
|
||||
return (
|
||||
<div className={style.progressBar}>
|
||||
<div className={style.progress} style={{ width: `${width}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
.sectionTitle {
|
||||
line-height: 1.2em;
|
||||
font-size: 1.5rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sectionContent {
|
||||
min-height: 2em;
|
||||
line-height: 1em;
|
||||
font-size: 3rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
|
||||
&.now {
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
&.next {
|
||||
color: $green-500;
|
||||
}
|
||||
|
||||
&.subdue {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MaybeString } from 'ontime-types';
|
||||
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
|
||||
import style from './TimelineSection.module.scss';
|
||||
|
||||
interface SectionProps {
|
||||
category: 'now' | 'next';
|
||||
content: MaybeString;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function Section(props: SectionProps) {
|
||||
const { category, content, title } = props;
|
||||
|
||||
const contentClasses = cx([style.sectionContent, content != null ? style[category] : style.subdue]);
|
||||
return (
|
||||
<div>
|
||||
<div className={style.sectionTitle}>{title}</div>
|
||||
<div className={contentClasses}>{content ?? '-'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { getTimeOption } from '../../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||
|
||||
export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
|
||||
return [getTimeOption(timeFormat)];
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import { isOntimeEvent, MaybeString, NormalisedRundown, OntimeEvent } from 'ontime-types';
|
||||
import {
|
||||
dayInMs,
|
||||
getEventWithId,
|
||||
getFirstEvent,
|
||||
getFirstEventNormal,
|
||||
getLastEventNormal,
|
||||
getNextEvent,
|
||||
MILLIS_PER_HOUR,
|
||||
millisToString,
|
||||
removeSeconds,
|
||||
} from 'ontime-utils';
|
||||
|
||||
import { clamp } from '../../../common/utils/math';
|
||||
import { formatDuration } from '../../../common/utils/time';
|
||||
|
||||
import type { ProgressStatus } from './TimelineEntry';
|
||||
|
||||
type CSSPosition = {
|
||||
left: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the position (in %) of an element relative to a schedule
|
||||
*/
|
||||
export function getRelativePositionX(scheduleStart: number, scheduleEnd: number, now: number): number {
|
||||
return clamp(((now - scheduleStart) / (scheduleEnd - scheduleStart)) * 100, 0, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates an absolute position of an element based on a schedule
|
||||
*/
|
||||
export function getElementPosition(
|
||||
scheduleStart: number,
|
||||
scheduleEnd: number,
|
||||
eventStart: number,
|
||||
eventDuration: number,
|
||||
containerWidth: number,
|
||||
): CSSPosition {
|
||||
const normalEnd = scheduleEnd < scheduleStart ? scheduleEnd + dayInMs : scheduleEnd;
|
||||
const totalDuration = normalEnd - scheduleStart;
|
||||
const width = (eventDuration * containerWidth) / totalDuration;
|
||||
const left = ((eventStart - scheduleStart) * containerWidth) / totalDuration;
|
||||
|
||||
return { left, width };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets rounded down hour for a given time
|
||||
*/
|
||||
export function getStartHour(startTime: number): number {
|
||||
const hours = Math.floor(startTime / MILLIS_PER_HOUR);
|
||||
return hours;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets rounded up hour for a given time
|
||||
*/
|
||||
export function getEndHour(endTime: number): number {
|
||||
const hours = Math.ceil(endTime / MILLIS_PER_HOUR);
|
||||
return hours;
|
||||
}
|
||||
|
||||
/**
|
||||
* converts a time span into an array of hours
|
||||
*/
|
||||
export function makeTimelineSections(firstHour: number, lastHour: number) {
|
||||
const timelineSections = [];
|
||||
for (let i = firstHour; i < lastHour; i++) {
|
||||
timelineSections.push(removeSeconds(millisToString((i % 24) * MILLIS_PER_HOUR)));
|
||||
}
|
||||
return timelineSections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the timeline sections from a rundown
|
||||
*/
|
||||
export function getTimelineSections(rundown: NormalisedRundown, order: string[]): string[] {
|
||||
if (order.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const { firstEvent } = getFirstEventNormal(rundown, order);
|
||||
const { lastEvent } = getLastEventNormal(rundown, order);
|
||||
const firstStart = firstEvent?.timeStart ?? 0;
|
||||
const lastEnd = lastEvent?.timeEnd ?? 0;
|
||||
const normalisedLastEnd = lastEnd < firstStart ? lastEnd + dayInMs : lastEnd;
|
||||
|
||||
const startHour = getStartHour(firstStart);
|
||||
const endHour = getEndHour(normalisedLastEnd);
|
||||
|
||||
const elements = makeTimelineSections(startHour, endHour);
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a formatted label for a progress status
|
||||
*/
|
||||
export function getStatusLabel(timeToStart: number, status: ProgressStatus): string {
|
||||
if (status === 'done' || status === 'live') {
|
||||
return status;
|
||||
}
|
||||
|
||||
if (timeToStart < 0) {
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
return formatDuration(timeToStart);
|
||||
}
|
||||
|
||||
type UpcomingEvents = {
|
||||
now: OntimeEvent | null;
|
||||
next: OntimeEvent | null;
|
||||
followedBy: OntimeEvent | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns upcoming events from current: now, next and followedBy
|
||||
*/
|
||||
export function getUpcomingEvents(events: OntimeEvent[], selectedId: MaybeString): UpcomingEvents {
|
||||
if (events.length === 0) {
|
||||
return { now: null, next: null, followedBy: null };
|
||||
}
|
||||
|
||||
const now = selectedId ? getEventWithId(events, selectedId) : getFirstEvent(events)?.firstEvent;
|
||||
|
||||
if (!isOntimeEvent(now)) {
|
||||
return { now: null, next: null, followedBy: null };
|
||||
}
|
||||
|
||||
const next = getNextEvent(events, now.id)?.nextEvent;
|
||||
const followedBy = next ? getNextEvent(events, next.id)?.nextEvent : null;
|
||||
|
||||
// Return the titles, handling nulls appropriately
|
||||
return {
|
||||
now,
|
||||
next,
|
||||
followedBy,
|
||||
};
|
||||
}
|
||||
|
||||
export function getFormattedTimeToStart(event: OntimeEvent, now: number, dueText: string): string {
|
||||
const timeToStart = event.timeStart - now;
|
||||
|
||||
if (timeToStart < 0) {
|
||||
return dueText;
|
||||
}
|
||||
|
||||
return `T - ${formatDuration(timeToStart)}`;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ $ontime-color: #ff7597;
|
||||
$error-red: $red-500;
|
||||
$warning-orange: $orange-500;
|
||||
$opacity-disabled: 0.4;
|
||||
$active-red: $red-700;
|
||||
|
||||
// playback colours
|
||||
$playback-start: $green-600;
|
||||
|
||||
@@ -19,4 +19,8 @@ export const langDe: TranslationObject = {
|
||||
'countdown.to_start': 'Zeit bis zum Start',
|
||||
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
|
||||
'countdown.overtime': 'überfällig',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'Beendet',
|
||||
'timeline.due': 'fällig',
|
||||
'timeline.followedby': 'Gefolgt von',
|
||||
};
|
||||
|
||||
@@ -17,6 +17,10 @@ export const langEn = {
|
||||
'countdown.to_start': 'Time to start',
|
||||
'countdown.waiting': 'Waiting for event start',
|
||||
'countdown.overtime': 'in overtime',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'done',
|
||||
'timeline.due': 'due',
|
||||
'timeline.followedby': 'Followed by',
|
||||
};
|
||||
|
||||
export type TranslationObject = Record<keyof typeof langEn, string>;
|
||||
|
||||
@@ -19,4 +19,8 @@ export const langEs: TranslationObject = {
|
||||
'countdown.to_start': 'Tiempo para comenzar',
|
||||
'countdown.waiting': 'Esperando el inicio del evento',
|
||||
'countdown.overtime': 'en tiempo extra',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'Terminado',
|
||||
'timeline.due': 'pendiente',
|
||||
'timeline.followedby': 'Seguido por',
|
||||
};
|
||||
|
||||
@@ -19,4 +19,8 @@ export const langFr: TranslationObject = {
|
||||
'countdown.to_start': 'Évènement commence dans',
|
||||
'countdown.waiting': 'En attente du début de l’évènement',
|
||||
'countdown.overtime': 'en dépassement',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'Terminé',
|
||||
'timeline.due': 'dû',
|
||||
'timeline.followedby': 'Suivi de',
|
||||
};
|
||||
|
||||
@@ -19,4 +19,8 @@ export const langIt: TranslationObject = {
|
||||
'countdown.to_start': 'Tempo alla partenza',
|
||||
'countdown.waiting': "In attesa dell'inizio dell'evento",
|
||||
'countdown.overtime': 'in ritardo',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'Terminato',
|
||||
'timeline.due': 'previsto',
|
||||
'timeline.followedby': 'Seguito da',
|
||||
};
|
||||
|
||||
@@ -19,4 +19,8 @@ export const langNo: TranslationObject = {
|
||||
'countdown.to_start': 'Tid til start',
|
||||
'countdown.waiting': 'Venter på start',
|
||||
'countdown.overtime': 'i overtiden',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'Ferdig',
|
||||
'timeline.due': 'Venter',
|
||||
'timeline.followedby': 'Etterfulgt av',
|
||||
};
|
||||
|
||||
@@ -19,4 +19,8 @@ export const langPl: TranslationObject = {
|
||||
'countdown.to_start': 'Do rozpoczęcia',
|
||||
'countdown.waiting': 'Oczekiwanie na start',
|
||||
'countdown.overtime': 'ponad czasem',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'Zakończony',
|
||||
'timeline.due': 'termin',
|
||||
'timeline.followedby': 'Następnie',
|
||||
};
|
||||
|
||||
@@ -19,4 +19,8 @@ export const langPt: TranslationObject = {
|
||||
'countdown.to_start': 'Tempo para iniciar',
|
||||
'countdown.waiting': 'Aguardando o início do evento',
|
||||
'countdown.overtime': 'em tempo extra',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'Concluído',
|
||||
'timeline.due': 'Pendente',
|
||||
'timeline.followedby': 'Seguido por',
|
||||
};
|
||||
|
||||
@@ -19,4 +19,8 @@ export const langSv: TranslationObject = {
|
||||
'countdown.to_start': 'Tid till start',
|
||||
'countdown.waiting': 'Väntar på att evenemanget ska starta',
|
||||
'countdown.overtime': 'i övertid',
|
||||
'timeline.live': 'live',
|
||||
'timeline.done': 'Avslutad',
|
||||
'timeline.due': 'Väntande',
|
||||
'timeline.followedby': 'Följt av',
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ export const navigatorConstants = [
|
||||
{ url: '/clock', label: 'Clock' },
|
||||
{ url: '/minimal', label: 'Minimal Timer' },
|
||||
{ url: '/backstage', label: 'Backstage' },
|
||||
{ url: '/timeline', label: 'Timeline' },
|
||||
{ url: '/public', label: 'Public' },
|
||||
{ url: '/lower', label: 'Lower Thirds' },
|
||||
{ url: '/studio', label: 'Studio Clock' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.4.0",
|
||||
"version": "3.5.0-beta.1",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "3.4.0",
|
||||
"version": "3.5.0-beta.1",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.4.0",
|
||||
"version": "3.5.0-beta.1",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
|
||||
@@ -8,6 +8,7 @@ export { sanitiseCue } from './src/cue-utils/cueUtils.js';
|
||||
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
|
||||
export { generateId } from './src/generate-id/generateId.js';
|
||||
export {
|
||||
getEventWithId,
|
||||
getFirst,
|
||||
getFirstEvent,
|
||||
getFirstEventNormal,
|
||||
|
||||
@@ -326,3 +326,7 @@ export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): { newA:
|
||||
|
||||
return { newA, newB };
|
||||
};
|
||||
|
||||
export function getEventWithId(rundown: OntimeRundown, id: string): OntimeRundownEntry | undefined {
|
||||
return rundown.find((event) => event.id === id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user