diff --git a/apps/client/src/common/hooks/useGroupTimer.ts b/apps/client/src/common/hooks/useGroupTimer.ts deleted file mode 100644 index c7789e896..000000000 --- a/apps/client/src/common/hooks/useGroupTimer.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { MaybeNumber } from 'ontime-types'; -import { useMemo } from 'react'; - -import useRundown from '../hooks-query/useRundown'; -import { getRemainingGroupTime } from '../utils/groupTimer'; -import { useGroupTimerData } from './useSocket'; - -export type GroupTimerState = { - /** whether views should display the group timer instead of the event timer */ - isActive: boolean; - /** time remaining in the group, mirrors the semantics of timer.current */ - current: MaybeNumber; - /** time already spent in the group, mirrors the semantics of timer.elapsed */ - elapsed: MaybeNumber; - /** scheduled duration of the group, used as the progress bar target */ - duration: MaybeNumber; -}; - -const inactiveGroupTimer: GroupTimerState = { isActive: false, current: null, elapsed: null, duration: null }; - -/** - * Derives a shared timer for the running group. - * - * The value is the running event timer plus the content still scheduled after it, - * which makes the group behave as if it were a single event containing all its children. - * Deriving it from the event timer (instead of from the clock) means pause, added time, - * overtime, roll and midnight rollovers are all inherited for free. - */ -export function useGroupTimer(): GroupTimerState { - const { group, currentEventId, current } = useGroupTimerData(); - const { data: rundown } = useRundown(); - - return useMemo(() => { - if (!group?.useGroupTimer || currentEventId === null || current === null) { - return inactiveGroupTimer; - } - - // the loaded event could be outside the group while the group data is still settling - if (!group.entries.includes(currentEventId)) { - return inactiveGroupTimer; - } - - const groupCurrent = current + getRemainingGroupTime(group, rundown.entries, currentEventId); - - return { - isActive: true, - current: groupCurrent, - elapsed: Math.max(0, group.duration - groupCurrent), - duration: group.duration, - }; - }, [group, currentEventId, current, rundown.entries]); -} diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index 015eb9559..55dc0079a 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -154,12 +154,8 @@ export const useTimer = createSelector((state: RuntimeStore) => ({ ...state.timer, })); -/** Runtime data needed to derive the shared group timer, see useGroupTimer */ -export const useGroupTimerData = createSelector((state: RuntimeStore) => ({ - group: state.groupNow, - currentEventId: state.eventNow?.id ?? null, - current: state.timer.current, -})); +/** Shared timer for the running group, null unless the group opted in */ +export const useGroupTimer = createSelector((state: RuntimeStore) => state.groupTimer); export const useNextFlag = createSelector((state: RuntimeStore) => ({ id: state.eventFlag?.id ?? null, diff --git a/apps/client/src/common/utils/__tests__/groupTimer.test.ts b/apps/client/src/common/utils/__tests__/groupTimer.test.ts deleted file mode 100644 index fd8e7f3ae..000000000 --- a/apps/client/src/common/utils/__tests__/groupTimer.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { EntryId, OntimeEntry, OntimeEvent, OntimeGroup } from 'ontime-types'; -import { SupportedEntry } from 'ontime-types'; - -import { getRemainingGroupTime } from '../groupTimer'; - -function makeEvent(id: EntryId, patch: Partial = {}): OntimeEvent { - return { - id, - type: SupportedEntry.Event, - duration: 0, - gap: 0, - skip: false, - parent: 'group', - ...patch, - } as OntimeEvent; -} - -function makeGroup(entries: EntryId[]): OntimeGroup { - return { id: 'group', type: SupportedEntry.Group, entries } as OntimeGroup; -} - -function makeEntries(...entries: OntimeEntry[]): Record { - return Object.fromEntries(entries.map((entry) => [entry.id, entry])); -} - -describe('getRemainingGroupTime()', () => { - it('sums the duration of the events after the current one', () => { - const group = makeGroup(['1', '2', '3']); - const entries = makeEntries( - makeEvent('1', { duration: 10 }), - makeEvent('2', { duration: 20 }), - makeEvent('3', { duration: 30 }), - ); - - expect(getRemainingGroupTime(group, entries, '1')).toBe(50); - expect(getRemainingGroupTime(group, entries, '2')).toBe(30); - }); - - it('accounts for the gaps between events', () => { - const group = makeGroup(['1', '2', '3']); - const entries = makeEntries( - makeEvent('1', { duration: 10 }), - makeEvent('2', { duration: 20, gap: 5 }), - makeEvent('3', { duration: 30, gap: 7 }), - ); - - expect(getRemainingGroupTime(group, entries, '1')).toBe(20 + 5 + 30 + 7); - }); - - it('adds up to the group duration when the first event is loaded', () => { - // mirrors the aggregation the server uses to calculate group.duration - const group = makeGroup(['1', '2', '3']); - const first = makeEvent('1', { duration: 10 }); - const entries = makeEntries(first, makeEvent('2', { duration: 20, gap: 5 }), makeEvent('3', { duration: 30 })); - - const groupDuration = 10 + 20 + 5 + 30; - expect(first.duration + getRemainingGroupTime(group, entries, '1')).toBe(groupDuration); - }); - - it('returns 0 on the last event of the group', () => { - const group = makeGroup(['1', '2']); - const entries = makeEntries(makeEvent('1', { duration: 10 }), makeEvent('2', { duration: 20 })); - - expect(getRemainingGroupTime(group, entries, '2')).toBe(0); - }); - - it('skips entries which are not playable events', () => { - const group = makeGroup(['1', '2', '3', '4']); - const entries = makeEntries( - makeEvent('1', { duration: 10 }), - makeEvent('2', { duration: 20, skip: true }), - { id: '3', type: SupportedEntry.Milestone, parent: 'group' } as OntimeEntry, - makeEvent('4', { duration: 40 }), - ); - - expect(getRemainingGroupTime(group, entries, '1')).toBe(40); - }); - - it('returns 0 when the loaded event is not part of the group', () => { - const group = makeGroup(['1', '2']); - const entries = makeEntries(makeEvent('1', { duration: 10 }), makeEvent('2', { duration: 20 })); - - expect(getRemainingGroupTime(group, entries, 'elsewhere')).toBe(0); - expect(getRemainingGroupTime(group, entries, null)).toBe(0); - }); - - it('tolerates ids which are missing from the rundown', () => { - const group = makeGroup(['1', 'missing', '3']); - const entries = makeEntries(makeEvent('1', { duration: 10 }), makeEvent('3', { duration: 30 })); - - expect(getRemainingGroupTime(group, entries, '1')).toBe(30); - }); -}); diff --git a/apps/client/src/common/utils/groupTimer.ts b/apps/client/src/common/utils/groupTimer.ts deleted file mode 100644 index 9809a18e1..000000000 --- a/apps/client/src/common/utils/groupTimer.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { EntryId, OntimeEntry, OntimeGroup } from 'ontime-types'; -import { isOntimeEvent, isPlayableEvent } from 'ontime-types'; - -/** - * Calculates the amount of playable content remaining in a group - * after (and excluding) the currently loaded event. - * - * The group timer treats the group as if it were a single event, so the displayed - * value is the running event timer plus whatever is still scheduled after it. - * Keeping this relative to the event timer means the group timer inherits pause, - * added time, overtime and roll behaviour without duplicating any of that logic. - * - * The aggregation mirrors the group duration calculated in the server - * (apps/server/src/api-data/rundown/rundown.dao.ts): non playable entries are - * skipped and the gap is accounted for in every entry other than the first. - */ -export function getRemainingGroupTime( - group: OntimeGroup, - entries: Record, - currentEventId: EntryId | null, -): number { - if (currentEventId === null) { - return 0; - } - - const currentIndex = group.entries.indexOf(currentEventId); - if (currentIndex === -1) { - return 0; - } - - let remaining = 0; - - for (let i = currentIndex + 1; i < group.entries.length; i++) { - const entry = entries[group.entries[i]]; - if (!entry || !isOntimeEvent(entry) || !isPlayableEvent(entry)) { - continue; - } - - // the first entry of the group has no gap to account for, - // any other entry could be preceded by idle time - if (i > 0) { - remaining += entry.gap; - } - remaining += entry.duration; - } - - return remaining; -} diff --git a/apps/client/src/features/control/message/TimerPreview.tsx b/apps/client/src/features/control/message/TimerPreview.tsx index 70731ca26..8f35c22ca 100644 --- a/apps/client/src/features/control/message/TimerPreview.tsx +++ b/apps/client/src/features/control/message/TimerPreview.tsx @@ -5,8 +5,7 @@ import { LuArrowDownToLine } from 'react-icons/lu'; import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils'; import Tooltip from '../../../common/components/tooltip/Tooltip'; import useViewSettings from '../../../common/hooks-query/useViewSettings'; -import { useGroupTimer } from '../../../common/hooks/useGroupTimer'; -import { useMessagePreview } from '../../../common/hooks/useSocket'; +import { useGroupTimer, useMessagePreview } from '../../../common/hooks/useSocket'; import { handleLinks } from '../../../common/utils/linkUtils'; import { cx, timerPlaceholder } from '../../../common/utils/styleUtils'; import PipRoot from '../../../views/editor/pip-timer/PipRoot'; @@ -23,7 +22,7 @@ const secondarySourceLabels: Record = { export default function TimerPreview() { const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview(); const { data } = useViewSettings(); - const { isActive: isGroupTimerActive } = useGroupTimer(); + const isGroupTimerActive = useGroupTimer() !== null; const main = (() => { if (showTimerMessage) return 'Message'; diff --git a/apps/client/src/views/editor/pip-timer/PipHost.tsx b/apps/client/src/views/editor/pip-timer/PipHost.tsx index 7b49ec041..a11e914ef 100644 --- a/apps/client/src/views/editor/pip-timer/PipHost.tsx +++ b/apps/client/src/views/editor/pip-timer/PipHost.tsx @@ -1,10 +1,8 @@ import { ErrorBoundary } from '@sentry/react'; -import { QueryClientProvider } from '@tanstack/react-query'; import { createRoot } from 'react-dom/client'; import { CornerPipButton } from '../../../common/components/editor-utils/EditorUtils'; import useViewSettings from '../../../common/hooks-query/useViewSettings'; -import { ontimeQueryClient } from '../../../common/queryClient'; import { PipTimer } from './PipTimer'; export default function PipTimerHost() { @@ -57,10 +55,7 @@ export default function PipTimerHost() { pipRoot.render( - {/* the PiP document is a separate react root, it needs its own provider to reach the query cache */} - - - + , ); }; diff --git a/apps/client/src/views/editor/pip-timer/PipTimer.tsx b/apps/client/src/views/editor/pip-timer/PipTimer.tsx index 7c3f035f6..80f82ba37 100644 --- a/apps/client/src/views/editor/pip-timer/PipTimer.tsx +++ b/apps/client/src/views/editor/pip-timer/PipTimer.tsx @@ -1,9 +1,8 @@ -import { TimerPhase, ViewSettings } from 'ontime-types'; +import { ViewSettings } from 'ontime-types'; import { FitText } from '../../../common/components/fit-text/FitText'; import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar'; -import { useGroupTimer } from '../../../common/hooks/useGroupTimer'; -import { useTimerSocket } from '../../../common/hooks/useSocket'; +import { useGroupTimer, useTimerSocket } from '../../../common/hooks/useSocket'; import { cx } from '../../../common/utils/styleUtils'; import { getFormattedTimer, getTimerByType } from '../../common/viewUtils'; import { @@ -11,9 +10,8 @@ import { getIsPlaying, getSecondaryDisplay, getShowMessage, - getShowModifiers, getShowProgressBar, - getTotalTime, + resolveTimerDisplay, } from '../../timer/timer.utils'; import { getTimerColour } from '../../utils/presentation.utils'; @@ -29,26 +27,23 @@ export function PipTimer({ viewSettings }: PipTimerProps) { // gather modifiers const showOverlay = getShowMessage(message.timer); - const { - showFinished: eventShowFinished, - showWarning: eventShowWarning, - showDanger: eventShowDanger, - } = getShowModifiers(timerTypeNow, countToEndNow, time.phase, false, '', false); - - /** - * warning and danger thresholds belong to the event, they carry no meaning against a group duration. - * overtime is kept, but only once the group itself has run out of time - */ - const showWarning = eventShowWarning && !groupTimer.isActive; - const showDanger = eventShowDanger && !groupTimer.isActive; - const showFinished = eventShowFinished && (!groupTimer.isActive || (groupTimer.current ?? 0) <= 0); + const timerDisplay = resolveTimerDisplay({ + time, + groupTimer, + event: eventNow, + timerType: timerTypeNow, + countToEnd: countToEndNow, + freezeOvertime: false, + freezeMessage: '', + hidePhase: false, + }); + const { showFinished, showWarning, showDanger } = timerDisplay; const isPlaying = getIsPlaying(time.playback); const showProgressBar = getShowProgressBar(timerTypeNow); // gather timer data - const totalTime = groupTimer.isActive ? groupTimer.duration : getTotalTime(time.duration, time.addedTime); - const stageTimer = getTimerByType(false, timerTypeNow, clock, groupTimer.isActive ? groupTimer : time, timerTypeNow); + const stageTimer = getTimerByType(false, timerTypeNow, clock, timerDisplay.source, timerTypeNow); const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', { removeSeconds: false, removeLeadingZero: false, @@ -88,11 +83,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
{display}
- {groupTimer.isActive &&
group
} + {timerDisplay.isGroup &&
group
}
{secondaryContent} @@ -103,12 +98,12 @@ export function PipTimer({ viewSettings }: PipTimerProps) { {showProgressBar && ( diff --git a/apps/client/src/views/timer/Timer.tsx b/apps/client/src/views/timer/Timer.tsx index b5eb39f94..d75788e3a 100644 --- a/apps/client/src/views/timer/Timer.tsx +++ b/apps/client/src/views/timer/Timer.tsx @@ -1,4 +1,4 @@ -import { MaybeString, OntimeView, TimerPhase, TimerType } from 'ontime-types'; +import { MaybeString, OntimeView, TimerType } from 'ontime-types'; import { useMemo } from 'react'; import { FitText } from '../../common/components/fit-text/FitText'; @@ -8,8 +8,7 @@ import TitleCard from '../../common/components/title-card/TitleCard'; import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock'; -import { useGroupTimer } from '../../common/hooks/useGroupTimer'; -import { useTimerSocket } from '../../common/hooks/useSocket'; +import { useGroupTimer, useTimerSocket } from '../../common/hooks/useSocket'; import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { cx } from '../../common/utils/styleUtils'; import { formatTime, getDefaultFormat } from '../../common/utils/time'; @@ -26,9 +25,8 @@ import { getSecondaryDisplay, getShowClock, getShowMessage, - getShowModifiers, getShowProgressBar, - getTotalTime, + resolveTimerDisplay, } from './timer.utils'; import { TimerData, useTimerData } from './useTimerData'; @@ -81,20 +79,17 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings, // gather modifiers const viewTimerType = timerType ?? timerTypeNow; const showOverlay = getShowMessage(message.timer); - const { - showEndMessage, - showFinished: eventShowFinished, - showWarning: eventShowWarning, - showDanger: eventShowDanger, - } = getShowModifiers(timerTypeNow, countToEndNow, time.phase, freezeOvertime, freezeMessage, hidePhase); - - /** - * warning and danger thresholds belong to the event, they carry no meaning against a group duration. - * overtime is kept, but only once the group itself has run out of time - */ - const showWarning = eventShowWarning && !groupTimer.isActive; - const showDanger = eventShowDanger && !groupTimer.isActive; - const showFinished = eventShowFinished && (!groupTimer.isActive || (groupTimer.current ?? 0) <= 0); + const timerDisplay = resolveTimerDisplay({ + time, + groupTimer, + event: eventNow, + timerType: timerTypeNow, + countToEnd: countToEndNow, + freezeOvertime, + freezeMessage, + hidePhase, + }); + const { showEndMessage, showFinished, showWarning, showDanger } = timerDisplay; const isPlaying = getIsPlaying(time.playback); const showClock = !hideClock && getShowClock(viewTimerType); const showProgressBar = !hideProgress && getShowProgressBar(viewTimerType); @@ -111,9 +106,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings, ); // gather timer data - const totalTime = groupTimer.isActive ? groupTimer.duration : getTotalTime(time.duration, time.addedTime); - const timerSource = groupTimer.isActive ? groupTimer : time; - const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, timerSource, timerType); + const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, timerDisplay.source, timerType); const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, { removeSeconds: hideTimerSeconds, removeLeadingZero: removeLeadingZeros, @@ -188,12 +181,12 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings, className={cx(['timer', subduePaused && 'timer--paused', showFinished && 'timer--finished'])} style={{ fontSize: `${timerFontSize}vw` }} data-type={viewTimerType} - data-phase={groupTimer.isActive ? TimerPhase.Default : time.phase} + data-phase={timerDisplay.phase} > {display}
)} - {groupTimer.isActive && !showEndMessage &&
group
} + {timerDisplay.isGroup && !showEndMessage &&
group
}
{secondaryContent} @@ -204,12 +197,12 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings, {showProgressBar && ( diff --git a/apps/client/src/views/timer/timer.utils.ts b/apps/client/src/views/timer/timer.utils.ts index 1add32d88..58b19d052 100644 --- a/apps/client/src/views/timer/timer.utils.ts +++ b/apps/client/src/views/timer/timer.utils.ts @@ -1,4 +1,5 @@ import { + GroupTimerState, MaybeNumber, MessageState, OntimeEvent, @@ -6,6 +7,7 @@ import { RundownEntries, TimerMessage, TimerPhase, + TimerState, TimerType, } from 'ontime-types'; import { isPlaybackActive } from 'ontime-utils'; @@ -114,6 +116,57 @@ export function getShowModifiers( }; } +interface ResolveTimerDisplayOptions { + time: TimerState; + /** when present, views show the group instead of the running event */ + groupTimer: GroupTimerState | null; + event: Pick | null; + timerType: TimerType; + countToEnd: boolean; + freezeOvertime: boolean; + freezeMessage: string; + hidePhase: boolean; +} + +/** + * Resolves which timer a view should display, and the modifiers that go with it. + * + * A group has no warning or danger thresholds of its own, so it only ever reports as + * running or overtime. Feeding that phase through `getShowModifiers` means the warning + * and danger states are suppressed as a consequence of what a group is, rather than + * every view having to remember to special case them. + */ +export function resolveTimerDisplay({ + time, + groupTimer, + event, + timerType, + countToEnd, + freezeOvertime, + freezeMessage, + hidePhase, +}: ResolveTimerDisplayOptions) { + const isGroup = groupTimer !== null; + const phase = isGroup + ? groupTimer.current <= 0 + ? TimerPhase.Overtime + : TimerPhase.Default + : time.phase; + + return { + isGroup, + phase, + /** the values to render, shaped for getTimerByType */ + source: isGroup ? groupTimer : time, + /** the target of a progress bar */ + total: isGroup ? groupTimer.duration : getTotalTime(time.duration, time.addedTime), + // thresholds belong to the event, they carry no meaning against a group duration + warning: isGroup ? undefined : event?.timeWarning, + danger: isGroup ? undefined : event?.timeDanger, + ...getShowModifiers(timerType, countToEnd, phase, freezeOvertime, freezeMessage, hidePhase), + }; +} + /** * What, if anything, should be displayed in the secondary field */ diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 29d9d17a6..5b373ea37 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -207,6 +207,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb eventStore.init({ clock: state.clock, timer: state.timer, + groupTimer: state.groupTimer, message: { ...runtimeStorePlaceholder.message }, offset: state.offset, rundown: state.rundown, diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index eecc5893a..4d8b6adf3 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -1,4 +1,16 @@ -import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types'; +import { + EndAction, + MaybeNumber, + OntimeEvent, + OntimeGroup, + Playback, + Rundown, + SupportedEntry, + TimeOfDay, + TimeStrategy, + TimerPhase, + TimerType, +} from 'ontime-types'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils'; import type { RuntimeState } from '../../stores/runtimeState.js'; @@ -7,6 +19,8 @@ import { getCurrent, getElapsed, getExpectedFinish, + getGroupTimer, + getGroupTiming, getRuntimeOffset, getTimerPhase, hasCrossedMidnight, @@ -1452,3 +1466,138 @@ describe('findDay()', () => { expect(findDayOffset(22 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(0); // -> 1 }); }); + +describe('getGroupTiming()', () => { + const makeEvent = (id: string, duration: number, patch: object = {}) => + ({ id, type: SupportedEntry.Event, duration, gap: 0, skip: false, parent: 'group', ...patch }) as OntimeEvent; + + const makeGroup = (entries: string[]) => ({ id: 'group', type: SupportedEntry.Group, entries }) as OntimeGroup; + + const makeEntries = (...entries: OntimeEvent[]) => + Object.fromEntries(entries.map((entry) => [entry.id, entry])) as Rundown['entries']; + + it('splits the scheduled content around the loaded event', () => { + const group = makeGroup(['1', '2', '3']); + const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20), makeEvent('3', 30)); + + expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 50 }); + expect(getGroupTiming(group, entries, '2')).toStrictEqual({ before: 10, after: 30 }); + expect(getGroupTiming(group, entries, '3')).toStrictEqual({ before: 30, after: 0 }); + }); + + it('accounts for the gaps between events', () => { + const group = makeGroup(['1', '2', '3']); + const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20, { gap: 5 }), makeEvent('3', 30, { gap: 7 })); + + expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 20 + 5 + 30 + 7 }); + // the gap before the loaded event has already passed, so it counts as time spent + expect(getGroupTiming(group, entries, '2')).toStrictEqual({ before: 10 + 5, after: 30 + 7 }); + }); + + it('splits into values which add up to the group duration', () => { + const group = makeGroup(['1', '2', '3']); + const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20, { gap: 5 }), makeEvent('3', 30)); + const groupDuration = 10 + 20 + 5 + 30; + + for (const id of ['1', '2', '3']) { + const timing = getGroupTiming(group, entries, id)!; + expect(timing.before + entries[id].duration + timing.after).toBe(groupDuration); + } + }); + + it('skips entries which are not playable events', () => { + const group = makeGroup(['1', '2', '3', '4']); + const entries = { + ...makeEntries(makeEvent('1', 10), makeEvent('2', 20, { skip: true }), makeEvent('4', 40)), + '3': { id: '3', type: SupportedEntry.Milestone, parent: 'group' }, + } as Rundown['entries']; + + expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 40 }); + }); + + it('returns null when the loaded event is not part of the group', () => { + const group = makeGroup(['1', '2']); + const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20)); + + expect(getGroupTiming(group, entries, 'elsewhere')).toBeNull(); + }); +}); + +describe('getGroupTimer()', () => { + const makeState = (patch: { + useGroupTimer?: boolean; + duration?: number; + timing?: { before: number; after: number } | null; + current?: MaybeNumber; + elapsed?: MaybeNumber; + addedTime?: number; + }) => + ({ + groupNow: { duration: patch.duration ?? 100, useGroupTimer: patch.useGroupTimer ?? true }, + _groupTiming: patch.timing === undefined ? { before: 0, after: 0 } : patch.timing, + timer: { + current: patch.current ?? null, + elapsed: patch.elapsed ?? null, + addedTime: patch.addedTime ?? 0, + }, + }) as RuntimeState; + + it('is null when there is no group running', () => { + expect(getGroupTimer({ groupNow: null, _groupTiming: null } as RuntimeState)).toBeNull(); + }); + + it('is null when the group has not opted in', () => { + expect(getGroupTimer(makeState({ useGroupTimer: false, current: 10 }))).toBeNull(); + }); + + it('is null when the loaded event is not part of the group', () => { + expect(getGroupTimer(makeState({ timing: null, current: 10 }))).toBeNull(); + }); + + it('offsets the event timer by the content scheduled around it', () => { + const state = makeState({ duration: 100, timing: { before: 30, after: 50 }, current: 20, elapsed: 0 }); + + expect(getGroupTimer(state)).toStrictEqual({ current: 70, elapsed: 30, duration: 100 }); + }); + + it('keeps elapsed and current adding up to the total', () => { + const state = makeState({ duration: 100, timing: { before: 30, after: 50 }, current: 12, elapsed: 8 }); + const groupTimer = getGroupTimer(state)!; + + expect(groupTimer.elapsed + groupTimer.current).toBe(groupTimer.duration); + }); + + it('grows the total with the time added to the running event', () => { + const state = makeState({ + duration: 100, + timing: { before: 0, after: 0 }, + current: 160, + elapsed: 0, + addedTime: 60, + }); + const groupTimer = getGroupTimer(state)!; + + expect(groupTimer.duration).toBe(160); + // elapsed keeps counting up rather than clamping at zero once time is added + expect(groupTimer.elapsed).toBe(0); + expect(groupTimer.elapsed + groupTimer.current).toBe(groupTimer.duration); + }); + + it('reports elapsed time while the group is in credit from added time', () => { + const state = makeState({ + duration: 100, + timing: { before: 0, after: 0 }, + current: 150, + elapsed: 10, + addedTime: 60, + }); + + expect(getGroupTimer(state)).toStrictEqual({ current: 150, elapsed: 10, duration: 160 }); + }); + + it('goes negative when the group runs into overtime', () => { + const state = makeState({ duration: 100, timing: { before: 90, after: 0 }, current: -15, elapsed: 25 }); + + expect(getGroupTimer(state)?.current).toBe(-15); + }); +}); diff --git a/apps/server/src/services/runtime-service/runtime.service.ts b/apps/server/src/services/runtime-service/runtime.service.ts index ee6d0de58..ed41ab74a 100644 --- a/apps/server/src/services/runtime-service/runtime.service.ts +++ b/apps/server/src/services/runtime-service/runtime.service.ts @@ -709,6 +709,18 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert RuntimeService.previousState.timer = { ...state.timer }; } + /** + * the group timer is the event timer offset by a constant, so it changes exactly when + * the event timer does and can share its throttling. + * entry changes are also considered since loading an event can move us between groups + */ + if (updateTimer || entryChanged) { + if (!deepEqual(RuntimeService.previousState.groupTimer, state.groupTimer)) { + batch.add('groupTimer', state.groupTimer); + RuntimeService.previousState.groupTimer = state.groupTimer ? { ...state.groupTimer } : null; + } + } + /** * clock has changed by a second or more. * or the timer updated so we ensure that the timer and clock ticks are in sync diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 8c28a5e8d..613828c8b 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,7 +1,18 @@ -import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types'; +import { + Day, + EntryId, + GroupTimerState, + MaybeNumber, + OntimeGroup, + Rundown, + TimeOfDay, + TimerPhase, + isOntimeEvent, + isPlayableEvent, +} from 'ontime-types'; import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils'; -import type { RuntimeState } from '../stores/runtimeState.js'; +import type { GroupTiming, RuntimeState } from '../stores/runtimeState.js'; /** * handle events that span over midnight @@ -231,3 +242,76 @@ export function findDayOffset(plannedStart: number, clock: number): Day { if (distance < -12 * MILLIS_PER_HOUR) return 1 as Day; return 0 as Day; } + +/** + * Splits a group's scheduled content around the loaded event. + * + * The result is stable for as long as the same event is loaded, so it is calculated + * when the group is loaded rather than on every update. + * The aggregation mirrors the group duration calculated in the rundown + * (see rundown.dao.ts): non playable entries are skipped and the gap is + * accounted for in every entry other than the first. + */ +export function getGroupTiming( + group: OntimeGroup, + entries: Rundown['entries'], + currentEventId: EntryId, +): GroupTiming | null { + const currentIndex = group.entries.indexOf(currentEventId); + if (currentIndex === -1) { + return null; + } + + let before = 0; + let after = 0; + + for (let i = 0; i < group.entries.length; i++) { + const entry = entries[group.entries[i]]; + if (!isOntimeEvent(entry) || !isPlayableEvent(entry)) { + continue; + } + + // the first entry of the group has no gap to account for, + // any other entry could be preceded by idle time + const gap = i > 0 ? entry.gap : 0; + + if (i < currentIndex) { + before += gap + entry.duration; + } else if (i === currentIndex) { + // the loaded event contributes its own duration through the event timer, + // but the idle time before it has already passed + before += gap; + } else { + after += gap + entry.duration; + } + } + + return { before, after }; +} + +/** + * Derives the shared group timer from the event timer. + * + * Keeping this relative to the event timer is what makes the group behave as if it were + * a single event containing all its children: pause, added time, overtime, roll and + * midnight rollovers are all inherited instead of being recalculated. + */ +export function getGroupTimer(state: RuntimeState): GroupTimerState | null { + const { groupNow, _groupTiming } = state; + + if (groupNow === null || !groupNow.useGroupTimer || _groupTiming === null) { + return null; + } + + const { current, elapsed, addedTime } = state.timer; + if (current === null) { + return null; + } + + return { + current: current + _groupTiming.after, + elapsed: (elapsed ?? 0) + _groupTiming.before, + // mirrors the total time of an event, which grows with the time added to it + duration: groupNow.duration + addedTime, + }; +} diff --git a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts index eb9905f2b..4fe325d8d 100644 --- a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts +++ b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts @@ -9,6 +9,8 @@ const baseState: RuntimeState = { eventNext: null, eventFlag: null, groupNow: null, + groupTimer: null, + _groupTiming: null, rundown: { selectedEventIndex: null, numEvents: 0, diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index f306b154e..ce05a0736 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -16,6 +16,7 @@ import { TimeOfDay, TimerPhase, TimerState, + GroupTimerState, isOntimeEvent, runtimeStorePlaceholder, } from 'ontime-types'; @@ -39,6 +40,8 @@ import { getCurrent, getElapsed, getExpectedFinish, + getGroupTimer, + getGroupTiming, getRuntimeOffset, getTimerPhase, hasCrossedMidnight, @@ -51,6 +54,12 @@ type ExpectedMetadata = { isLinkedToLoaded: boolean; } | null; +/** scheduled content of the running group, split around the loaded event */ +export type GroupTiming = { + before: number; + after: number; +}; + export type RuntimeState = { clock: TimeOfDay; groupNow: OntimeGroup | null; @@ -59,6 +68,8 @@ export type RuntimeState = { eventFlag: PlayableEvent | null; offset: Offset; timer: TimerState; + /** derived from the timer, only refreshed on the getState() projection */ + groupTimer: GroupTimerState | null; rundown: RundownState; // private properties of the timer calculations _timer: { @@ -72,6 +83,7 @@ export type RuntimeState = { totalDelay: number; // this value comes from rundown service }; _group: ExpectedMetadata; + _groupTiming: GroupTiming | null; _flag: ExpectedMetadata; _end: ExpectedMetadata; _startEpoch: Maybe; @@ -86,6 +98,7 @@ const runtimeState: RuntimeState = { eventFlag: null, offset: { ...runtimeStorePlaceholder.offset }, timer: { ...runtimeStorePlaceholder.timer }, + groupTimer: null, rundown: { ...runtimeStorePlaceholder.rundown }, _timer: { forceFinish: null, @@ -98,6 +111,7 @@ const runtimeState: RuntimeState = { totalDelay: 0, }, _group: null, + _groupTiming: null, _flag: null, _end: null, _startEpoch: null, @@ -115,6 +129,8 @@ export function getState(): Readonly { offset: { ...runtimeState.offset }, rundown: { ...runtimeState.rundown }, timer: { ...runtimeState.timer }, + // derived here so it can never drift from the timer values it is built on + groupTimer: getGroupTimer(runtimeState), _timer: { ...runtimeState._timer }, _rundown: { ...runtimeState._rundown }, }; @@ -156,6 +172,7 @@ export function clearState() { runtimeState.groupNow = null; runtimeState._group = null; + runtimeState._groupTiming = null; runtimeState.rundown.actualStart = null; runtimeState.rundown.selectedEventIndex = null; @@ -897,6 +914,7 @@ export function loadGroupFlagAndEnd( const previousGroup = state.groupNow?.id; state.groupNow = null; state._group = null; + state._groupTiming = null; state.eventFlag = null; state._flag = null; state._end = null; @@ -920,6 +938,11 @@ export function loadGroupFlagAndEnd( state.rundown.actualGroupStart = null; } + // the split is stable while the same event is loaded, so we only calculate it here + if (state.groupNow !== null) { + state._groupTiming = getGroupTiming(state.groupNow, rundown.entries, state.eventNow.id); + } + // if we don't have a any flags in the rundown then no need to look for it let foundFlag = !flagsPresent; // if we don't have a last event for the group there is no need to find its end time diff --git a/packages/types/src/definitions/runtime/RuntimeStore.ts b/packages/types/src/definitions/runtime/RuntimeStore.ts index a5e899abd..92edcd46a 100644 --- a/packages/types/src/definitions/runtime/RuntimeStore.ts +++ b/packages/types/src/definitions/runtime/RuntimeStore.ts @@ -17,6 +17,7 @@ export const runtimeStorePlaceholder: Readonly = { secondaryTimer: null, // change on every update startedAt: null, // change can only be initiated by user }, + groupTimer: null, // derived from the timer, null unless the running group opted in message: { timer: { text: '', diff --git a/packages/types/src/definitions/runtime/RuntimeStore.type.ts b/packages/types/src/definitions/runtime/RuntimeStore.type.ts index d2517c3f6..2db088dda 100644 --- a/packages/types/src/definitions/runtime/RuntimeStore.type.ts +++ b/packages/types/src/definitions/runtime/RuntimeStore.type.ts @@ -3,12 +3,14 @@ import type { SimpleTimerState } from './AuxTimer.type.js'; import type { MessageState } from './MessageControl.type.js'; import type { Offset } from './Offset.type.js'; import type { RundownState } from './RundownState.type.js'; -import type { TimerState } from './TimerState.type.js'; +import type { GroupTimerState, TimerState } from './TimerState.type.js'; export type RuntimeStore = { // timer data clock: number; timer: TimerState; + /** shared timer for the running group, null unless the group opted in */ + groupTimer: GroupTimerState | null; // messages service message: MessageState; diff --git a/packages/types/src/definitions/runtime/TimerState.type.ts b/packages/types/src/definitions/runtime/TimerState.type.ts index 626144876..1d7cd8ff7 100644 --- a/packages/types/src/definitions/runtime/TimerState.type.ts +++ b/packages/types/src/definitions/runtime/TimerState.type.ts @@ -11,6 +11,22 @@ export enum TimerPhase { Pending = 'pending', } +/** + * Timer for the running group, present when the group opted into a shared timer. + * + * The group is treated as a single event containing all its children: the values are + * derived from the running event timer plus the content scheduled around it, so pause, + * added time, overtime and roll are inherited from the event timer rather than recalculated. + */ +export type GroupTimerState = { + /** Time remaining in the group */ + current: number; + /** Time elapsed since the group started */ + elapsed: number; + /** Total time in the group, includes time added to the running event */ + duration: number; +}; + /** * Gathers the current running timer state */ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 77e98fd91..9ed8dea47 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -119,7 +119,7 @@ export type { Offset } from './definitions/runtime/Offset.type.js'; export { OffsetMode } from './definitions/runtime/Offset.type.js'; export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js'; export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js'; -export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js'; +export { type GroupTimerState, type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js'; // ---> Extra Timer export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';