mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 18:03:47 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5075e8d18 | |||
| 2105a2af2a |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "3.4.0",
|
"version": "3.5.0-beta.1",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.4.0",
|
"version": "3.5.0-beta.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
|||||||
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
||||||
|
|
||||||
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
|
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 Public = React.lazy(() => import('./features/viewers/public/Public'));
|
||||||
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||||
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||||
@@ -39,6 +40,7 @@ const SBackstage = withPreset(withData(Backstage));
|
|||||||
const SPublic = withPreset(withData(Public));
|
const SPublic = withPreset(withData(Public));
|
||||||
const SLowerThird = withPreset(withData(Lower));
|
const SLowerThird = withPreset(withData(Lower));
|
||||||
const SStudio = withPreset(withData(StudioClock));
|
const SStudio = withPreset(withData(StudioClock));
|
||||||
|
const STimeline = withPreset(withData(Timeline));
|
||||||
|
|
||||||
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
|
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
|
||||||
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
|
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
|
||||||
@@ -74,26 +76,20 @@ export default function AppRouter() {
|
|||||||
<SentryRoutes>
|
<SentryRoutes>
|
||||||
<Route path='/' element={<Navigate to='/timer' />} />
|
<Route path='/' element={<Navigate to='/timer' />} />
|
||||||
<Route path='/timer' element={<STimer />} />
|
<Route path='/timer' element={<STimer />} />
|
||||||
|
<Route path='/public' element={<SPublic />} />
|
||||||
<Route path='/minimal' element={<SMinimalTimer />} />
|
<Route path='/minimal' element={<SMinimalTimer />} />
|
||||||
|
|
||||||
<Route path='/clock' element={<SClock />} />
|
<Route path='/clock' element={<SClock />} />
|
||||||
|
|
||||||
<Route path='/countdown' element={<SCountdown />} />
|
<Route path='/countdown' element={<SCountdown />} />
|
||||||
|
|
||||||
<Route path='/backstage' element={<SBackstage />} />
|
<Route path='/backstage' element={<SBackstage />} />
|
||||||
|
|
||||||
<Route path='/public' element={<SPublic />} />
|
|
||||||
|
|
||||||
<Route path='/studio' element={<SStudio />} />
|
<Route path='/studio' element={<SStudio />} />
|
||||||
|
|
||||||
<Route path='/lower' element={<SLowerThird />} />
|
<Route path='/lower' element={<SLowerThird />} />
|
||||||
|
<Route path='/timeline' element={<STimeline />} />
|
||||||
<Route path='/op' element={<Operator />} />
|
|
||||||
|
|
||||||
{/*/!* Protected Routes *!/*/}
|
{/*/!* Protected Routes *!/*/}
|
||||||
<Route path='/editor' element={<Editor />} />
|
<Route path='/editor' element={<Editor />} />
|
||||||
<Route path='/cuesheet' element={<Cuesheet />} />
|
<Route path='/cuesheet' element={<Cuesheet />} />
|
||||||
|
<Route path='/op' element={<Operator />} />
|
||||||
|
|
||||||
{/*/!* Protected Routes - Elements *!/*/}
|
{/*/!* Protected Routes - Elements *!/*/}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ interface EditFormDrawerProps {
|
|||||||
viewOptions: ViewOption[];
|
viewOptions: ViewOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
|
||||||
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||||
|
|||||||
@@ -183,3 +183,12 @@ export const useRuntimePlaybackOverview = () => {
|
|||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
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 timerPlaceholder = '––:––:––';
|
||||||
export const timerPlaceholderMin = '––:––';
|
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 { 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 { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
|
||||||
import { APP_SETTINGS } from '../api/constants';
|
import { APP_SETTINGS } from '../api/constants';
|
||||||
@@ -9,17 +9,17 @@ import { ontimeQueryClient } from '../queryClient';
|
|||||||
* Returns current time in milliseconds
|
* Returns current time in milliseconds
|
||||||
* @returns {number}
|
* @returns {number}
|
||||||
*/
|
*/
|
||||||
export const nowInMillis = () => {
|
export function nowInMillis(): number {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// extract milliseconds since midnight
|
// extract milliseconds since midnight
|
||||||
let elapsed = now.getHours() * 3600000;
|
let elapsed = now.getHours() * MILLIS_PER_HOUR;
|
||||||
elapsed += now.getMinutes() * 60000;
|
elapsed += now.getMinutes() * MILLIS_PER_MINUTE;
|
||||||
elapsed += now.getSeconds() * 1000;
|
elapsed += now.getSeconds() * MILLIS_PER_SECOND;
|
||||||
elapsed += now.getMilliseconds();
|
elapsed += now.getMilliseconds();
|
||||||
|
|
||||||
return elapsed;
|
return elapsed;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Resolves format from url and store
|
* @description Resolves format from url and store
|
||||||
@@ -95,3 +95,26 @@ export const formatTime = (
|
|||||||
const isNegative = milliseconds < 0;
|
const isNegative = milliseconds < 0;
|
||||||
return `${isNegative ? '-' : ''}${display}`;
|
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 {
|
&.running {
|
||||||
border-top: 1px solid $gray-1300;
|
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 {
|
&.past {
|
||||||
@@ -99,7 +99,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding-inline: 0.25rem;
|
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;
|
$error-red: $red-500;
|
||||||
$warning-orange: $orange-500;
|
$warning-orange: $orange-500;
|
||||||
$opacity-disabled: 0.4;
|
$opacity-disabled: 0.4;
|
||||||
|
$active-red: $red-700;
|
||||||
|
|
||||||
// playback colours
|
// playback colours
|
||||||
$playback-start: $green-600;
|
$playback-start: $green-600;
|
||||||
|
|||||||
@@ -19,4 +19,8 @@ export const langDe: TranslationObject = {
|
|||||||
'countdown.to_start': 'Zeit bis zum Start',
|
'countdown.to_start': 'Zeit bis zum Start',
|
||||||
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
|
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
|
||||||
'countdown.overtime': 'überfällig',
|
'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.to_start': 'Time to start',
|
||||||
'countdown.waiting': 'Waiting for event start',
|
'countdown.waiting': 'Waiting for event start',
|
||||||
'countdown.overtime': 'in overtime',
|
'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>;
|
export type TranslationObject = Record<keyof typeof langEn, string>;
|
||||||
|
|||||||
@@ -19,4 +19,8 @@ export const langEs: TranslationObject = {
|
|||||||
'countdown.to_start': 'Tiempo para comenzar',
|
'countdown.to_start': 'Tiempo para comenzar',
|
||||||
'countdown.waiting': 'Esperando el inicio del evento',
|
'countdown.waiting': 'Esperando el inicio del evento',
|
||||||
'countdown.overtime': 'en tiempo extra',
|
'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.to_start': 'Évènement commence dans',
|
||||||
'countdown.waiting': 'En attente du début de l’évènement',
|
'countdown.waiting': 'En attente du début de l’évènement',
|
||||||
'countdown.overtime': 'en dépassement',
|
'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.to_start': 'Tempo alla partenza',
|
||||||
'countdown.waiting': "In attesa dell'inizio dell'evento",
|
'countdown.waiting': "In attesa dell'inizio dell'evento",
|
||||||
'countdown.overtime': 'in ritardo',
|
'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.to_start': 'Tid til start',
|
||||||
'countdown.waiting': 'Venter på start',
|
'countdown.waiting': 'Venter på start',
|
||||||
'countdown.overtime': 'i overtiden',
|
'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.to_start': 'Do rozpoczęcia',
|
||||||
'countdown.waiting': 'Oczekiwanie na start',
|
'countdown.waiting': 'Oczekiwanie na start',
|
||||||
'countdown.overtime': 'ponad czasem',
|
'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.to_start': 'Tempo para iniciar',
|
||||||
'countdown.waiting': 'Aguardando o início do evento',
|
'countdown.waiting': 'Aguardando o início do evento',
|
||||||
'countdown.overtime': 'em tempo extra',
|
'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.to_start': 'Tid till start',
|
||||||
'countdown.waiting': 'Väntar på att evenemanget ska starta',
|
'countdown.waiting': 'Väntar på att evenemanget ska starta',
|
||||||
'countdown.overtime': 'i övertid',
|
'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: '/clock', label: 'Clock' },
|
||||||
{ url: '/minimal', label: 'Minimal Timer' },
|
{ url: '/minimal', label: 'Minimal Timer' },
|
||||||
{ url: '/backstage', label: 'Backstage' },
|
{ url: '/backstage', label: 'Backstage' },
|
||||||
|
{ url: '/timeline', label: 'Timeline' },
|
||||||
{ url: '/public', label: 'Public' },
|
{ url: '/public', label: 'Public' },
|
||||||
{ url: '/lower', label: 'Lower Thirds' },
|
{ url: '/lower', label: 'Lower Thirds' },
|
||||||
{ url: '/studio', label: 'Studio Clock' },
|
{ url: '/studio', label: 'Studio Clock' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "3.4.0",
|
"version": "3.5.0-beta.1",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "3.4.0",
|
"version": "3.5.0-beta.1",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "3.4.0",
|
"version": "3.5.0-beta.1",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ontime",
|
"ontime",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export { sanitiseCue } from './src/cue-utils/cueUtils.js';
|
|||||||
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
|
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
|
||||||
export { generateId } from './src/generate-id/generateId.js';
|
export { generateId } from './src/generate-id/generateId.js';
|
||||||
export {
|
export {
|
||||||
|
getEventWithId,
|
||||||
getFirst,
|
getFirst,
|
||||||
getFirstEvent,
|
getFirstEvent,
|
||||||
getFirstEventNormal,
|
getFirstEventNormal,
|
||||||
|
|||||||
@@ -326,3 +326,7 @@ export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): { newA:
|
|||||||
|
|
||||||
return { newA, newB };
|
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