diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index 0000a7650..e4cab2010 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -170,6 +170,12 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({ offset: state.runtime.offset, })); +export const useTimeUntilData = createSelector((state: RuntimeStore) => ({ + clock: state.clock, + offset: state.runtime.offset, + currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event +})); + export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({ offset: state.runtime.offset, })); diff --git a/apps/client/src/common/utils/__tests__/time.test.ts b/apps/client/src/common/utils/__tests__/time.test.ts index b79ce13b9..beb195e5a 100644 --- a/apps/client/src/common/utils/__tests__/time.test.ts +++ b/apps/client/src/common/utils/__tests__/time.test.ts @@ -1,4 +1,4 @@ -import { formatTime, nowInMillis } from '../time'; +import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time'; describe('nowInMillis()', () => { it('should return the current time in milliseconds', () => { @@ -38,3 +38,84 @@ describe('formatTime()', () => { expect(time).toStrictEqual('-01:00'); }); }); + +describe('calculateTimeUntilStart()', () => { + test('ontime', () => { + const test = { + timeStart: 100, + dayOffset: 0, + delay: 0, + currentDay: 0, + totalGap: 0, + clock: 90, + offset: 0, + }; + + expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10); + expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10); + }); + + test('running behind', () => { + const test = { + timeStart: 100, + dayOffset: 0, + delay: 0, + currentDay: 0, + totalGap: 0, + clock: 90, + offset: -20, + }; + + expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30); + expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30); + }); + + test('running ahead', () => { + const test = { + timeStart: 100, + dayOffset: 0, + delay: 0, + currentDay: 0, + totalGap: 0, + clock: 80, + offset: 10, + }; + + expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put + expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10); + }); + + test('running behind with enough gaps', () => { + const test = { + timeStart: 100, + dayOffset: 0, + delay: 0, + currentDay: 0, + totalGap: 20, + clock: 50, + offset: -20, + }; + + expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50); + expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible + }); + + test('running behind with to little gaps', () => { + const test = { + timeStart: 100, + dayOffset: 0, + delay: 0, + currentDay: 0, + totalGap: 10, + clock: 50, + offset: -20, + }; + + expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60); + expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible + }); + + //TODO: more indepth testing, + // including day offset handling + // and more? +}); diff --git a/apps/client/src/common/utils/time.ts b/apps/client/src/common/utils/time.ts index de4283e1c..4c5ee9ed7 100644 --- a/apps/client/src/common/utils/time.ts +++ b/apps/client/src/common/utils/time.ts @@ -1,8 +1,9 @@ -import { MaybeNumber, Settings, TimeFormat } from 'ontime-types'; -import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils'; +import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types'; +import { dayInMs, 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'; +import { useTimeUntilData } from '../hooks/useSocket'; import { ontimeQueryClient } from '../queryClient'; /** @@ -125,3 +126,71 @@ export function formatDuration(duration: number, hideSeconds = true): string { } return result; } + +/** + * + * @param totalGap accumulated gap from the current event + * @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event + * @returns + */ +export function useTimeUntilStart( + // typed like this to make it very clear what the data is + data: Pick & { + totalGap: number; + isLinkedToLoaded: boolean; + }, +): number { + const { offset, clock, currentDay } = useTimeUntilData(); + return calculateTimeUntilStart({ ...data, currentDay, clock, offset }); +} + +/** + * + * @param currentDay the day offset of the urrently running event + * @param totalGap accumulated gap from the current event + * @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event + * @param clock + * @param offset + * @returns + */ +export function calculateTimeUntilStart( + data: Pick & { + currentDay: number; + totalGap: number; + isLinkedToLoaded: boolean; + clock: number; + offset: number; + }, +): number { + const { timeStart, dayOffset, currentDay, totalGap, isLinkedToLoaded, clock, offset, delay } = data; + + //How many days from the currently running event to this one + const relativeDayOffset = dayOffset - currentDay; + + const delayedStart = Math.max(0, timeStart + delay); + + //The normalised start time of this event relative to the currently running event + const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs; + + const offsetTimestart = normalisedTimeStart - offset; + const offsetTimeUntil = offsetTimestart - clock; + + if (isLinkedToLoaded) { + //if we are directly linked back to the loaded event we just follow the offset + return offsetTimeUntil; + } + + const scheduledTimeUntil = normalisedTimeStart - clock; + + const isAheadOfSchedule = offset >= 0; + const gapsCanCompensadeForOffset = totalGap + offset >= 0; + + if (isAheadOfSchedule || gapsCanCompensadeForOffset) { + // if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time + return scheduledTimeUntil; + } + + // otherwise consume as much of the offset as possible with the gap + const offsetTimeUntilBufferedByGaps = offsetTimeUntil - totalGap; + return offsetTimeUntilBufferedByGaps; +} diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 6422a343f..0b28e3061 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -7,7 +7,6 @@ import { isOntimeEvent, isPlayableEvent, MaybeString, - OntimeEvent, PlayableEvent, Playback, RundownCached, @@ -272,7 +271,7 @@ export default function Rundown({ data }: RundownProps) { let isNextDay = false; let totalGap = 0; const isEditMode = appMode === AppMode.Edit; - let currentDay = 0; + let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event return (
@@ -299,7 +298,10 @@ export default function Rundown({ data }: RundownProps) { if (isPlayableEvent(entry)) { isNextDay = checkIsNextDay(entry, lastEvent); - totalGap += !isPast ? entry.gap : 0; + if (!isPast) { + totalGap += entry.gap; + isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null; + } if (isNewLatest(entry, lastEvent)) { // populate previous entry thisEvent = entry; @@ -313,7 +315,6 @@ export default function Rundown({ data }: RundownProps) { const hasCursor = entry.id === cursor; if (isLoaded) { isPast = false; - currentDay = (entry as OntimeEvent).dayOffset; } return ( @@ -336,7 +337,7 @@ export default function Rundown({ data }: RundownProps) { isRolling={featureData.playback === Playback.Roll} isNextDay={isNextDay} totalGap={totalGap} - currentDay={currentDay} + isLinkedToLoaded={isLinkedToLoaded} />
diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 8e4ebf96c..6deb859dd 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -39,7 +39,7 @@ interface RundownEntryProps { playback?: Playback; // we only care about this if this event is playing isRolling: boolean; // we need to know even if not related to this event totalGap: number; - currentDay: number; + isLinkedToLoaded: boolean; } export default function RundownEntry(props: RundownEntryProps) { @@ -56,7 +56,7 @@ export default function RundownEntry(props: RundownEntryProps) { eventIndex, isNextDay, totalGap, - currentDay, + isLinkedToLoaded, } = props; const { emitError } = useEmitLog(); const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction(); @@ -178,8 +178,9 @@ export default function RundownEntry(props: RundownEntryProps) { isRolling={isRolling} gap={data.gap} isNextDay={isNextDay} - dayOffset={data.dayOffset - currentDay} + dayOffset={data.dayOffset} totalGap={totalGap} + isLinkedToLoaded={isLinkedToLoaded} actionHandler={actionHandler} /> ); diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index 1fe5d959f..936211a9c 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -51,6 +51,7 @@ interface EventBlockProps { isNextDay: boolean; dayOffset: number; totalGap: number; + isLinkedToLoaded: boolean; actionHandler: ( action: EventItemActions, payload?: @@ -91,6 +92,7 @@ export default function EventBlock(props: EventBlockProps) { isNextDay, dayOffset, totalGap, + isLinkedToLoaded, actionHandler, } = props; const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping(); @@ -310,6 +312,7 @@ export default function EventBlock(props: EventBlockProps) { dayOffset={dayOffset} isPast={isPast} totalGap={totalGap} + isLinkedToLoaded={isLinkedToLoaded} /> )} diff --git a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx index b2e549edc..2b1101f11 100644 --- a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx @@ -11,7 +11,6 @@ import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward' import { IoStop } from '@react-icons/all-files/io5/IoStop'; import { IoTime } from '@react-icons/all-files/io5/IoTime'; import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types'; -import { dayInMs } from 'ontime-utils'; import { cx } from '../../../common/utils/styleUtils'; import { tooltipDelayMid } from '../../../ontimeConfig'; @@ -47,6 +46,7 @@ interface EventBlockInnerProps { dayOffset: number; isPast: boolean; totalGap: number; + isLinkedToLoaded: boolean; } function EventBlockInner(props: EventBlockInnerProps) { @@ -72,6 +72,7 @@ function EventBlockInner(props: EventBlockInnerProps) { dayOffset, isPast, totalGap, + isLinkedToLoaded, } = props; const [renderInner, setRenderInner] = useState(false); @@ -120,11 +121,13 @@ function EventBlockInner(props: EventBlockInnerProps) { )} diff --git a/apps/client/src/features/rundown/event-block/composite/EventBlockChip.tsx b/apps/client/src/features/rundown/event-block/composite/EventBlockChip.tsx index 0a8003a71..58d5288ae 100644 --- a/apps/client/src/features/rundown/event-block/composite/EventBlockChip.tsx +++ b/apps/client/src/features/rundown/event-block/composite/EventBlockChip.tsx @@ -3,27 +3,29 @@ import { Tooltip } from '@chakra-ui/react'; import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle'; import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils'; -import { usePlayback, useTimelineStatus } from '../../../../common/hooks/useSocket'; +import { usePlayback } from '../../../../common/hooks/useSocket'; import useReport from '../../../../common/hooks-query/useReport'; import { cx } from '../../../../common/utils/styleUtils'; -import { formatDuration, formatTime } from '../../../../common/utils/time'; +import { formatDuration, formatTime, useTimeUntilStart } from '../../../../common/utils/time'; import { tooltipDelayFast } from '../../../../ontimeConfig'; import style from './EventBlockChip.module.scss'; interface EventBlockChipProps { id: string; - trueTimeStart: number; + timeStart: number; + delay: number; + dayOffset: number; isPast: boolean; isLoaded: boolean; className: string; totalGap: number; - isLinkedAndNext: boolean; duration: number; + isLinkedToLoaded: boolean; } export default function EventBlockChip(props: EventBlockChipProps) { - const { trueTimeStart, isPast, isLoaded, className, totalGap, isLinkedAndNext, id, duration } = props; + const { timeStart, delay, dayOffset, isPast, isLoaded, className, totalGap, id, duration, isLinkedToLoaded } = props; const { playback } = usePlayback(); if (isLoaded) { @@ -41,7 +43,13 @@ export default function EventBlockChip(props: EventBlockChipProps) { return (
- +
); @@ -51,18 +59,17 @@ export default function EventBlockChip(props: EventBlockChipProps) { } interface EventUntilProps { - trueTimeStart: number; + timeStart: number; + delay: number; + dayOffset: number; totalGap: number; - isLinkedAndNext: boolean; + isLinkedToLoaded: boolean; } function EventUntil(props: EventUntilProps) { - const { trueTimeStart, totalGap, isLinkedAndNext } = props; - const { clock, offset } = useTimelineStatus(); + const { timeStart, delay, dayOffset, totalGap, isLinkedToLoaded } = props; - const consumedOffset = isLinkedAndNext ? offset : Math.min(offset + totalGap, 0); - const offsetTimestart = trueTimeStart - consumedOffset; - const timeUntil = offsetTimestart - clock; + const timeUntil = useTimeUntilStart({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }); const isDue = timeUntil < MILLIS_PER_SECOND; const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;