diff --git a/apps/client/src/common/components/delay-indicator/DelayIndicator.tsx b/apps/client/src/common/components/delay-indicator/DelayIndicator.tsx index f1a4df211..14b01a55d 100644 --- a/apps/client/src/common/components/delay-indicator/DelayIndicator.tsx +++ b/apps/client/src/common/components/delay-indicator/DelayIndicator.tsx @@ -17,7 +17,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) { if (typeof delayValue === 'number') { if (delayValue < 0) { return ( - + @@ -27,7 +27,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) { if (delayValue > 0) { return ( - + diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index ef0155cdb..6e2fe8af1 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -165,10 +165,23 @@ export const setClientName = (newName: string) => socketSendJson('set-client-nam export const useRuntimeOverview = () => { const featureSelector = (state: RuntimeStore) => ({ - playback: state.timer.playback, - clock: state.clock, - selectedEventIndex: state.runtime.selectedEventIndex, - numEvents: state.runtime.numEvents, + plannedStart: state.runtime.plannedStart, + actualStart: state.runtime.actualStart, + plannedEnd: state.runtime.plannedEnd, + expectedEnd: state.runtime.expectedEnd, + }); + + return useRuntimeStore(featureSelector); +}; + +export const useRuntimePlaybackOverview = () => { + const featureSelector = (state: RuntimeStore) => ({ + playback: state.timer.playback, + clock: state.clock, + + numEvents: state.runtime.numEvents, + selectedEventIndex: state.runtime.selectedEventIndex, + offset: state.runtime.offset, }); return useRuntimeStore(featureSelector); diff --git a/apps/client/src/common/stores/runtime.ts b/apps/client/src/common/stores/runtime.ts index e450bc0f8..a9e1cf9e8 100644 --- a/apps/client/src/common/stores/runtime.ts +++ b/apps/client/src/common/stores/runtime.ts @@ -37,8 +37,13 @@ export const runtimeStorePlaceholder: RuntimeStore = { }, }, runtime: { - numEvents: 0, selectedEventIndex: null, + numEvents: 0, + offset: 0, + plannedStart: 0, + plannedEnd: 0, + actualStart: null, + expectedEnd: null, }, eventNow: null, eventNext: null, diff --git a/apps/client/src/common/utils/__tests__/dateConfig.test.js b/apps/client/src/common/utils/__tests__/dateConfig.test.js index a378b4f2f..2a26fec96 100644 --- a/apps/client/src/common/utils/__tests__/dateConfig.test.js +++ b/apps/client/src/common/utils/__tests__/dateConfig.test.js @@ -260,17 +260,17 @@ describe('test forgivingStringToMillis()', () => { describe('millisToDelayString()', () => { it('returns null for null values', () => { - expect(millisToDelayString(null)).toBeNull(); + expect(millisToDelayString(null)).toBe(''); }); it('returns null 0', () => { - expect(millisToDelayString(0)).toBeNull(); + expect(millisToDelayString(0)).toBe(''); }); describe('converts values in seconds', () => { it('shows a simple string with value in seconds', () => { - expect(millisToDelayString(10000, true)).toBe('+10 sec'); + expect(millisToDelayString(10000)).toBe('+10 sec'); }); it('... and its negative counterpart', () => { - expect(millisToDelayString(-10000, true)).toBe('-10 sec'); + expect(millisToDelayString(-10000)).toBe('-10 sec'); }); const underAMinute = [1, 500, 1000, 6000, 55000, 59999]; @@ -279,37 +279,36 @@ describe('millisToDelayString()', () => { expect(millisToDelayString(value)?.endsWith('sec')).toBe(true); }); }); - expect(millisToDelayString(null)).toBeNull(); }); describe('converts values in minutes', () => { it('shows a simple string with value in minutes', () => { - expect(millisToDelayString(720000, true)).toBe('+12 min'); + expect(millisToDelayString(720000)).toBe('+12 min'); }); it('... and its negative counterpart', () => { - expect(millisToDelayString(-720000, true)).toBe('-12 min'); + expect(millisToDelayString(-720000)).toBe('-12 min'); }); it('shows a simple string with value in minutes and seconds', () => { - expect(millisToDelayString(630000, true)).toBe('+00:10:30'); + expect(millisToDelayString(630000)).toBe('+00:10:30'); }); it('... and its negative counterpart', () => { - expect(millisToDelayString(-630000, true)).toBe('-00:10:30'); + expect(millisToDelayString(-630000)).toBe('-00:10:30'); }); const underAnHour = [60000, 360000, 720000]; underAnHour.forEach((value) => { it(`handles ${value}`, () => { - expect(millisToDelayString(value, true)?.endsWith('min')).toBe(true); + expect(millisToDelayString(value)?.endsWith('min')).toBe(true); }); }); }); describe('converts values with full time string', () => { it('positive added time', () => { - expect(millisToDelayString(45015000, true)).toBe('+12:30:15'); + expect(millisToDelayString(45015000)).toBe('+12:30:15'); }); it('negative added time', () => { - expect(millisToDelayString(-45015000, true)).toBe('-12:30:15'); + expect(millisToDelayString(-45015000)).toBe('-12:30:15'); }); }); }); diff --git a/apps/client/src/common/utils/__tests__/time.test.ts b/apps/client/src/common/utils/__tests__/time.test.ts index 12bd974df..b79ce13b9 100644 --- a/apps/client/src/common/utils/__tests__/time.test.ts +++ b/apps/client/src/common/utils/__tests__/time.test.ts @@ -16,13 +16,13 @@ describe('nowInMillis()', () => { describe('formatTime()', () => { it('parses 24h strings', () => { const ms = 13 * 60 * 60 * 1000; - const time = formatTime(ms, {format12: "hh:mm:ss", format24: "HH:mm:ss" }, (_format12, format24) => format24); + const time = formatTime(ms, { format12: 'hh:mm:ss', format24: 'HH:mm:ss' }, (_format12, format24) => format24); expect(time).toStrictEqual('13:00:00'); }); it('parses same string in 12h strings', () => { const ms = 13 * 60 * 60 * 1000; - const time = formatTime(ms, {format12: "hh:mm:ss a", format24: "HH:mm:ss" }, (format12, _format24) => format12); + const time = formatTime(ms, { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' }, (format12, _format24) => format12); expect(time).toStrictEqual('01:00:00 PM'); }); @@ -34,7 +34,7 @@ describe('formatTime()', () => { it('handles negative times', () => { const ms = 1 * 60 * 60 * 1000; - const time = formatTime(-ms, {format12: "hh:mm a", format24: "HH:mm" }, (_format12, format24) => format24); + const time = formatTime(-ms, { format12: 'hh:mm a', format24: 'HH:mm' }, (_format12, format24) => format24); expect(time).toStrictEqual('-01:00'); }); }); diff --git a/apps/client/src/common/utils/dateConfig.ts b/apps/client/src/common/utils/dateConfig.ts index bf4cfd58d..7f16a2e1c 100644 --- a/apps/client/src/common/utils/dateConfig.ts +++ b/apps/client/src/common/utils/dateConfig.ts @@ -1,3 +1,4 @@ +import { MaybeNumber } from 'ontime-types'; import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils'; /** @@ -55,7 +56,9 @@ function checkMatchers(value: string) { const secondsMatchValue = secondsMatch ? parse(secondsMatch[1]) : 0; if (hoursMatchValue > 0 || minutesMatchValue > 0 || secondsMatchValue > 0) { - return hoursMatchValue * MILLIS_PER_HOUR + minutesMatchValue * MILLIS_PER_MINUTE + secondsMatchValue * MILLIS_PER_SECOND; + return ( + hoursMatchValue * MILLIS_PER_HOUR + minutesMatchValue * MILLIS_PER_MINUTE + secondsMatchValue * MILLIS_PER_SECOND + ); } return { hoursMatchValue }; } @@ -155,21 +158,22 @@ export const forgivingStringToMillis = (value: string): number => { return millis; }; -export function millisToDelayString(millis: number | null, small = false): undefined | string | null { +export function millisToDelayString(millis: MaybeNumber, format: 'compact' | 'expanded' = 'compact'): string { if (millis == null || millis === 0) { - return null; + return ''; } const isNegative = millis < 0; const absMillis = Math.abs(millis); - const delayed = small ? '+' : 'delayed by '; - const ahead = small ? '-' : 'ahead by '; + const isCompact = format === 'compact'; + const delayed = isCompact ? '+' : 'delayed by '; + const ahead = isCompact ? '-' : 'ahead by '; if (absMillis < MILLIS_PER_MINUTE) { return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 's')} sec`; } else if (absMillis < MILLIS_PER_HOUR && absMillis % MILLIS_PER_MINUTE === 0) { return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'm')} min`; - } else { - return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'HH:mm:ss')}`; } + + return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'HH:mm:ss')}`; } diff --git a/apps/client/src/common/utils/styleUtils.ts b/apps/client/src/common/utils/styleUtils.ts index a7bbbd7a2..b4fd14bd2 100644 --- a/apps/client/src/common/utils/styleUtils.ts +++ b/apps/client/src/common/utils/styleUtils.ts @@ -29,3 +29,7 @@ export const getAccessibleColour = (bgColour?: string): ColourCombination => { * @param classNames - css modules objects */ export const cx = (classNames: any[]) => classNames.filter(Boolean).join(' '); + +export const enDash = '–'; + +export const timerPlaceholder = '––:––:––'; diff --git a/apps/client/src/features/control/message/MessageControl.tsx b/apps/client/src/features/control/message/MessageControl.tsx index 4d36b0101..6d70c47d2 100644 --- a/apps/client/src/features/control/message/MessageControl.tsx +++ b/apps/client/src/features/control/message/MessageControl.tsx @@ -5,6 +5,7 @@ import { IoSunny } from '@react-icons/all-files/io5/IoSunny'; import { IoSunnyOutline } from '@react-icons/all-files/io5/IoSunnyOutline'; import { setMessage, useMessageControl } from '../../../common/hooks/useSocket'; +import { enDash } from '../../../common/utils/styleUtils'; import InputRow from './InputRow'; @@ -64,7 +65,7 @@ export default function MessageControl() { -- : -- : --; + return
{timerPlaceholder}
; } const isNegative = time < 0; - const display = millisToString(Math.abs(time), { fallback: '-- : -- : --' }); + const display = millisToString(Math.abs(time), { fallback: timerPlaceholder }); const classes = cx([style.timer, isNegative ? style.finished : null]); return
{display}
; diff --git a/apps/client/src/features/cuesheet/Cuesheet.module.scss b/apps/client/src/features/cuesheet/Cuesheet.module.scss index 3c52b9a22..3bca1d7d5 100644 --- a/apps/client/src/features/cuesheet/Cuesheet.module.scss +++ b/apps/client/src/features/cuesheet/Cuesheet.module.scss @@ -1,12 +1,6 @@ $table-font-size: calc(1rem - 2px); $table-header-font-size: calc(1rem - 3px); -@mixin ellipsis-overflow() { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - .cuesheetContainer { grid-area: table; display: flex; diff --git a/apps/client/src/features/cuesheet/cuesheet-table-elements/DelayRow.tsx b/apps/client/src/features/cuesheet/cuesheet-table-elements/DelayRow.tsx index 1afbd3d26..0149cc991 100644 --- a/apps/client/src/features/cuesheet/cuesheet-table-elements/DelayRow.tsx +++ b/apps/client/src/features/cuesheet/cuesheet-table-elements/DelayRow.tsx @@ -10,7 +10,7 @@ interface DelayRowProps { function DelayRow(props: DelayRowProps) { const { duration } = props; - const delayTime = millisToDelayString(duration); + const delayTime = millisToDelayString(duration, 'expanded'); return ( diff --git a/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx b/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx index cc6970420..73e0da24a 100644 --- a/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx +++ b/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx @@ -8,7 +8,7 @@ import { Playback, ProjectData } from 'ontime-types'; import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon'; import useFullscreen from '../../../common/hooks/useFullscreen'; import useProjectData from '../../../common/hooks-query/useProjectData'; -import { cx } from '../../../common/utils/styleUtils'; +import { cx, enDash } from '../../../common/utils/styleUtils'; import { tooltipDelayFast } from '../../../ontimeConfig'; import { useCuesheetSettings } from '../store/CuesheetSettings'; @@ -42,15 +42,15 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh const selected = !featureData.numEvents ? 'No events' - : `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : '-'}/${ - featureData.numEvents ? featureData.numEvents : '-' + : `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : enDash}/${ + featureData.numEvents ? featureData.numEvents : enDash }`; return (
-
{project?.title || '-'}
-
{featureData?.titleNow || '-'}
+
{project?.title || enDash}
+
{featureData?.titleNow || enDash}
{selected}
diff --git a/apps/client/src/features/modals/settings-modal/ModalPinInput.tsx b/apps/client/src/features/modals/settings-modal/ModalPinInput.tsx index c3152a246..3f1d49974 100644 --- a/apps/client/src/features/modals/settings-modal/ModalPinInput.tsx +++ b/apps/client/src/features/modals/settings-modal/ModalPinInput.tsx @@ -3,6 +3,8 @@ import { UseFormRegister } from 'react-hook-form'; import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react'; import { IoEyeOutline } from '@react-icons/all-files/io5/IoEyeOutline'; +import { enDash } from '../../../common/utils/styleUtils'; + interface FormInput { [key: string]: string; } @@ -21,7 +23,7 @@ export default function ModalPinInput({ register, formName, isDisabled }: ModalP type={isVisible ? 'text' : 'password'} maxLength={4} {...register(formName)} - placeholder='-' + placeholder={enDash} isDisabled={isDisabled} /> diff --git a/apps/client/src/features/overview/Overview.module.scss b/apps/client/src/features/overview/Overview.module.scss index a9d7c95f0..57cbd04c7 100644 --- a/apps/client/src/features/overview/Overview.module.scss +++ b/apps/client/src/features/overview/Overview.module.scss @@ -2,54 +2,26 @@ grid-area: overview; display: flex; align-items: center; - justify-content: start; + justify-content: space-between; font-size: $inner-section-text-size; - gap: 2rem; - padding-left: 1rem; - padding-right: 0.5rem; -} - -.titles { - flex: 1; + padding: 0 1rem; } .title { font-size: 1.5rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis-overflow; } .description { font-size: 1rem; color: $label-gray; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis-overflow; } -.inline { - display: flex; - align-items: center; - gap: 0.25rem; +.ahead { + color: $green-500; } -@mixin indicator($bg-color) { - &::before { - content: ''; - background-color: $bg-color; - display: inline-flex; - height: 0.75em; - width: 0.75em; - vertical-align: middle; - margin-right: 0.25rem; - } -} - -.start { - @include indicator($green-500); -} - -.end { - @include indicator($red-500); +.behind { + color: $ontime-delay-text; } diff --git a/apps/client/src/features/overview/Overview.tsx b/apps/client/src/features/overview/Overview.tsx index 56d7c7c5f..c9d14f4d0 100644 --- a/apps/client/src/features/overview/Overview.tsx +++ b/apps/client/src/features/overview/Overview.tsx @@ -1,34 +1,39 @@ -import { Tooltip } from '@chakra-ui/react'; +import { MaybeNumber } from 'ontime-types'; +import { millisToString } from 'ontime-utils'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; -import PlaybackIcon from '../../common/components/playback-icon/PlaybackIcon'; -import { useRuntimeOverview } from '../../common/hooks/useSocket'; +import { useRuntimeOverview, useRuntimePlaybackOverview } from '../../common/hooks/useSocket'; import useProjectData from '../../common/hooks-query/useProjectData'; -import { formatTime } from '../../common/utils/time'; +import { enDash, timerPlaceholder } from '../../common/utils/styleUtils'; -import styles from './Overview.module.scss'; +import { TimeColumn, TimeRow } from './composite/TimeLayout'; + +import style from './Overview.module.scss'; + +/** + * Encapsulates the logic for formatting time in overview + * @param time + * @returns + */ +function formattedTime(time: MaybeNumber) { + return millisToString(time, { fallback: timerPlaceholder }); +} export default function Overview() { + const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview(); + return ( -
+
-
- -
Planned start
-
- -
Actual start
-
+
+ +
-
- -
Planned end
-
- -
Expected end
-
+
+ +
@@ -39,31 +44,31 @@ function TitlesOverview() { const { data } = useProjectData(); return ( -
-
{data.title}
-
{data.description}
+
+
{data.title}
+
{data.description}
); } function RuntimeOverview() { - const { playback, clock, numEvents, selectedEventIndex } = useRuntimeOverview(); + const { clock, numEvents, selectedEventIndex, offset } = useRuntimePlaybackOverview(); - const current = selectedEventIndex !== null ? selectedEventIndex + 1 : '-'; - const ofTotal = numEvents || '-'; + const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash; + const ofTotal = numEvents || enDash; + const progressText = numEvents ? `${current} of ${ofTotal}` : ''; - const display = formatTime(clock); + const isAhead = offset <= 0; + let offsetText = millisToString(Math.abs(offset), { fallback: enDash }); + if (offsetText !== enDash) { + offsetText = isAhead ? `+${offsetText}` : `${enDash}${offsetText}`; + } return ( -
-
- - {display} -
-
- {`(${current} / ${ofTotal})`} - Over / Under -
-
+ <> + + + + ); } diff --git a/apps/client/src/features/overview/composite/TimeLayout.module.scss b/apps/client/src/features/overview/composite/TimeLayout.module.scss new file mode 100644 index 000000000..81c79d832 --- /dev/null +++ b/apps/client/src/features/overview/composite/TimeLayout.module.scss @@ -0,0 +1,40 @@ +.label { + color: $label-gray; + font-size: calc(1rem - 2px); + width: 10em; // a number large enough to force right alignment +} + +.clock { + text-align: left; + font-size: 1.5rem; + letter-spacing: 0.5px; + min-width: 5em; + + &::after { + content: '\200b'; + } +} + +.column { + display: flex; + flex-direction: column; + + .label { + line-height: 0.9em; + + } +} + +.row { + display: flex; + align-items: center; + gap: 0.5rem; + + .label { + text-align: right; + } + + .clock { + font-size: 1.25rem; + } +} diff --git a/apps/client/src/features/overview/composite/TimeLayout.tsx b/apps/client/src/features/overview/composite/TimeLayout.tsx new file mode 100644 index 000000000..a0e0bfff1 --- /dev/null +++ b/apps/client/src/features/overview/composite/TimeLayout.tsx @@ -0,0 +1,27 @@ +import { cx } from '../../../common/utils/styleUtils'; + +import style from './TimeLayout.module.scss'; + +interface TimeLayoutProps { + label: string; + value: string; + className?: string; +} + +export function TimeColumn({ label, value, className }: TimeLayoutProps) { + return ( +
+ {label} + {value} +
+ ); +} + +export function TimeRow({ label, value, className }: TimeLayoutProps) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx b/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx index 0c27d99d9..b05850c08 100644 --- a/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx +++ b/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx @@ -64,9 +64,9 @@ const EventEditorTimes = (props: EventEditorTimesProps) => { const hasDelay = delay !== 0; const delayLabel = hasDelay - ? `Event is ${millisToDelayString(delay)}. New schedule ${millisToString(timeStart + delay)} → ${millisToString( - timeEnd + delay, - )}` + ? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${millisToString( + timeStart + delay, + )} → ${millisToString(timeEnd + delay)}` : ''; return ( diff --git a/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx b/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx index 009f8ad7c..debd341e9 100644 --- a/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx +++ b/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx @@ -10,6 +10,7 @@ import ViewParamsEditor from '../../../common/components/view-params-editor/View import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { OverridableOptions } from '../../../common/models/View.types'; +import { timerPlaceholder } from '../../../common/utils/styleUtils'; import { isStringBoolean } from '../../../common/utils/viewUtils'; import { useTranslation } from '../../../translation/TranslationProvider'; import { getTimerByType } from '../common/viewerUtils'; @@ -153,7 +154,7 @@ export default function MinimalTimer(props: MinimalTimerProps) { : viewSettings.normalColor; const stageTimer = getTimerByType(time); - let display = millisToString(stageTimer, { fallback: '-- : -- : --' }); + let display = millisToString(stageTimer, { fallback: timerPlaceholder }); if (stageTimer !== null) { if (hideTimerSeconds) { display = removeSeconds(display); diff --git a/apps/client/src/features/viewers/timer/Timer.tsx b/apps/client/src/features/viewers/timer/Timer.tsx index 12c058944..e05e21097 100644 --- a/apps/client/src/features/viewers/timer/Timer.tsx +++ b/apps/client/src/features/viewers/timer/Timer.tsx @@ -12,6 +12,7 @@ import { getTimerOptions } from '../../../common/components/view-params-editor/c import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; +import { timerPlaceholder } from '../../../common/utils/styleUtils'; import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { isStringBoolean } from '../../../common/utils/viewUtils'; import { useTranslation } from '../../../translation/TranslationProvider'; @@ -115,7 +116,7 @@ export default function Timer(props: TimerProps) { : viewSettings.normalColor; const stageTimer = getTimerByType(time); - let display = millisToString(stageTimer, { fallback: '-- : -- : --' }); + let display = millisToString(stageTimer, { fallback: timerPlaceholder }); if (stageTimer !== null) { if (hideTimerSeconds) { display = removeSeconds(display); diff --git a/apps/client/src/theme/_ontimeStyles.scss b/apps/client/src/theme/_ontimeStyles.scss index 531d69d1a..71fc720c1 100644 --- a/apps/client/src/theme/_ontimeStyles.scss +++ b/apps/client/src/theme/_ontimeStyles.scss @@ -69,3 +69,9 @@ $min-tablet: 500px; opacity: 20%; } } + +@mixin ellipsis-overflow() { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index e29d81934..16e10809f 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -42,8 +42,8 @@ import { runtimeService } from './services/runtime-service/RuntimeService.js'; import { restoreService } from './services/RestoreService.js'; import { messageService } from './services/message-service/MessageService.js'; import { populateDemo } from './modules/loadDemo.js'; -import { getState, updateNumEvents } from './stores/runtimeState.js'; -import { getNumEvents, setRundown } from './services/rundown-service/RundownService.js'; +import { getState, updateRundownData } from './stores/runtimeState.js'; +import { setRundown, getPlayableEvents } from './services/rundown-service/RundownService.js'; console.log(`Starting Ontime version ${ONTIME_VERSION}`); @@ -184,8 +184,7 @@ export const startServer = async () => { setRundown(persistedRundown); // TODO: do this on the init of the runtime service - const numEvents = getNumEvents(); - updateNumEvents(numEvents); + updateRundownData(getPlayableEvents()); // load restore point if it exists const maybeRestorePoint = await restoreService.load(); diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index 866daeb5c..e62530979 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -37,19 +37,6 @@ export class DataProvider { await this.persist(); } - static getIndexOf(eventId: string) { - return data.rundown.findIndex((e) => e.id === eventId); - } - - static getRundownLength() { - return data.rundown.length; - } - - static async clearRundown() { - data.rundown = []; - await db.write(); - } - static getSettings() { return data.settings; } diff --git a/apps/server/src/services/RestoreService.ts b/apps/server/src/services/RestoreService.ts index 823330d67..d701f2827 100644 --- a/apps/server/src/services/RestoreService.ts +++ b/apps/server/src/services/RestoreService.ts @@ -9,6 +9,7 @@ export type RestorePoint = { startedAt: MaybeNumber; addedTime: number; pausedAt: MaybeNumber; + firstStart: MaybeNumber; }; /** @@ -43,6 +44,10 @@ export function isRestorePoint(obj: unknown): obj is RestorePoint { return false; } + if (typeof restorePoint.firstStart !== 'number' && restorePoint.pausedAt !== null) { + return false; + } + return true; } diff --git a/apps/server/src/services/TimerService.ts b/apps/server/src/services/TimerService.ts index 7b344e316..2be17f3dd 100644 --- a/apps/server/src/services/TimerService.ts +++ b/apps/server/src/services/TimerService.ts @@ -139,6 +139,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert startedAt: state.timer.startedAt, addedTime: state.timer.addedTime, pausedAt: state._timer.pausedAt, + firstStart: state.runtime.actualStart, }); return result; }; diff --git a/apps/server/src/services/__tests__/RestoreService.test.ts b/apps/server/src/services/__tests__/RestoreService.test.ts index e7866e978..9e7859437 100644 --- a/apps/server/src/services/__tests__/RestoreService.test.ts +++ b/apps/server/src/services/__tests__/RestoreService.test.ts @@ -13,6 +13,7 @@ describe('isRestorePoint()', () => { startedAt: 1, addedTime: 2, pausedAt: 3, + firstStart: 1, }; expect(isRestorePoint(restorePoint)).toBe(true); @@ -22,6 +23,7 @@ describe('isRestorePoint()', () => { startedAt: null, addedTime: 0, pausedAt: null, + firstStart: 1, }; expect(isRestorePoint(restorePoint)).toBe(true); }); @@ -68,6 +70,7 @@ describe('RestoreService()', () => { startedAt: 1234, addedTime: 5678, pausedAt: 9087, + firstStart: 1234, }; const restoreService = new RestoreService('/path/to/restore/file'); @@ -84,6 +87,7 @@ describe('RestoreService()', () => { startedAt: null, addedTime: 0, pausedAt: null, + firstStart: 1234, }; const restoreService = new RestoreService('/path/to/restore/file'); @@ -100,6 +104,7 @@ describe('RestoreService()', () => { startedAt: 1234, addedTime: 1234, pausedAt: 1234, + firstStart: 1234, }; const restoreService = new RestoreService('/path/to/restore/file'); @@ -118,6 +123,7 @@ describe('RestoreService()', () => { startedAt: 1234, addedTime: 1234, pausedAt: 1234, + firstStart: 1234, }; const restoreService = new RestoreService('/path/to/restore/file'); diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index ca159a9f5..f87cb895c 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -5,6 +5,7 @@ import { getCurrent, getExpectedFinish, getRollTimers, + getRuntimeOffset, normaliseEndTime, skippedOutOfEvent, updateRoll, @@ -1370,3 +1371,68 @@ describe('updateRoll()', () => { expect(updateRoll(timers)).toStrictEqual(expected); }); }); + +describe('getRuntimeOffset()', () => { + it('calculates the difference between schedule and actual start', () => { + const state = { + eventNow: { + id: '1', + timeStart: 100, + }, + timer: { + startedAt: 150, + addedTime: 10, + current: 0, + }, + _timer: { + pausedAt: null, + }, + } as RuntimeState; + + const offset = getRuntimeOffset(state); + expect(offset).toBe(60); + }); + + it('adds the overtime time of the current timer', () => { + const state = { + eventNow: { + id: '1', + timeStart: 100, + timeEnd: 140, + }, + timer: { + startedAt: 100, + current: -10, + addedTime: 0, + }, + _timer: { + pausedAt: null, + }, + } as RuntimeState; + + const offset = getRuntimeOffset(state); + expect(offset).toBe(10); + }); + + it('accounts for paused time', () => { + const state = { + eventNow: { + id: '1', + timeStart: 100, + timeEnd: 150, + }, + clock: 150, + timer: { + startedAt: 100, + current: 25, + addedTime: 0, + }, + _timer: { + pausedAt: 125, + }, + } as RuntimeState; + + const offset = getRuntimeOffset(state); + expect(offset).toBe(25); + }); +}); diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 6f0f4f7b4..9b8fb59bc 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -15,7 +15,7 @@ import { block as blockDef, delay as delayDef } from '../../models/eventsDefinit import { sendRefetch } from '../../adapters/websocketAux.js'; import { logger } from '../../classes/Logger.js'; import { createEvent } from '../../utils/parser.js'; -import { updateNumEvents } from '../../stores/runtimeState.js'; +import { updateRundownData } from '../../stores/runtimeState.js'; import { runtimeService } from '../runtime-service/RuntimeService.js'; import * as cache from './rundownCache.js'; @@ -159,8 +159,7 @@ export async function swapEvents(from: string, to: string) { * Called when we make changes to the rundown object */ function updateChangeNumEvents() { - const numEvents = getPlayableEvents().length; - updateNumEvents(numEvents); + updateRundownData(getPlayableEvents()); } /** @@ -286,6 +285,10 @@ export function findNext(currentEventId?: string): OntimeEvent | null { return nextEvent ?? null; } +/** + * Overrides the rundown with the given + * @param rundown + */ export async function setRundown(rundown: OntimeRundown) { cache.init(rundown); notifyChanges({ timer: true }); diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index a08bfe9c6..d8098a608 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -289,3 +289,23 @@ export const updateRoll = (state: RuntimeState) => { return { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished: isPrimaryFinished }; }; + +/** + * Calculates difference between the runtime and the schedule of an event + * @param state + * @returns + */ +export function getRuntimeOffset(state: RuntimeState): number { + if (state.eventNow === null) { + return 0; + } + + const { timeStart } = state.eventNow; + const { addedTime, current, startedAt } = state.timer; + + const overtime = Math.min(current, 0); + const startOffset = startedAt - timeStart; + const pausedTime = state._timer.pausedAt === null ? 0 : state.clock - state._timer.pausedAt; + + return startOffset + addedTime + pausedTime + Math.abs(overtime); +} diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index 384b314a3..651dc2001 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -1,9 +1,10 @@ import { OntimeEvent, Playback } from 'ontime-types'; import { deepmerge } from 'ontime-utils'; -import { RuntimeState, clear, getState, load, pause, start, stop } from '../runtimeState.js'; +import { RuntimeState, addTime, clear, getState, load, pause, start, stop } from '../runtimeState.js'; const mockEvent = { + type: 'event', id: 'mock', cue: 'mock', timeStart: 0, @@ -88,6 +89,7 @@ describe('mutation on runtimeState', () => { expect(newState.timer).toMatchObject({ playback: Playback.Play, }); + expect(newState.runtime.actualStart).toBe(newState.clock); // 3. Pause event success = pause(); @@ -122,7 +124,7 @@ describe('mutation on runtimeState', () => { ); expect(newState._timer.pausedAt).toBeNull(); - // 4. Stop event + // 5. Stop event success = stop(); expect(success).toBe(true); expect(newState.eventNow).toBe(null); @@ -133,8 +135,55 @@ describe('mutation on runtimeState', () => { expectedFinish: null, startedAt: null, }); + expect(newState.runtime.actualStart).toBeNull(); }); + test('runtime offset', () => { + const event1 = { ...mockEvent, id: 'event1', timeStart: 0, timeEnd: 1000, duration: 1000 }; + const event2 = { ...mockEvent, id: 'event2', timeStart: 1000, timeEnd: 1500, duration: 500 }; + + // 1. Load event + load(event1, [event1, event2]); + let newState = getState(); + expect(newState.runtime.actualStart).toBeNull(); + expect(newState.runtime.plannedStart).toBe(0); + expect(newState.runtime.plannedEnd).toBe(1500); + + // 2. Start event + start(); + newState = getState(); + const firstStart = newState.clock; + expect(newState.runtime.actualStart).toBe(newState.clock); + expect(newState.runtime.offset).toBe(newState.clock - event1.timeStart); + expect(newState.runtime.expectedEnd).toBe(newState.runtime.offset + event2.timeEnd); + + // 3. Next event + load(event2, [event1, event2]); + start(); + newState = getState(); + expect(newState.runtime.actualStart).toBe(firstStart); + // we are over-under, the difference between the schedule and the actual start + const delayBefore = newState.clock - event2.timeStart; + expect(newState.runtime.offset).toBe(delayBefore); + // finish is the difference between the runtime and the schedule + expect(newState.runtime.expectedEnd).toBe(event2.timeEnd + newState.runtime.offset); + + // 4. Add time + addTime(10); + newState = getState(); + expect(newState.runtime.offset).toBe(delayBefore + 10); + expect(newState.runtime.expectedEnd).toBe(event2.timeEnd + newState.runtime.offset); + + // 5. Stop event + stop(); + newState = getState(); + expect(newState.runtime.actualStart).toBeNull(); + expect(newState.runtime.offset).toBe(0); + expect(newState.runtime.expectedEnd).toBeNull(); + }); + + test.todo('runtime offset on timers in overtime', () => {}); + test.todo('roll mode', () => {}); }); }); diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index cf27cb3de..4cd93f9d5 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -1,15 +1,27 @@ import { Runtime, OntimeEvent, Playback, TimerState, TimerType, MaybeNumber } from 'ontime-types'; -import { calculateDuration, dayInMs } from 'ontime-utils'; +import { calculateDuration, dayInMs, getFirstEvent, getLastEvent } from 'ontime-utils'; import { clock } from '../services/Clock.js'; import { RestorePoint } from '../services/RestoreService.js'; import { getPlayableEvents } from '../services/rundown-service/RundownService.js'; -import { getCurrent, getExpectedFinish, getRollTimers, skippedOutOfEvent, updateRoll } from '../services/timerUtils.js'; +import { + getCurrent, + getExpectedFinish, + getRollTimers, + getRuntimeOffset, + skippedOutOfEvent, + updateRoll, +} from '../services/timerUtils.js'; import { timerConfig } from '../config/config.js'; const initialRuntime: Runtime = { selectedEventIndex: null, numEvents: 0, + offset: 0, + plannedStart: 0, + plannedEnd: 0, + actualStart: null, + expectedEnd: null, }; const initialTimer: TimerState = { @@ -64,13 +76,13 @@ export function getState(): Readonly { } export function clear() { - // TODO: check that entire state is reset here runtimeState.eventNow = null; runtimeState.publicEventNow = null; runtimeState.eventNext = null; runtimeState.publicEventNext = null; - runtimeState.runtime = { ...initialRuntime }; + runtimeState.runtime = { ...initialRuntime, actualStart: runtimeState.runtime.actualStart }; + // TODO: can we cleanup the initialisation of runtime state? runtimeState.runtime.numEvents = fetchNumEvents(); runtimeState.timer.playback = Playback.Stop; @@ -106,11 +118,17 @@ function fetchNumEvents(): number { } /** - * Utility, allows updating the number of events + * Utility, allows updating data derived from the rundown * @param numEvents */ -export function updateNumEvents(numEvents: number) { - runtimeState.runtime.numEvents = numEvents; +export function updateRundownData(playableRundown: OntimeEvent[]) { + runtimeState.runtime.numEvents = playableRundown.length; + + const { firstEvent } = getFirstEvent(playableRundown); + const { lastEvent } = getLastEvent(playableRundown); + + runtimeState.runtime.plannedStart = firstEvent?.timeStart ?? null; + runtimeState.runtime.plannedEnd = lastEvent?.timeEnd ?? null; } /** @@ -119,9 +137,11 @@ export function updateNumEvents(numEvents: number) { * @param rundown * @param initialData */ -export function load(event: OntimeEvent, rundown: OntimeEvent[], initialData?: Partial) { +export function load(event: OntimeEvent, rundown: OntimeEvent[], initialData?: Partial) { clear(); + updateRundownData(rundown); + const eventIndex = rundown.findIndex((eventInMemory) => eventInMemory.id === event.id); runtimeState.runtime.selectedEventIndex = eventIndex; @@ -137,6 +157,13 @@ export function load(event: OntimeEvent, rundown: OntimeEvent[], initialData?: P if (initialData) { patchTimer(initialData); + + const firstStart = initialData?.firstStart; + if (firstStart === null || typeof firstStart === 'number') { + runtimeState.runtime.actualStart = firstStart; + runtimeState.runtime.offset = getRuntimeOffset(runtimeState); + runtimeState.runtime.expectedEnd = runtimeState.runtime.plannedEnd + runtimeState.runtime.offset; + } } } @@ -256,6 +283,15 @@ export function start(state: RuntimeState = runtimeState): boolean { state.timer.playback = Playback.Play; state.timer.expectedFinish = getExpectedFinish(state); state.timer.elapsed = 0; + + // update runtime delays: over - under + if (state.runtime.actualStart === null) { + state.runtime.actualStart = state.clock; + } + + state.runtime.offset = getRuntimeOffset(state); + state.runtime.expectedEnd = state.runtime.plannedEnd + state.runtime.offset; + return true; } @@ -274,6 +310,7 @@ export function stop(state: RuntimeState = runtimeState): boolean { if (state.timer.playback === Playback.Stop) { return false; } + runtimeState.runtime.actualStart = null; clear(); return true; } @@ -298,6 +335,10 @@ export function addTime(amount: number) { runtimeState.timer.finishedAt = null; } } + + // update runtime delays: over - under + runtimeState.runtime.offset = getRuntimeOffset(runtimeState); + runtimeState.runtime.expectedEnd = runtimeState.runtime.plannedEnd + runtimeState.runtime.offset; return true; } @@ -318,6 +359,9 @@ export function update(force: boolean, updateInterval: number) { _force = true; } + // update offset + runtimeState.runtime.offset = getRuntimeOffset(runtimeState); + // we call integrations if we update timers if (runtimeState.timer.playback === Playback.Roll) { const result = roll(); diff --git a/packages/types/src/definitions/runtime/Runtime.type.ts b/packages/types/src/definitions/runtime/Runtime.type.ts index 996a0d96e..70606d622 100644 --- a/packages/types/src/definitions/runtime/Runtime.type.ts +++ b/packages/types/src/definitions/runtime/Runtime.type.ts @@ -3,4 +3,9 @@ import { MaybeNumber } from '../../utils/utils.type.js'; export type Runtime = { numEvents: number; selectedEventIndex: MaybeNumber; + offset: number; + plannedStart: MaybeNumber; + actualStart: MaybeNumber; + plannedEnd: MaybeNumber; + expectedEnd: MaybeNumber; }; diff --git a/packages/utils/src/rundown-utils/rundownUtils.test.ts b/packages/utils/src/rundown-utils/rundownUtils.test.ts index 63edb57ba..61d8de7e5 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.test.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.test.ts @@ -1,6 +1,6 @@ import { OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types'; -import { getNext, getNextEvent, getPrevious, getPreviousEvent, swapEventData } from './rundownUtils'; +import { getLastEvent, getNext, getNextEvent, getPrevious, getPreviousEvent, swapEventData } from './rundownUtils'; describe('getNext()', () => { it('returns the next event of type event', () => { @@ -189,3 +189,23 @@ describe('swapEventData', () => { }); }); }); + +describe('getLastEvent', () => { + it('returns the last event of type event', () => { + const testRundown = [ + { id: '1', type: SupportedEvent.Event }, + { id: '2', type: SupportedEvent.Delay }, + { id: '3', type: SupportedEvent.Event }, + { id: '4', type: SupportedEvent.Block }, + ]; + + const { lastEvent } = getLastEvent(testRundown as OntimeRundown); + expect(lastEvent?.id).toBe('3'); + }); + it('handles rundowns with a single event', () => { + const testRundown = [{ id: '1', type: SupportedEvent.Event }]; + + const { lastEvent } = getLastEvent(testRundown as OntimeRundown); + expect(lastEvent?.id).toBe('1'); + }); +}); diff --git a/packages/utils/src/rundown-utils/rundownUtils.ts b/packages/utils/src/rundown-utils/rundownUtils.ts index be2166e41..f2d7467c4 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.ts @@ -74,7 +74,7 @@ export function getLastEvent(rundown: OntimeRundown): { return { lastEvent: null, lastIndex: null }; } - for (let i = rundown.length - 1; i > 0; i--) { + for (let i = rundown.length - 1; i >= 0; i--) { const lastEvent = rundown.at(i); if (isOntimeEvent(lastEvent)) { return { lastEvent, lastIndex: i }; @@ -100,7 +100,7 @@ export function getLastEventNormal( return { lastEvent: null, lastIndex: null }; } - for (let i = order.length - 1; i > 0; i--) { + for (let i = order.length - 1; i >= 0; i--) { const lastId = order[i]; const lastEvent = rundown[lastId]; if (isOntimeEvent(lastEvent)) {