mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-06 14:59:09 +00:00
feat: count to group completion
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
.timerOverrideCell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 1.5rem;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.timerOverrideIndicator {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: $active-indicator;
|
||||
font-size: 1rem;
|
||||
}
|
||||
+39
-6
@@ -1,8 +1,18 @@
|
||||
import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types';
|
||||
import {
|
||||
CustomFields,
|
||||
TimeStrategy,
|
||||
TimerType,
|
||||
URLPreset,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
} from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { useCallback } from 'react';
|
||||
import { IoTimerOutline } from 'react-icons/io5';
|
||||
|
||||
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
||||
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
@@ -17,6 +27,8 @@ import MutedText from './MutedText';
|
||||
import SingleLineCell from './SingleLineCell';
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
import style from './cuesheetColsFactory.module.scss';
|
||||
|
||||
function getColumnLabel(column: CuesheetCellContext['column']): string {
|
||||
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
|
||||
}
|
||||
@@ -193,17 +205,38 @@ function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
|
||||
}
|
||||
|
||||
const canWrite = column.columnDef.meta?.canWrite;
|
||||
if (!canWrite) {
|
||||
return <GhostedText>{initialValue}</GhostedText>;
|
||||
}
|
||||
|
||||
return (
|
||||
const content = canWrite ? (
|
||||
<SingleLineCell
|
||||
initialValue={initialValue as string}
|
||||
fieldId={column.id}
|
||||
fieldLabel={getColumnLabel(column)}
|
||||
handleUpdate={update}
|
||||
/>
|
||||
) : (
|
||||
<GhostedText>{initialValue}</GhostedText>
|
||||
);
|
||||
|
||||
if (column.id !== 'title') {
|
||||
return content;
|
||||
}
|
||||
|
||||
const isGroupOverride = isOntimeGroup(row.original) && row.original.useGroupTimer;
|
||||
const isEventOverride = isOntimeEvent(row.original) && row.original.groupUsesTimer;
|
||||
if (!isGroupOverride && !isEventOverride) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const timerType = isOntimeGroup(row.original) ? row.original.timerType : row.original.groupTimerType;
|
||||
const direction = timerType === TimerType.CountUp ? 'count up' : 'count down';
|
||||
const tooltip = isGroupOverride ? `Group timer (${direction})` : `Timer display controlled by group (${direction})`;
|
||||
|
||||
return (
|
||||
<div className={style.timerOverrideCell}>
|
||||
{content}
|
||||
<Tooltip text={tooltip} render={<span className={style.timerOverrideIndicator} />}>
|
||||
<IoTimerOutline />
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,14 @@
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.timer-source {
|
||||
color: $viewer-label-color;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.timer {
|
||||
opacity: 1;
|
||||
font-family: $viewer-font-family;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cx } from '../../../common/utils/styleUtils';
|
||||
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
|
||||
import {
|
||||
getEstimatedFontSize,
|
||||
getEventTimerSecondary,
|
||||
getIsPlaying,
|
||||
getSecondaryDisplay,
|
||||
getShowMessage,
|
||||
@@ -23,7 +24,18 @@ interface PipTimerProps {
|
||||
}
|
||||
|
||||
export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
|
||||
const {
|
||||
eventNow,
|
||||
message,
|
||||
time,
|
||||
eventTimer,
|
||||
clock,
|
||||
timerTypeNow,
|
||||
eventTimerType,
|
||||
countToEndNow,
|
||||
usesGroupTimer,
|
||||
auxTimer,
|
||||
} = useTimerSocket();
|
||||
|
||||
// gather modifiers
|
||||
const showOverlay = getShowMessage(message.timer);
|
||||
@@ -59,7 +71,9 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||
return null;
|
||||
})();
|
||||
|
||||
const secondaryContent = getSecondaryDisplay(message, currentAux, 'min', false, true, false);
|
||||
const secondaryContent = usesGroupTimer
|
||||
? getEventTimerSecondary(eventTimer, eventTimerType, clock, 'min', false, true)
|
||||
: getSecondaryDisplay(message, currentAux, 'min', false, true, false);
|
||||
|
||||
// gather presentation styles
|
||||
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
|
||||
@@ -77,6 +91,7 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||
</div>
|
||||
|
||||
<div className='timer-container'>
|
||||
{usesGroupTimer && <div className='timer-source'>Group timer</div>}
|
||||
<div
|
||||
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
|
||||
style={{ fontSize: `${timerFontSize}vw` }}
|
||||
@@ -96,11 +111,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
||||
now={time.current}
|
||||
complete={totalTime}
|
||||
eventId={eventNow?.id}
|
||||
eventId={usesGroupTimer ? undefined : eventNow?.id}
|
||||
normalColor={viewSettings.normalColor}
|
||||
warning={eventNow?.timeWarning}
|
||||
warning={usesGroupTimer ? undefined : eventNow?.timeWarning}
|
||||
warningColor={viewSettings.warningColor}
|
||||
danger={eventNow?.timeDanger}
|
||||
danger={usesGroupTimer ? undefined : eventNow?.timeDanger}
|
||||
dangerColor={viewSettings.dangerColor}
|
||||
hideOvertime={!showFinished}
|
||||
/>
|
||||
|
||||
@@ -89,6 +89,14 @@
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.timer-source {
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
font-size: $timer-label-size;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.end-message {
|
||||
text-align: center;
|
||||
font-size: 11.5vw;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getTimerOptions, useTimerOptions } from './timer.options';
|
||||
import {
|
||||
getCardData,
|
||||
getEstimatedFontSize,
|
||||
getEventTimerSecondary,
|
||||
getIsPlaying,
|
||||
getSecondaryDisplay,
|
||||
getShowClock,
|
||||
@@ -52,7 +53,19 @@ export default function TimerLoader() {
|
||||
}
|
||||
|
||||
function Timer({ customFields, projectData, isMirrored, settings, viewSettings, entries }: TimerData) {
|
||||
const { eventNext, eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
|
||||
const {
|
||||
eventNext,
|
||||
eventNow,
|
||||
message,
|
||||
time,
|
||||
eventTimer,
|
||||
clock,
|
||||
timerTypeNow,
|
||||
eventTimerType,
|
||||
countToEndNow,
|
||||
usesGroupTimer,
|
||||
auxTimer,
|
||||
} = useTimerSocket();
|
||||
const {
|
||||
hideClock,
|
||||
hideCards,
|
||||
@@ -78,7 +91,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const localisedMinutes = getLocalizedString('common.minutes');
|
||||
|
||||
const showSoundPrompt = useTimerSound(time.phase, endSound);
|
||||
const showSoundPrompt = useTimerSound(eventTimer.phase, endSound);
|
||||
|
||||
// gather modifiers
|
||||
const viewTimerType = timerType ?? timerTypeNow;
|
||||
@@ -128,14 +141,18 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
return null;
|
||||
})();
|
||||
|
||||
const secondaryContent = getSecondaryDisplay(
|
||||
message,
|
||||
currentAux,
|
||||
localisedMinutes,
|
||||
hideTimerSeconds,
|
||||
removeLeadingZeros,
|
||||
hideSecondary,
|
||||
);
|
||||
const secondaryContent =
|
||||
usesGroupTimer && !hideSecondary
|
||||
? getEventTimerSecondary(
|
||||
eventTimer,
|
||||
eventTimerType,
|
||||
clock,
|
||||
localisedMinutes,
|
||||
hideTimerSeconds,
|
||||
removeLeadingZeros,
|
||||
timeformat,
|
||||
)
|
||||
: getSecondaryDisplay(message, currentAux, localisedMinutes, hideTimerSeconds, removeLeadingZeros, hideSecondary);
|
||||
|
||||
// gather presentation styles
|
||||
const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
|
||||
@@ -176,6 +193,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
{showClock && <TimerAutoTickingClock clockFormat={timeformat} />}
|
||||
|
||||
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
|
||||
{usesGroupTimer && <div className='timer-source'>Group timer</div>}
|
||||
{showEndMessage ? (
|
||||
<FitText mode='multi' min={64} max={256} className='end-message'>
|
||||
{freezeMessage}
|
||||
@@ -202,11 +220,11 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
||||
now={time.current}
|
||||
complete={totalTime}
|
||||
eventId={eventNow?.id}
|
||||
eventId={usesGroupTimer ? undefined : eventNow?.id}
|
||||
normalColor={viewSettings.normalColor}
|
||||
warning={eventNow?.timeWarning}
|
||||
warning={usesGroupTimer ? undefined : eventNow?.timeWarning}
|
||||
warningColor={viewSettings.warningColor}
|
||||
danger={eventNow?.timeDanger}
|
||||
danger={usesGroupTimer ? undefined : eventNow?.timeDanger}
|
||||
dangerColor={viewSettings.dangerColor}
|
||||
hideOvertime={!showFinished}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
import { TimerPhase } from 'ontime-types';
|
||||
import { TimerPhase, TimerType } from 'ontime-types';
|
||||
|
||||
import { shouldPlayEndSound } from '../timer.utils';
|
||||
import { getEventTimerSecondary, shouldPlayEndSound } from '../timer.utils';
|
||||
|
||||
describe('getEventTimerSecondary()', () => {
|
||||
it('formats the event countdown as a labelled secondary value', () => {
|
||||
expect(
|
||||
getEventTimerSecondary({ current: 65_000, elapsed: 5_000 }, TimerType.CountDown, 0, 'min', false, false),
|
||||
).toBe('Event timer 00:01:05');
|
||||
});
|
||||
|
||||
it('preserves the event count-up display', () => {
|
||||
expect(getEventTimerSecondary({ current: 55_000, elapsed: 5_000 }, TimerType.CountUp, 0, 'min', false, false)).toBe(
|
||||
'Event timer 00:00:05',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to remaining time when the event timer is hidden', () => {
|
||||
expect(getEventTimerSecondary({ current: 5_000, elapsed: 55_000 }, TimerType.None, 0, 'min', false, false)).toBe(
|
||||
'Event timer 00:00:05',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows event progress instead of wall-clock time for clock events', () => {
|
||||
expect(
|
||||
getEventTimerSecondary({ current: 5_000, elapsed: 55_000 }, TimerType.Clock, 12_000, 'min', false, false),
|
||||
).toBe('Event timer 00:00:05');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldPlayEndSound()', () => {
|
||||
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
|
||||
|
||||
@@ -6,11 +6,12 @@ import {
|
||||
RundownEntries,
|
||||
TimerMessage,
|
||||
TimerPhase,
|
||||
TimerState,
|
||||
TimerType,
|
||||
} from 'ontime-types';
|
||||
import { isPlaybackActive } from 'ontime-utils';
|
||||
|
||||
import { getFormattedTimer, getPropertyValue } from '../common/viewUtils';
|
||||
import { getFormattedTimer, getPropertyValue, getTimerByType } from '../common/viewUtils';
|
||||
|
||||
/**
|
||||
* Whether a message should be shown
|
||||
@@ -144,6 +145,25 @@ export function getSecondaryDisplay(
|
||||
return;
|
||||
}
|
||||
|
||||
export function getEventTimerSecondary(
|
||||
timer: Pick<TimerState, 'current' | 'elapsed'>,
|
||||
timerType: TimerType,
|
||||
clock: number,
|
||||
localisedMinutes: string,
|
||||
removeSeconds: boolean,
|
||||
removeLeadingZero: boolean,
|
||||
clockFormat?: string | null,
|
||||
): string {
|
||||
const effectiveType = timerType === TimerType.CountUp ? TimerType.CountUp : TimerType.CountDown;
|
||||
const value = getTimerByType(false, effectiveType, clock, timer);
|
||||
const display = getFormattedTimer(value, effectiveType, localisedMinutes, {
|
||||
removeSeconds,
|
||||
removeLeadingZero,
|
||||
clockFormat,
|
||||
});
|
||||
return `Event timer ${display}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What should we be showing in the cards?
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user