diff --git a/apps/client/src/common/hooks/__tests__/useSocket.utils.test.ts b/apps/client/src/common/hooks/__tests__/useSocket.utils.test.ts new file mode 100644 index 000000000..caacac424 --- /dev/null +++ b/apps/client/src/common/hooks/__tests__/useSocket.utils.test.ts @@ -0,0 +1,81 @@ +import { Playback, RuntimeStore, TimerPhase, TimerType, runtimeStorePlaceholder } from 'ontime-types'; + +import { resolveTimerDisplay } from '../useSocket.utils'; + +const eventTimer = { ...runtimeStorePlaceholder.timer, current: 5_000 }; +const groupTimer = { + ...runtimeStorePlaceholder.timer, + current: 25_000, + phase: TimerPhase.Default, + playback: Playback.Play, +}; + +function makeState(patch: Partial = {}): RuntimeStore { + return { + ...runtimeStorePlaceholder, + timer: eventTimer, + eventNow: { + id: 'event-1', + timerType: TimerType.CountUp, + countToEnd: true, + } as RuntimeStore['eventNow'], + ...patch, + }; +} + +describe('resolveTimerDisplay()', () => { + it('uses the event timer by default', () => { + expect(resolveTimerDisplay(makeState())).toMatchObject({ + time: eventTimer, + timerType: TimerType.CountUp, + countToEnd: true, + usesGroupTimer: false, + }); + }); + + it('uses the group timer and display type when enabled', () => { + const display = resolveTimerDisplay( + makeState({ + groupNow: { useGroupTimer: true, timerType: TimerType.CountDown } as RuntimeStore['groupNow'], + groupTimer, + }), + ); + + expect(display).toMatchObject({ + time: groupTimer, + timerType: TimerType.CountDown, + countToEnd: false, + usesGroupTimer: true, + eventTimer, + eventTimerType: TimerType.CountUp, + }); + }); + + it('ignores a group timer when the group setting is disabled', () => { + const display = resolveTimerDisplay( + makeState({ + groupNow: { useGroupTimer: false, timerType: TimerType.CountDown } as RuntimeStore['groupNow'], + groupTimer, + }), + ); + + expect(display.time).toBe(eventTimer); + expect(display.usesGroupTimer).toBe(false); + }); + + it('falls back entirely to the event display while group timer data is unavailable', () => { + const display = resolveTimerDisplay( + makeState({ + groupNow: { useGroupTimer: true, timerType: TimerType.CountDown } as RuntimeStore['groupNow'], + groupTimer: null, + }), + ); + + expect(display).toMatchObject({ + time: eventTimer, + timerType: TimerType.CountUp, + countToEnd: true, + usesGroupTimer: false, + }); + }); +}); diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index b64cce05f..c727429f9 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -1,7 +1,8 @@ -import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage, TimerType } from 'ontime-types'; +import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types'; import { useRuntimeStore } from '../stores/runtime'; import { sendSocket } from '../utils/socket'; +import { resolveTimerDisplay } from './useSocket.utils'; const createSelector = (selector: (state: RuntimeStore) => T) => @@ -38,15 +39,19 @@ export const useExternalMessageInput = createSelector((state: RuntimeStore) => ( visible: state.message.timer.secondarySource === 'secondary', })); -export const useMessagePreview = createSelector((state: RuntimeStore) => ({ - blink: state.message.timer.blink, - blackout: state.message.timer.blackout, - phase: state.timer.phase, - secondarySource: state.message.timer.secondarySource, - showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text), - timerType: state.eventNow?.timerType ?? null, - countToEnd: state.eventNow?.countToEnd ?? false, -})); +export const useMessagePreview = createSelector((state: RuntimeStore) => { + const timerDisplay = resolveTimerDisplay(state); + return { + blink: state.message.timer.blink, + blackout: state.message.timer.blackout, + phase: timerDisplay.time.phase, + secondarySource: state.message.timer.secondarySource, + showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text), + timerType: timerDisplay.timerType, + countToEnd: timerDisplay.countToEnd, + usesGroupTimer: timerDisplay.usesGroupTimer, + }; +}); export const setMessage = { timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }), @@ -230,20 +235,26 @@ export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({ /* ======================= View specific subscriptions ======================= */ -export const useTimerSocket = createSelector((state: RuntimeStore) => ({ - eventNext: state.eventNext, - eventNow: state.eventNow, - message: state.message, - time: state.timer, - clock: state.clock, - timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown, - countToEndNow: state.eventNow?.countToEnd ?? false, - auxTimer: { - aux1: state.auxtimer1.current, - aux2: state.auxtimer2.current, - aux3: state.auxtimer3.current, - }, -})); +export const useTimerSocket = createSelector((state: RuntimeStore) => { + const timerDisplay = resolveTimerDisplay(state); + return { + eventNext: state.eventNext, + eventNow: state.eventNow, + message: state.message, + time: timerDisplay.time, + eventTimer: timerDisplay.eventTimer, + clock: state.clock, + timerTypeNow: timerDisplay.timerType, + eventTimerType: timerDisplay.eventTimerType, + countToEndNow: timerDisplay.countToEnd, + usesGroupTimer: timerDisplay.usesGroupTimer, + auxTimer: { + aux1: state.auxtimer1.current, + aux2: state.auxtimer2.current, + aux3: state.auxtimer3.current, + }, + }; +}); export const useCountdownSocket = createSelector((state: RuntimeStore) => ({ playback: state.timer.playback, diff --git a/apps/client/src/common/hooks/useSocket.utils.ts b/apps/client/src/common/hooks/useSocket.utils.ts new file mode 100644 index 000000000..eefc4fa72 --- /dev/null +++ b/apps/client/src/common/hooks/useSocket.utils.ts @@ -0,0 +1,27 @@ +import { RuntimeStore, TimerType } from 'ontime-types'; + +type TimerDisplaySource = Pick; + +export function resolveTimerDisplay(state: TimerDisplaySource) { + const eventTimerType = state.eventNow?.timerType ?? TimerType.CountDown; + + if (state.groupNow?.useGroupTimer === true && state.groupTimer !== null) { + return { + time: state.groupTimer, + timerType: state.groupNow.timerType, + countToEnd: false, + usesGroupTimer: true, + eventTimer: state.timer, + eventTimerType, + }; + } + + return { + time: state.timer, + timerType: eventTimerType, + countToEnd: state.eventNow?.countToEnd ?? false, + usesGroupTimer: false, + eventTimer: state.timer, + eventTimerType, + }; +} diff --git a/apps/client/src/common/utils/__tests__/rundownMetadata.test.ts b/apps/client/src/common/utils/__tests__/rundownMetadata.test.ts index e60446be4..c96de719f 100644 --- a/apps/client/src/common/utils/__tests__/rundownMetadata.test.ts +++ b/apps/client/src/common/utils/__tests__/rundownMetadata.test.ts @@ -1,6 +1,6 @@ -import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types'; +import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry, TimerType } from 'ontime-types'; -import { initRundownMetadata } from '../rundownMetadata'; +import { getFlatRundownMetadata, initRundownMetadata } from '../rundownMetadata'; describe('initRundownMetadata()', () => { it('processes nested rundown data', () => { @@ -300,3 +300,36 @@ describe('initRundownMetadata()', () => { }); }); }); + +describe('getFlatRundownMetadata()', () => { + it('exposes group timer settings on a group and its events', () => { + const group = { + id: 'group', + type: SupportedEntry.Group, + entries: ['event'], + colour: 'red', + useGroupTimer: true, + timerType: TimerType.CountUp, + } as OntimeGroup; + const event = { + id: 'event', + type: SupportedEntry.Event, + parent: group.id, + timeStart: 0, + timeEnd: 1, + duration: 1, + dayOffset: 0, + gap: 0, + skip: false, + linkStart: false, + } as OntimeEvent; + + const flat = getFlatRundownMetadata( + { entries: { [group.id]: group, [event.id]: event }, flatOrder: [group.id, event.id] }, + null, + ); + + expect(flat[0]).toMatchObject({ groupUsesTimer: true, groupTimerType: TimerType.CountUp }); + expect(flat[1]).toMatchObject({ groupUsesTimer: true, groupTimerType: TimerType.CountUp }); + }); +}); diff --git a/apps/client/src/common/utils/rundownMetadata.ts b/apps/client/src/common/utils/rundownMetadata.ts index 5e410bae0..90d7b125f 100644 --- a/apps/client/src/common/utils/rundownMetadata.ts +++ b/apps/client/src/common/utils/rundownMetadata.ts @@ -3,9 +3,11 @@ import { OntimeDelay, OntimeEntry, OntimeEvent, + OntimeGroup, OntimeMilestone, PlayableEvent, Rundown, + TimerType, isOntimeEvent, isOntimeGroup, isPlayableEvent, @@ -29,7 +31,11 @@ export type RundownMetadata = { isFirstAfterGroup: boolean; }; -export type ExtendedEntry = T & RundownMetadata; +export type ExtendedEntry = T & + RundownMetadata & { + groupUsesTimer?: boolean; + groupTimerType?: TimerType; + }; export const lastMetadataKey = 'LAST'; @@ -65,10 +71,23 @@ export function getFlatRundownMetadata( ): ExtendedEntry[] { const { process } = initRundownMetadata(selectedEventId); const flatRundown: ExtendedEntry[] = []; + let activeGroup: OntimeGroup | null = null; for (const id of data.flatOrder) { const entry = data.entries[id]; - const extendedEntry = { ...entry, ...process(entry) }; + if (isOntimeGroup(entry)) { + activeGroup = entry; + } else if (entry.parent !== activeGroup?.id) { + activeGroup = null; + } + + const timerGroup = isOntimeGroup(entry) ? entry : activeGroup; + const extendedEntry = { + ...entry, + ...process(entry), + groupUsesTimer: timerGroup?.useGroupTimer ?? false, + groupTimerType: timerGroup?.timerType, + }; flatRundown.push(extendedEntry); } diff --git a/apps/client/src/features/control/message/TimerPreview.module.scss b/apps/client/src/features/control/message/TimerPreview.module.scss index 7f64c4856..b0ca94865 100644 --- a/apps/client/src/features/control/message/TimerPreview.module.scss +++ b/apps/client/src/features/control/message/TimerPreview.module.scss @@ -27,6 +27,13 @@ border-top: 1px solid $white-7; } +.timerSource { + color: $active-indicator; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + .blackout { display: none; } diff --git a/apps/client/src/features/control/message/TimerPreview.tsx b/apps/client/src/features/control/message/TimerPreview.tsx index 62c7e6709..ff0c867fb 100644 --- a/apps/client/src/features/control/message/TimerPreview.tsx +++ b/apps/client/src/features/control/message/TimerPreview.tsx @@ -1,5 +1,5 @@ import { TimerPhase, TimerType } from 'ontime-types'; -import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5'; +import { IoArrowDown, IoArrowUp, IoBan, IoTime, IoTimerOutline } from 'react-icons/io5'; import { LuArrowDownToLine } from 'react-icons/lu'; import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils'; @@ -20,7 +20,8 @@ const secondarySourceLabels: Record = { }; export default function TimerPreview() { - const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview(); + const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType, usesGroupTimer } = + useMessagePreview(); const { data } = useViewSettings(); const main = (() => { @@ -35,7 +36,9 @@ export default function TimerPreview() { const secondary = (() => { // message is a fullscreen overlay or secondary is not active - if (showTimerMessage || !secondarySource) return null; + if (showTimerMessage) return null; + if (usesGroupTimer) return 'Event timer'; + if (!secondarySource) return null; // we need to check aux first since it takes priority return secondarySourceLabels[secondarySource]; @@ -55,6 +58,7 @@ export default function TimerPreview() {
handleLinks('timer', event)} pipElement={} />
+ {usesGroupTimer &&
Group timer
}
{secondary}
}
+ } + className={style.statusIcon} + data-active={usesGroupTimer} + > + + } diff --git a/apps/client/src/features/rundown/entry-editor/EntryEditor.module.scss b/apps/client/src/features/rundown/entry-editor/EntryEditor.module.scss index 1cf8d1328..9be48e4eb 100644 --- a/apps/client/src/features/rundown/entry-editor/EntryEditor.module.scss +++ b/apps/client/src/features/rundown/entry-editor/EntryEditor.module.scss @@ -32,6 +32,12 @@ gap: 1rem; } +.timerDisplaySettings { + display: flex; + flex-direction: column; + gap: 1rem; +} + .column { display: flex; flex-direction: column; diff --git a/apps/client/src/features/rundown/entry-editor/GroupEditor.tsx b/apps/client/src/features/rundown/entry-editor/GroupEditor.tsx index 94b4e831e..bf96c2e5d 100644 --- a/apps/client/src/features/rundown/entry-editor/GroupEditor.tsx +++ b/apps/client/src/features/rundown/entry-editor/GroupEditor.tsx @@ -1,10 +1,12 @@ -import { MaybeNumber, OntimeGroup } from 'ontime-types'; +import { MaybeNumber, OntimeGroup, TimerType } from 'ontime-types'; import { millisToString } from 'ontime-utils'; import { useCallback } from 'react'; import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect'; import AppLink from '../../../common/components/link/app-link/AppLink'; +import Select from '../../../common/components/select/Select'; +import Switch from '../../../common/components/switch/Switch'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import useCustomFields from '../../../common/hooks-query/useCustomFields'; import { getOffsetState } from '../../../common/utils/offset'; @@ -107,6 +109,38 @@ export default function GroupEditor({ group }: GroupEditorProps) {
+
+ Timer display +
+
+ Use group timer + + updateEntry({ id: group.id, useGroupTimer })} + /> + {group.useGroupTimer ? 'On' : 'Off'} + +
+
+ Timer type +