mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 636b2e7c3e |
@@ -0,0 +1,52 @@
|
||||
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]);
|
||||
}
|
||||
@@ -154,6 +154,13 @@ 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,
|
||||
}));
|
||||
|
||||
export const useNextFlag = createSelector((state: RuntimeStore) => ({
|
||||
id: state.eventFlag?.id ?? null,
|
||||
expectedStart: state.offset.expectedFlagStart,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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> = {}): 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<EntryId, OntimeEntry> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
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<EntryId, OntimeEntry | undefined>,
|
||||
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;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { TimerPhase, TimerType } from 'ontime-types';
|
||||
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
|
||||
import { IoArrowDown, IoArrowUp, IoBan, IoFolderOutline, IoTime } from 'react-icons/io5';
|
||||
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 { handleLinks } from '../../../common/utils/linkUtils';
|
||||
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
|
||||
@@ -22,6 +23,7 @@ const secondarySourceLabels: Record<string, string> = {
|
||||
export default function TimerPreview() {
|
||||
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
|
||||
const { data } = useViewSettings();
|
||||
const { isActive: isGroupTimerActive } = useGroupTimer();
|
||||
|
||||
const main = (() => {
|
||||
if (showTimerMessage) return 'Message';
|
||||
@@ -105,6 +107,14 @@ export default function TimerPreview() {
|
||||
>
|
||||
<LuArrowDownToLine />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
text={isGroupTimerActive ? 'Views are showing the shared group timer' : 'Views are showing the event timer'}
|
||||
render={<span />}
|
||||
className={style.statusIcon}
|
||||
data-active={isGroupTimerActive}
|
||||
>
|
||||
<IoFolderOutline />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ 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 Switch from '../../../common/components/switch/Switch';
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||
import { getOffsetState } from '../../../common/utils/offset';
|
||||
@@ -95,6 +97,22 @@ export default function GroupEditor({ group }: GroupEditorProps) {
|
||||
submitHandler={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip
|
||||
text='Timer views count the scheduled duration of the whole group instead of the running event'
|
||||
render={<Editor.Label htmlFor='useGroupTimer' />}
|
||||
>
|
||||
Group timer
|
||||
</Tooltip>
|
||||
<Editor.Label className={style.switchLabel}>
|
||||
<Switch
|
||||
id='useGroupTimer'
|
||||
checked={group.useGroupTimer}
|
||||
onCheckedChange={(value) => updateEntry({ id: group.id, useGroupTimer: value })}
|
||||
/>
|
||||
{group.useGroupTimer ? 'On' : 'Off'}
|
||||
</Editor.Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.column}>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
|
||||
import IconButton from '../../../common/components/buttons/IconButton';
|
||||
import Tag from '../../../common/components/tag/Tag';
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||
@@ -189,6 +190,13 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
|
||||
</div>
|
||||
</div>
|
||||
{data.useGroupTimer && (
|
||||
<div className={style.metaEntry}>
|
||||
<Tooltip text='Views show a shared timer for this group instead of the event timer'>
|
||||
<Tag>Group timer</Tag>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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() {
|
||||
@@ -55,7 +57,10 @@ export default function PipTimerHost() {
|
||||
|
||||
pipRoot.render(
|
||||
<ErrorBoundary>
|
||||
<PipTimer viewSettings={data} />
|
||||
{/* the PiP document is a separate react root, it needs its own provider to reach the query cache */}
|
||||
<QueryClientProvider client={ontimeQueryClient}>
|
||||
<PipTimer viewSettings={data} />
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -68,6 +68,18 @@
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// signals that the displayed timer belongs to the group, not to the running event
|
||||
.group-indicator {
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.25em;
|
||||
font-weight: 600;
|
||||
font-size: 2.5vw;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
color: var(--timer-colour, $ui-white);
|
||||
}
|
||||
}
|
||||
|
||||
.secondary {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
import { TimerPhase, 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 { cx } from '../../../common/utils/styleUtils';
|
||||
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
|
||||
@@ -24,23 +25,30 @@ interface PipTimerProps {
|
||||
|
||||
export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
|
||||
const groupTimer = useGroupTimer();
|
||||
|
||||
// gather modifiers
|
||||
const showOverlay = getShowMessage(message.timer);
|
||||
const { showFinished, showWarning, showDanger } = getShowModifiers(
|
||||
timerTypeNow,
|
||||
countToEndNow,
|
||||
time.phase,
|
||||
false,
|
||||
'',
|
||||
false,
|
||||
);
|
||||
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 isPlaying = getIsPlaying(time.playback);
|
||||
const showProgressBar = getShowProgressBar(timerTypeNow);
|
||||
|
||||
// gather timer data
|
||||
const totalTime = getTotalTime(time.duration, time.addedTime);
|
||||
const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow);
|
||||
const totalTime = groupTimer.isActive ? groupTimer.duration : getTotalTime(time.duration, time.addedTime);
|
||||
const stageTimer = getTimerByType(false, timerTypeNow, clock, groupTimer.isActive ? groupTimer : time, timerTypeNow);
|
||||
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
|
||||
removeSeconds: false,
|
||||
removeLeadingZero: false,
|
||||
@@ -80,10 +88,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||
<div
|
||||
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
|
||||
style={{ fontSize: `${timerFontSize}vw` }}
|
||||
data-phase={time.phase}
|
||||
data-phase={groupTimer.isActive ? TimerPhase.Default : time.phase}
|
||||
>
|
||||
{display}
|
||||
</div>
|
||||
{groupTimer.isActive && <div className='group-indicator'>group</div>}
|
||||
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
|
||||
<FitText mode='multi' min={12} max={256}>
|
||||
{secondaryContent}
|
||||
@@ -94,12 +103,12 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||
{showProgressBar && (
|
||||
<MultiPartProgressBar
|
||||
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
||||
now={time.current}
|
||||
now={groupTimer.isActive ? groupTimer.current : time.current}
|
||||
complete={totalTime}
|
||||
normalColor={viewSettings.normalColor}
|
||||
warning={eventNow?.timeWarning}
|
||||
warning={groupTimer.isActive ? undefined : eventNow?.timeWarning}
|
||||
warningColor={viewSettings.warningColor}
|
||||
danger={eventNow?.timeDanger}
|
||||
danger={groupTimer.isActive ? undefined : eventNow?.timeDanger}
|
||||
dangerColor={viewSettings.dangerColor}
|
||||
hideOvertime={!showFinished}
|
||||
/>
|
||||
|
||||
@@ -131,6 +131,18 @@
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// signals that the displayed timer belongs to the group, not to the running event
|
||||
.group-indicator {
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.25em;
|
||||
font-weight: 600;
|
||||
font-size: 1.5vw;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
color: var(--timer-colour, var(--timer-color-override, $ui-white));
|
||||
}
|
||||
}
|
||||
|
||||
.secondary {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MaybeString, OntimeView, TimerType } from 'ontime-types';
|
||||
import { MaybeString, OntimeView, TimerPhase, TimerType } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { FitText } from '../../common/components/fit-text/FitText';
|
||||
@@ -8,6 +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 { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
@@ -72,20 +73,28 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
timeformat,
|
||||
} = useTimerOptions();
|
||||
|
||||
const groupTimer = useGroupTimer();
|
||||
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const localisedMinutes = getLocalizedString('common.minutes');
|
||||
|
||||
// gather modifiers
|
||||
const viewTimerType = timerType ?? timerTypeNow;
|
||||
const showOverlay = getShowMessage(message.timer);
|
||||
const { showEndMessage, showFinished, showWarning, showDanger } = getShowModifiers(
|
||||
timerTypeNow,
|
||||
countToEndNow,
|
||||
time.phase,
|
||||
freezeOvertime,
|
||||
freezeMessage,
|
||||
hidePhase,
|
||||
);
|
||||
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 isPlaying = getIsPlaying(time.playback);
|
||||
const showClock = !hideClock && getShowClock(viewTimerType);
|
||||
const showProgressBar = !hideProgress && getShowProgressBar(viewTimerType);
|
||||
@@ -102,8 +111,9 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
);
|
||||
|
||||
// gather timer data
|
||||
const totalTime = getTotalTime(time.duration, time.addedTime);
|
||||
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, time, timerType);
|
||||
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 display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
|
||||
removeSeconds: hideTimerSeconds,
|
||||
removeLeadingZero: removeLeadingZeros,
|
||||
@@ -178,11 +188,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={time.phase}
|
||||
data-phase={groupTimer.isActive ? TimerPhase.Default : time.phase}
|
||||
>
|
||||
{display}
|
||||
</div>
|
||||
)}
|
||||
{groupTimer.isActive && !showEndMessage && <div className='group-indicator'>group</div>}
|
||||
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
|
||||
<FitText mode='multi' min={64} max={256}>
|
||||
{secondaryContent}
|
||||
@@ -193,12 +204,12 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
{showProgressBar && (
|
||||
<MultiPartProgressBar
|
||||
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
||||
now={time.current}
|
||||
now={groupTimer.isActive ? groupTimer.current : time.current}
|
||||
complete={totalTime}
|
||||
normalColor={viewSettings.normalColor}
|
||||
warning={eventNow?.timeWarning}
|
||||
warning={groupTimer.isActive ? undefined : eventNow?.timeWarning}
|
||||
warningColor={viewSettings.warningColor}
|
||||
danger={eventNow?.timeDanger}
|
||||
danger={groupTimer.isActive ? undefined : eventNow?.timeDanger}
|
||||
dangerColor={viewSettings.dangerColor}
|
||||
hideOvertime={!showFinished}
|
||||
/>
|
||||
|
||||
@@ -411,6 +411,7 @@ export function migrateRundown(
|
||||
targetDuration: null,
|
||||
colour: '', //leave default colour
|
||||
custom: {}, // leave empty
|
||||
useGroupTimer: false, // opt-in feature, off for migrated projects
|
||||
// !==== RUNTIME METADATA ====! //
|
||||
revision: -1,
|
||||
timeStart: null,
|
||||
|
||||
@@ -316,6 +316,7 @@ describe('v3 to v4', () => {
|
||||
targetDuration: null,
|
||||
timeEnd: null,
|
||||
timeStart: null,
|
||||
useGroupTimer: false,
|
||||
},
|
||||
event2: {
|
||||
id: 'event2',
|
||||
@@ -392,6 +393,7 @@ describe('v3 to v4', () => {
|
||||
targetDuration: null,
|
||||
timeEnd: null,
|
||||
timeStart: null,
|
||||
useGroupTimer: false,
|
||||
},
|
||||
delay: {
|
||||
type: SupportedEntry.Delay,
|
||||
|
||||
@@ -301,6 +301,23 @@ describe('parseRundown()', () => {
|
||||
expect(parsedRundown.entries.group).toMatchObject({ entries: ['1', '2'] });
|
||||
expect(Object.keys(parsedRundown.entries).length).toEqual(3);
|
||||
});
|
||||
|
||||
it('normalises groups from projects made before the group timer existed', () => {
|
||||
const rundown = {
|
||||
id: 'test',
|
||||
title: '',
|
||||
order: ['group'],
|
||||
flatOrder: ['group'],
|
||||
entries: {
|
||||
// a group as it would have been persisted by a previous version
|
||||
group: { id: 'group', type: SupportedEntry.Group, title: 'legacy', entries: [] },
|
||||
},
|
||||
revision: 1,
|
||||
} as unknown as Rundown;
|
||||
|
||||
const parsedRundown = parseRundown(rundown, {});
|
||||
expect(parsedRundown.entries.group).toMatchObject({ id: 'group', useGroupTimer: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitiseCustomFields()', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } fr
|
||||
import { parseRundown } from '../rundown.parser.js';
|
||||
import {
|
||||
calculateDayOffset,
|
||||
createGroupPatch,
|
||||
deleteById,
|
||||
doesInvalidateMetadata,
|
||||
getIntegerAndFraction,
|
||||
@@ -610,3 +611,25 @@ describe('isLoadedPlayable()', () => {
|
||||
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGroupPatch()', () => {
|
||||
it('preserves useGroupTimer when it is not part of the patch', () => {
|
||||
const original = makeOntimeGroup({ id: 'group', useGroupTimer: true });
|
||||
const patched = createGroupPatch(original, { title: 'Morning sessions' });
|
||||
|
||||
expect(patched.title).toBe('Morning sessions');
|
||||
expect(patched.useGroupTimer).toBe(true);
|
||||
});
|
||||
|
||||
it('updates useGroupTimer when it is part of the patch', () => {
|
||||
const original = makeOntimeGroup({ id: 'group', useGroupTimer: false });
|
||||
expect(createGroupPatch(original, { useGroupTimer: true }).useGroupTimer).toBe(true);
|
||||
expect(createGroupPatch(original, { useGroupTimer: false }).useGroupTimer).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores non boolean values for useGroupTimer', () => {
|
||||
const original = makeOntimeGroup({ id: 'group', useGroupTimer: true });
|
||||
// @ts-expect-error -- testing a value coming from an unvalidated request
|
||||
expect(createGroupPatch(original, { useGroupTimer: 'nope' }).useGroupTimer).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,6 +179,8 @@ export function createGroupPatch(originalGroup: OntimeGroup, patchGroup: Partial
|
||||
entries: patchGroup.entries ?? originalGroup.entries,
|
||||
targetDuration: maybeTargetDuration(),
|
||||
colour: makeString(patchGroup.colour, originalGroup.colour),
|
||||
useGroupTimer:
|
||||
typeof patchGroup.useGroupTimer === 'boolean' ? patchGroup.useGroupTimer : originalGroup.useGroupTimer,
|
||||
revision: originalGroup.revision,
|
||||
timeStart: originalGroup.timeStart,
|
||||
timeEnd: originalGroup.timeEnd,
|
||||
|
||||
@@ -147,6 +147,7 @@ There are four entry types discriminated by \`type\`:
|
||||
note: string
|
||||
entries: EntryId[]
|
||||
targetDuration: number | null
|
||||
useGroupTimer: boolean // views show a shared timer for the whole group
|
||||
custom: { [key: string]: string }
|
||||
timeStart: number | null // calculated from nested entries (runtime)
|
||||
timeEnd: number | null // calculated from nested entries (runtime)
|
||||
|
||||
@@ -39,6 +39,7 @@ export const stageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -172,6 +173,7 @@ export const stageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['0aaa7d'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#3E75E8',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -219,6 +221,7 @@ export const stageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -360,6 +363,7 @@ export const backstageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#A790F5',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -476,6 +480,7 @@ export const backstageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -588,6 +593,7 @@ export const backstageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['bs0301'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#3E75E8',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -634,6 +640,7 @@ export const backstageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -768,6 +775,7 @@ export const broadcastRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['br0101', 'br0102', 'br0103'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#ED3333',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -851,6 +859,7 @@ export const broadcastRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['br0201', 'br0202', 'br0203', 'br0204'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -959,6 +968,7 @@ export const broadcastRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['br0301', 'br0302'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#3E75E8',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -1029,6 +1039,7 @@ export const broadcastRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['br0401', 'br0402', 'br0403'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
|
||||
@@ -46,6 +46,8 @@ export type OntimeGroup = OntimeBaseEvent & {
|
||||
targetDuration: MaybeNumber;
|
||||
colour: string;
|
||||
custom: EntryCustomFields;
|
||||
/** whether views should display a shared timer for the group instead of the event timer */
|
||||
useGroupTimer: boolean;
|
||||
// !==== RUNTIME METADATA ====! //
|
||||
revision: number;
|
||||
timeStart: MaybeNumber; // calculated at runtime
|
||||
|
||||
@@ -54,6 +54,7 @@ export const group: Omit<OntimeGroup, 'id'> = {
|
||||
targetDuration: null,
|
||||
colour: '',
|
||||
custom: {},
|
||||
useGroupTimer: false,
|
||||
// !==== RUNTIME METADATA ====! //
|
||||
revision: 0, // calculated at runtime
|
||||
timeStart: null, // calculated at runtime
|
||||
|
||||
@@ -69,6 +69,7 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
|
||||
targetDuration: patch.targetDuration ?? null,
|
||||
colour: makeString(patch.colour, ''),
|
||||
custom: patch.custom ?? {},
|
||||
useGroupTimer: typeof patch.useGroupTimer === 'boolean' ? patch.useGroupTimer : false,
|
||||
revision: 0,
|
||||
timeStart: null,
|
||||
timeEnd: null,
|
||||
|
||||
Reference in New Issue
Block a user