diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx index 3aeb83209..37a9ddd0b 100644 --- a/apps/client/src/AppRouter.tsx +++ b/apps/client/src/AppRouter.tsx @@ -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() { } /> } /> - + } /> } /> - } /> } /> - } /> - - } /> - } /> - } /> - - } /> + } /> {/*/!* Protected Routes *!/*/} } /> } /> + } /> {/*/!* Protected Routes - Elements *!/*/} { return useRuntimeStore(featureSelector); }; + +export const useTimelineStatus = () => { + const featureSelector = (state: RuntimeStore) => ({ + clock: state.clock, + offset: state.runtime.offset, + }); + + return useRuntimeStore(featureSelector); +}; diff --git a/apps/client/src/common/utils/styleUtils.ts b/apps/client/src/common/utils/styleUtils.ts index b54e94358..3c3c331e5 100644 --- a/apps/client/src/common/utils/styleUtils.ts +++ b/apps/client/src/common/utils/styleUtils.ts @@ -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; +} diff --git a/apps/client/src/common/utils/time.ts b/apps/client/src/common/utils/time.ts index f41af9e3b..64a2ea037 100644 --- a/apps/client/src/common/utils/time.ts +++ b/apps/client/src/common/utils/time.ts @@ -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; +} diff --git a/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss b/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss index 94e6982cb..efc78c670 100644 --- a/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss +++ b/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss @@ -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; diff --git a/apps/client/src/features/operator/operator.options.tsx b/apps/client/src/features/operator/operator.options.tsx index 203bddcd8..0e31e28b1 100644 --- a/apps/client/src/features/operator/operator.options.tsx +++ b/apps/client/src/features/operator/operator.options.tsx @@ -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, }, diff --git a/apps/client/src/features/viewers/timeline/Timeline.module.scss b/apps/client/src/features/viewers/timeline/Timeline.module.scss new file mode 100644 index 000000000..9e3580deb --- /dev/null +++ b/apps/client/src/features/viewers/timeline/Timeline.module.scss @@ -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; +} diff --git a/apps/client/src/features/viewers/timeline/Timeline.tsx b/apps/client/src/features/viewers/timeline/Timeline.tsx new file mode 100644 index 000000000..eddba2c5c --- /dev/null +++ b/apps/client/src/features/viewers/timeline/Timeline.tsx @@ -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 ( +
+ + +
+ {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 ( + + ); + })} +
+
+ ); +} diff --git a/apps/client/src/features/viewers/timeline/TimelineEntry.tsx b/apps/client/src/features/viewers/timeline/TimelineEntry.tsx new file mode 100644 index 000000000..433af75b8 --- /dev/null +++ b/apps/client/src/features/viewers/timeline/TimelineEntry.tsx @@ -0,0 +1,94 @@ +import { useTimelineStatus } from '../../../common/hooks/useSocket'; +import { alpha, cx } 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); + const columnClasses = cx([style.column, width < 40 && style.smallArea]); + const contentClasses = cx([style.content, width < 20 && style.hide]); + const showTitle = width > 25; + + return ( +
+
+
{formattedStartTime}
+ {hasDelay &&
{formatTime(delayedStart, formatOptions)}
} + {showTitle &&
{title}
} +
+
+ {status !== 'done' && ( + <> +
{formattedDuration}
+ + + )} +
+
+ ); +} + +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(); + + // start times need to be normalised in a rundown that crosses midnight + let statusText = getStatusLabel(start - clock + offset, status); + if (statusText === 'live') { + statusText = getLocalizedString('timeline.live'); + } else if (statusText === 'pending') { + statusText = getLocalizedString('timeline.due'); + } + + return
{statusText}
; +} diff --git a/apps/client/src/features/viewers/timeline/TimelinePage.module.scss b/apps/client/src/features/viewers/timeline/TimelinePage.module.scss new file mode 100644 index 000000000..f57864f8e --- /dev/null +++ b/apps/client/src/features/viewers/timeline/TimelinePage.module.scss @@ -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; +} diff --git a/apps/client/src/features/viewers/timeline/TimelinePage.tsx b/apps/client/src/features/viewers/timeline/TimelinePage.tsx new file mode 100644 index 000000000..082090a84 --- /dev/null +++ b/apps/client/src/features/viewers/timeline/TimelinePage.tsx @@ -0,0 +1,67 @@ +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, getScopedRundown, getUpcomingEvents } from './timeline.utils'; + +import style from './TimelinePage.module.scss'; + +interface TimelinePageProps { + backstageEvents: OntimeEvent[]; + events: 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 { events, general, selectedId, settings, time } = props; + + const { getLocalizedString } = useTranslation(); + const clock = formatTime(time.clock); + + const scopedRundown = useMemo(() => { + return getScopedRundown(events, selectedId); + }, [events, selectedId]); + + const { now, next, followedBy } = useMemo(() => { + return getUpcomingEvents(scopedRundown, selectedId); + }, [scopedRundown, 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 ( +
+ +
{general.title}
+
+
+
+
+
+
+ +
+ ); +} diff --git a/apps/client/src/features/viewers/timeline/__tests__/timeline.utils.test.ts b/apps/client/src/features/viewers/timeline/__tests__/timeline.utils.test.ts new file mode 100644 index 000000000..a522b36ef --- /dev/null +++ b/apps/client/src/features/viewers/timeline/__tests__/timeline.utils.test.ts @@ -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']); + }); +}); diff --git a/apps/client/src/features/viewers/timeline/timeline-markers/TimelineMarkers.module.scss b/apps/client/src/features/viewers/timeline/timeline-markers/TimelineMarkers.module.scss new file mode 100644 index 000000000..b2961ee42 --- /dev/null +++ b/apps/client/src/features/viewers/timeline/timeline-markers/TimelineMarkers.module.scss @@ -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; + } +} diff --git a/apps/client/src/features/viewers/timeline/timeline-markers/TimelineMarkers.tsx b/apps/client/src/features/viewers/timeline/timeline-markers/TimelineMarkers.tsx new file mode 100644 index 000000000..f6ba72c85 --- /dev/null +++ b/apps/client/src/features/viewers/timeline/timeline-markers/TimelineMarkers.tsx @@ -0,0 +1,22 @@ +import { makeTimelineSections } from '../timeline.utils'; + +import style from './TimelineMarkers.module.scss'; + +interface TimelineMarkersProps { + startHour: number; + endHour: number; +} + +export default function TimelineMarkers(props: TimelineMarkersProps) { + const { startHour, endHour } = props; + + const elements = makeTimelineSections(startHour, endHour); + + return ( +
+ {elements.map((tag) => { + return {tag}; + })} +
+ ); +} diff --git a/apps/client/src/features/viewers/timeline/timeline-progress-bar/TimelineProgressBar.module.scss b/apps/client/src/features/viewers/timeline/timeline-progress-bar/TimelineProgressBar.module.scss new file mode 100644 index 000000000..66995ed3c --- /dev/null +++ b/apps/client/src/features/viewers/timeline/timeline-progress-bar/TimelineProgressBar.module.scss @@ -0,0 +1,13 @@ +.progressBar { + width: 100%; + height: 1rem; + + transition-duration: 0.3s; + transition-property: left; + background-color: $gray-1000; + + .progress { + height: 100%; + background-color: $active-red; + } +} diff --git a/apps/client/src/features/viewers/timeline/timeline-progress-bar/TimelineProgressBar.tsx b/apps/client/src/features/viewers/timeline/timeline-progress-bar/TimelineProgressBar.tsx new file mode 100644 index 000000000..153d9087e --- /dev/null +++ b/apps/client/src/features/viewers/timeline/timeline-progress-bar/TimelineProgressBar.tsx @@ -0,0 +1,24 @@ +import { MILLIS_PER_HOUR } from 'ontime-utils'; + +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 * MILLIS_PER_HOUR, endHour * MILLIS_PER_HOUR, clock); + + return ( +
+
+
+ ); +} diff --git a/apps/client/src/features/viewers/timeline/timeline-section/TimelineSection.module.scss b/apps/client/src/features/viewers/timeline/timeline-section/TimelineSection.module.scss new file mode 100644 index 000000000..3dc1ae61f --- /dev/null +++ b/apps/client/src/features/viewers/timeline/timeline-section/TimelineSection.module.scss @@ -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; + } +} diff --git a/apps/client/src/features/viewers/timeline/timeline-section/TimelineSection.tsx b/apps/client/src/features/viewers/timeline/timeline-section/TimelineSection.tsx new file mode 100644 index 000000000..4d909ce38 --- /dev/null +++ b/apps/client/src/features/viewers/timeline/timeline-section/TimelineSection.tsx @@ -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 ( +
+
{title}
+
{content ?? '-'}
+
+ ); +} diff --git a/apps/client/src/features/viewers/timeline/timeline.options.ts b/apps/client/src/features/viewers/timeline/timeline.options.ts new file mode 100644 index 000000000..ca314df52 --- /dev/null +++ b/apps/client/src/features/viewers/timeline/timeline.options.ts @@ -0,0 +1,22 @@ +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), + { + id: 'hidePast', + title: 'Hide Past Events', + description: 'Whether to hide events that have passed', + type: 'boolean', + defaultValue: false, + }, + { + id: 'hideBackstage', + title: 'Hide Private Events', + description: 'Whether to hide non-public events', + type: 'boolean', + defaultValue: false, + }, + ]; +}; diff --git a/apps/client/src/features/viewers/timeline/timeline.utils.ts b/apps/client/src/features/viewers/timeline/timeline.utils.ts new file mode 100644 index 000000000..27db65c19 --- /dev/null +++ b/apps/client/src/features/viewers/timeline/timeline.utils.ts @@ -0,0 +1,154 @@ +import { isOntimeEvent, MaybeString, OntimeEvent } from 'ontime-types'; +import { + dayInMs, + getEventWithId, + getFirstEvent, + getNextEvent, + MILLIS_PER_HOUR, + millisToString, + removeSeconds, +} from 'ontime-utils'; + +import { clamp } from '../../../common/utils/math'; +import { formatDuration } from '../../../common/utils/time'; +import { isStringBoolean } from '../common/viewUtils'; + +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; +} + +/** + * 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); +} + +export function getScopedRundown(rundown: OntimeEvent[], selectedEventId: MaybeString): OntimeEvent[] { + if (rundown.length === 0) { + return []; + } + + const params = new URL(document.location.href).searchParams; + const hideBackstage = isStringBoolean(params.get('hideBackstage')); + const hidePast = isStringBoolean(params.get('hidePast')); + + let scopedRundown = [...rundown]; + + if (hidePast && selectedEventId) { + const currentIndex = rundown.findIndex((event) => event.id === selectedEventId); + if (currentIndex >= 0) { + scopedRundown = scopedRundown.slice(currentIndex); + } + } + + if (hideBackstage) { + scopedRundown = scopedRundown.filter((event) => event.isPublic); + } + + return scopedRundown; +} + +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)}`; +} diff --git a/apps/client/src/theme/_ontimeColours.scss b/apps/client/src/theme/_ontimeColours.scss index 99c4ef771..dc53254b1 100644 --- a/apps/client/src/theme/_ontimeColours.scss +++ b/apps/client/src/theme/_ontimeColours.scss @@ -7,6 +7,7 @@ $white-9: rgba(255, 255, 255, 0.09); $white-10: rgba(255, 255, 255, 0.10); $white-13: rgba(255, 255, 255, 0.13); $white-20: rgba(255, 255, 255, 0.20); +$white-40: rgba(255, 255, 255, 0.40); $white-60: rgba(255, 255, 255, 0.60); $white-90: rgba(255, 255, 255, 0.90); diff --git a/apps/client/src/theme/_ontimeStyles.scss b/apps/client/src/theme/_ontimeStyles.scss index 46de7d8e4..c5227566f 100644 --- a/apps/client/src/theme/_ontimeStyles.scss +++ b/apps/client/src/theme/_ontimeStyles.scss @@ -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; diff --git a/apps/client/src/translation/languages/de.ts b/apps/client/src/translation/languages/de.ts index 25470bf48..e75c293aa 100644 --- a/apps/client/src/translation/languages/de.ts +++ b/apps/client/src/translation/languages/de.ts @@ -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', }; diff --git a/apps/client/src/translation/languages/en.ts b/apps/client/src/translation/languages/en.ts index 429563357..c0889b05b 100644 --- a/apps/client/src/translation/languages/en.ts +++ b/apps/client/src/translation/languages/en.ts @@ -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; diff --git a/apps/client/src/translation/languages/es.ts b/apps/client/src/translation/languages/es.ts index 99c0d9d4c..e63a30c4b 100644 --- a/apps/client/src/translation/languages/es.ts +++ b/apps/client/src/translation/languages/es.ts @@ -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', }; diff --git a/apps/client/src/translation/languages/fr.ts b/apps/client/src/translation/languages/fr.ts index 09ed6d1c2..5ed6eab3f 100644 --- a/apps/client/src/translation/languages/fr.ts +++ b/apps/client/src/translation/languages/fr.ts @@ -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', }; diff --git a/apps/client/src/translation/languages/it.ts b/apps/client/src/translation/languages/it.ts index 52b3d85bc..830425c4b 100644 --- a/apps/client/src/translation/languages/it.ts +++ b/apps/client/src/translation/languages/it.ts @@ -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', }; diff --git a/apps/client/src/translation/languages/no.ts b/apps/client/src/translation/languages/no.ts index 118b99a9c..075aa142e 100644 --- a/apps/client/src/translation/languages/no.ts +++ b/apps/client/src/translation/languages/no.ts @@ -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', }; diff --git a/apps/client/src/translation/languages/pl.ts b/apps/client/src/translation/languages/pl.ts index a90fc9e3d..a974c1827 100644 --- a/apps/client/src/translation/languages/pl.ts +++ b/apps/client/src/translation/languages/pl.ts @@ -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', }; diff --git a/apps/client/src/translation/languages/pt.ts b/apps/client/src/translation/languages/pt.ts index c4a5b8eb8..fdae87c28 100644 --- a/apps/client/src/translation/languages/pt.ts +++ b/apps/client/src/translation/languages/pt.ts @@ -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', }; diff --git a/apps/client/src/translation/languages/sv.ts b/apps/client/src/translation/languages/sv.ts index c75119187..8fc9c9fb7 100644 --- a/apps/client/src/translation/languages/sv.ts +++ b/apps/client/src/translation/languages/sv.ts @@ -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', }; diff --git a/apps/client/src/viewerConfig.ts b/apps/client/src/viewerConfig.ts index c7aa393ac..a17d9ba62 100644 --- a/apps/client/src/viewerConfig.ts +++ b/apps/client/src/viewerConfig.ts @@ -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' }, diff --git a/apps/electron/src/menu/applicationMenu.js b/apps/electron/src/menu/applicationMenu.js index 1e8aefbdb..cc75c40d5 100644 --- a/apps/electron/src/menu/applicationMenu.js +++ b/apps/electron/src/menu/applicationMenu.js @@ -59,6 +59,19 @@ function getApplicationMenu(isMac, askToQuit, urlBase, version, redirectWindow) { label: 'Ontime Views (opens in browser)', submenu: [ + { + label: 'Public', + click: async () => { + await shell.openExternal(`${urlBase}/public`); + }, + }, + { + label: 'Lower Thirds', + click: async () => { + await shell.openExternal(`${urlBase}/lower`); + }, + }, + { type: 'separator' }, { label: 'Timer', accelerator: 'CmdOrCtrl+V', @@ -85,15 +98,9 @@ function getApplicationMenu(isMac, askToQuit, urlBase, version, redirectWindow) }, }, { - label: 'Public', + label: 'Timeline', click: async () => { - await shell.openExternal(`${urlBase}/public`); - }, - }, - { - label: 'Lower Thirds', - click: async () => { - await shell.openExternal(`${urlBase}/lower`); + await shell.openExternal(`${urlBase}/timeline`); }, }, { diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 4922c5434..6a294595d 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -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, filterPlayable, filterTimedEvents, getFirst, diff --git a/packages/utils/src/rundown-utils/rundownUtils.ts b/packages/utils/src/rundown-utils/rundownUtils.ts index a9b85a399..1415e2e6b 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.ts @@ -327,6 +327,10 @@ 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); +} + /** * Gets relevant block element for a given ID * @param rundown