mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-04 15:08:01 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3d9fe0157 |
@@ -9,6 +9,7 @@ import './MultiPartProgressBar.scss';
|
||||
interface MultiPartProgressBar {
|
||||
now: MaybeNumber;
|
||||
complete: MaybeNumber;
|
||||
eventId?: string | null;
|
||||
normalColor: string;
|
||||
warning?: MaybeNumber;
|
||||
warningColor: string;
|
||||
@@ -24,6 +25,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
const {
|
||||
now,
|
||||
complete,
|
||||
eventId,
|
||||
normalColor,
|
||||
warning,
|
||||
warningColor,
|
||||
@@ -35,7 +37,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
className = '',
|
||||
} = props;
|
||||
|
||||
const percentRemaining = 100 - useAnimatedProgress(now, complete);
|
||||
const percentRemaining = 100 - useAnimatedProgress(now, complete, eventId);
|
||||
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
|
||||
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
|
||||
const isOvertime = now !== null && now < 0;
|
||||
|
||||
@@ -7,12 +7,12 @@ import './ProgressBar.scss';
|
||||
interface ProgressBarProps {
|
||||
current: MaybeNumber;
|
||||
duration: MaybeNumber;
|
||||
eventId?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ProgressBar(props: ProgressBarProps) {
|
||||
const { current, duration, className } = props;
|
||||
const progress = useAnimatedProgress(current, duration);
|
||||
export default function ProgressBar({ current, duration, eventId, className }: ProgressBarProps) {
|
||||
const progress = useAnimatedProgress(current, duration, eventId);
|
||||
|
||||
return (
|
||||
<div className={`progress-bar__bg ${className}`}>
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import { MaybeNumber, Playback } from 'ontime-types';
|
||||
import { EntryId, MaybeNumber, Playback } from 'ontime-types';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getProgress } from '../utils/getProgress';
|
||||
import { usePlayback } from './useSocket';
|
||||
import { useIsOnline, usePlayback } from './useSocket';
|
||||
|
||||
/**
|
||||
* Returns the live completion percentage (0–100) of a countdown, interpolated locally.
|
||||
*/
|
||||
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber): number {
|
||||
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber, eventId?: EntryId | null): number {
|
||||
const playback = usePlayback();
|
||||
const isOnline = useIsOnline();
|
||||
const isRunning = playback === Playback.Play || playback === Playback.Roll;
|
||||
|
||||
const baseline = useRef({ current, at: performance.now() });
|
||||
const baseline = useRef({ current, duration, eventId, playback, at: performance.now() });
|
||||
const [, setTick] = useState(0);
|
||||
const now = performance.now();
|
||||
|
||||
// there is only something to animate while a running timer is counting down towards 0
|
||||
const shouldAnimate = isRunning && current !== null && current > 0 && duration !== null;
|
||||
const hasAuthoritativeUpdate =
|
||||
baseline.current.current !== current || // handle timer updates
|
||||
baseline.current.duration !== duration || // handle duration changes
|
||||
baseline.current.eventId !== eventId || // handle event changing
|
||||
baseline.current.playback !== playback; // handle playback changes
|
||||
|
||||
// re-anchor to the authoritative value whenever the server pushes a new timer update
|
||||
useEffect(() => {
|
||||
baseline.current = { current, at: performance.now() };
|
||||
}, [current, duration, playback]);
|
||||
if (hasAuthoritativeUpdate) {
|
||||
// Reset during render so an event change is reflected in this very paint.
|
||||
baseline.current = { current, duration, eventId, playback, at: now };
|
||||
}
|
||||
|
||||
// There is only something to animate while a connected timer is counting down towards 0.
|
||||
const shouldAnimate = isOnline && isRunning && current !== null && current > 0 && duration !== null;
|
||||
|
||||
// while counting down, re-render every animation frame so the derived progress stays smooth
|
||||
useEffect(() => {
|
||||
@@ -34,8 +42,8 @@ export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber)
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [shouldAnimate]);
|
||||
|
||||
// derive from the anchor plus elapsed time at render; frozen to the anchor when not running
|
||||
// Derive from the anchor plus elapsed time at render; freeze while disconnected or not running.
|
||||
const anchored = baseline.current.current;
|
||||
const value = isRunning && anchored !== null ? anchored - (performance.now() - baseline.current.at) : anchored;
|
||||
const value = isOnline && isRunning && anchored !== null ? anchored - (now - baseline.current.at) : anchored;
|
||||
return getProgress(value, duration);
|
||||
}
|
||||
|
||||
@@ -154,9 +154,6 @@ export const useTimer = createSelector((state: RuntimeStore) => ({
|
||||
...state.timer,
|
||||
}));
|
||||
|
||||
/** 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,
|
||||
expectedStart: state.offset.expectedFlagStart,
|
||||
@@ -166,6 +163,7 @@ export const useNextFlag = createSelector((state: RuntimeStore) => ({
|
||||
export const useProgressData = createSelector((state: RuntimeStore) => ({
|
||||
current: state.timer.current,
|
||||
duration: state.timer.duration,
|
||||
eventId: state.eventNow?.id ?? null,
|
||||
timeWarning: state.eventNow?.timeWarning ?? null,
|
||||
timeDanger: state.eventNow?.timeDanger ?? null,
|
||||
}));
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { TimerPhase, TimerType } from 'ontime-types';
|
||||
import { IoArrowDown, IoArrowUp, IoBan, IoFolderOutline, IoTime } from 'react-icons/io5';
|
||||
import { IoArrowDown, IoArrowUp, IoBan, 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, useMessagePreview } from '../../../common/hooks/useSocket';
|
||||
import { 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';
|
||||
@@ -22,7 +22,6 @@ const secondarySourceLabels: Record<string, string> = {
|
||||
export default function TimerPreview() {
|
||||
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
|
||||
const { data } = useViewSettings();
|
||||
const isGroupTimerActive = useGroupTimer() !== null;
|
||||
|
||||
const main = (() => {
|
||||
if (showTimerMessage) return 'Message';
|
||||
@@ -106,14 +105,6 @@ 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>
|
||||
);
|
||||
|
||||
@@ -10,12 +10,13 @@ interface StatusBarProgressProps {
|
||||
}
|
||||
|
||||
export default function StatusBarProgress({ viewSettings }: StatusBarProgressProps) {
|
||||
const { current, duration, timeWarning, timeDanger } = useProgressData();
|
||||
const { current, duration, eventId, timeWarning, timeDanger } = useProgressData();
|
||||
|
||||
return (
|
||||
<MultiPartProgressBar
|
||||
now={current}
|
||||
complete={duration}
|
||||
eventId={eventId}
|
||||
normalColor={viewSettings.normalColor}
|
||||
warning={timeWarning}
|
||||
warningColor={viewSettings.warningColor}
|
||||
|
||||
@@ -5,8 +5,6 @@ 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';
|
||||
@@ -97,22 +95,6 @@ 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}>
|
||||
|
||||
+3
-2
@@ -1,12 +1,13 @@
|
||||
import { useAnimatedProgress } from '../../../../common/hooks/useAnimatedProgress';
|
||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||
import { useSelectedEventId, useTimer } from '../../../../common/hooks/useSocket';
|
||||
|
||||
import style from './RundownEventProgressBar.module.scss';
|
||||
|
||||
export default function RundownEventProgressBar() {
|
||||
const timer = useTimer();
|
||||
const eventId = useSelectedEventId();
|
||||
|
||||
const progress = useAnimatedProgress(timer.current, timer.duration);
|
||||
const progress = useAnimatedProgress(timer.current, timer.duration, eventId);
|
||||
|
||||
return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ 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';
|
||||
@@ -190,13 +189,6 @@ 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>
|
||||
|
||||
@@ -112,7 +112,14 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
|
||||
<BackstageClock timeformat={timeformat} />
|
||||
</div>
|
||||
|
||||
{showProgress && <ProgressBar className='progress-container' current={time.current} duration={time.duration} />}
|
||||
{showProgress && (
|
||||
<ProgressBar
|
||||
className='progress-container'
|
||||
current={time.current}
|
||||
duration={time.duration}
|
||||
eventId={selectedEventId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!hasEvents && <Empty text={getLocalizedString('common.no_data')} className='empty-container' />}
|
||||
|
||||
|
||||
@@ -6,12 +6,13 @@ import styles from './CuesheetProgress.module.scss';
|
||||
|
||||
export default function CuesheetProgress() {
|
||||
const { data } = useViewSettings();
|
||||
const { current, duration, timeWarning, timeDanger } = useProgressData();
|
||||
const { current, duration, eventId, timeWarning, timeDanger } = useProgressData();
|
||||
|
||||
return (
|
||||
<MultiPartProgressBar
|
||||
now={current}
|
||||
complete={duration}
|
||||
eventId={eventId}
|
||||
normalColor={data.normalColor}
|
||||
warning={timeWarning}
|
||||
warningColor={data.warningColor}
|
||||
|
||||
@@ -68,18 +68,6 @@
|
||||
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 {
|
||||
|
||||
@@ -2,7 +2,7 @@ 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, useTimerSocket } from '../../../common/hooks/useSocket';
|
||||
import { useTimerSocket } from '../../../common/hooks/useSocket';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
|
||||
import {
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
getIsPlaying,
|
||||
getSecondaryDisplay,
|
||||
getShowMessage,
|
||||
getShowModifiers,
|
||||
getShowProgressBar,
|
||||
resolveTimerDisplay,
|
||||
getTotalTime,
|
||||
} from '../../timer/timer.utils';
|
||||
import { getTimerColour } from '../../utils/presentation.utils';
|
||||
|
||||
@@ -23,27 +24,23 @@ 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 timerDisplay = resolveTimerDisplay({
|
||||
time,
|
||||
groupTimer,
|
||||
event: eventNow,
|
||||
timerType: timerTypeNow,
|
||||
countToEnd: countToEndNow,
|
||||
freezeOvertime: false,
|
||||
freezeMessage: '',
|
||||
hidePhase: false,
|
||||
});
|
||||
const { showFinished, showWarning, showDanger } = timerDisplay;
|
||||
|
||||
const { showFinished, showWarning, showDanger } = getShowModifiers(
|
||||
timerTypeNow,
|
||||
countToEndNow,
|
||||
time.phase,
|
||||
false,
|
||||
'',
|
||||
false,
|
||||
);
|
||||
const isPlaying = getIsPlaying(time.playback);
|
||||
const showProgressBar = getShowProgressBar(timerTypeNow);
|
||||
|
||||
// gather timer data
|
||||
const stageTimer = getTimerByType(false, timerTypeNow, clock, timerDisplay.source, timerTypeNow);
|
||||
const totalTime = getTotalTime(time.duration, time.addedTime);
|
||||
const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow);
|
||||
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
|
||||
removeSeconds: false,
|
||||
removeLeadingZero: false,
|
||||
@@ -83,11 +80,10 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||
<div
|
||||
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
|
||||
style={{ fontSize: `${timerFontSize}vw` }}
|
||||
data-phase={timerDisplay.phase}
|
||||
data-phase={time.phase}
|
||||
>
|
||||
{display}
|
||||
</div>
|
||||
{timerDisplay.isGroup && <div className='group-indicator'>group</div>}
|
||||
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
|
||||
<FitText mode='multi' min={12} max={256}>
|
||||
{secondaryContent}
|
||||
@@ -98,12 +94,13 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||
{showProgressBar && (
|
||||
<MultiPartProgressBar
|
||||
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
||||
now={timerDisplay.source.current}
|
||||
complete={timerDisplay.total}
|
||||
now={time.current}
|
||||
complete={totalTime}
|
||||
eventId={eventNow?.id}
|
||||
normalColor={viewSettings.normalColor}
|
||||
warning={timerDisplay.warning}
|
||||
warning={eventNow?.timeWarning}
|
||||
warningColor={viewSettings.warningColor}
|
||||
danger={timerDisplay.danger}
|
||||
danger={eventNow?.timeDanger}
|
||||
dangerColor={viewSettings.dangerColor}
|
||||
hideOvertime={!showFinished}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Day } from 'ontime-types';
|
||||
import { CSSProperties, RefObject } from 'react';
|
||||
|
||||
import { useAnimatedProgress } from '../../common/hooks/useAnimatedProgress';
|
||||
import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
|
||||
import { useExpectedStartData, useSelectedEventId, useTimer } from '../../common/hooks/useSocket';
|
||||
import { alpha, cx } from '../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime, getExpectedTimesFromExtendedEvent } from '../../common/utils/time';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
@@ -169,7 +169,8 @@ function TimelineEntryStatus({
|
||||
/** Generates a block level progress bar */
|
||||
function ActiveBlock() {
|
||||
const { current, duration } = useTimer();
|
||||
const progress = useAnimatedProgress(current, duration);
|
||||
const eventId = useSelectedEventId();
|
||||
const progress = useAnimatedProgress(current, duration, eventId);
|
||||
return (
|
||||
<div data-status='live' className={style.timelineBlock} style={{ '--progress': `${progress}%` } as CSSProperties} />
|
||||
);
|
||||
|
||||
@@ -131,18 +131,6 @@
|
||||
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 {
|
||||
|
||||
@@ -8,7 +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, useTimerSocket } from '../../common/hooks/useSocket';
|
||||
import { useTimerSocket } from '../../common/hooks/useSocket';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import { formatTime, getDefaultFormat } from '../../common/utils/time';
|
||||
@@ -25,8 +25,9 @@ import {
|
||||
getSecondaryDisplay,
|
||||
getShowClock,
|
||||
getShowMessage,
|
||||
getShowModifiers,
|
||||
getShowProgressBar,
|
||||
resolveTimerDisplay,
|
||||
getTotalTime,
|
||||
} from './timer.utils';
|
||||
import { TimerData, useTimerData } from './useTimerData';
|
||||
|
||||
@@ -71,25 +72,20 @@ 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 timerDisplay = resolveTimerDisplay({
|
||||
time,
|
||||
groupTimer,
|
||||
event: eventNow,
|
||||
timerType: timerTypeNow,
|
||||
countToEnd: countToEndNow,
|
||||
const { showEndMessage, showFinished, showWarning, showDanger } = getShowModifiers(
|
||||
timerTypeNow,
|
||||
countToEndNow,
|
||||
time.phase,
|
||||
freezeOvertime,
|
||||
freezeMessage,
|
||||
hidePhase,
|
||||
});
|
||||
const { showEndMessage, showFinished, showWarning, showDanger } = timerDisplay;
|
||||
);
|
||||
const isPlaying = getIsPlaying(time.playback);
|
||||
const showClock = !hideClock && getShowClock(viewTimerType);
|
||||
const showProgressBar = !hideProgress && getShowProgressBar(viewTimerType);
|
||||
@@ -106,7 +102,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
);
|
||||
|
||||
// gather timer data
|
||||
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, timerDisplay.source, timerType);
|
||||
const totalTime = getTotalTime(time.duration, time.addedTime);
|
||||
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, time, timerType);
|
||||
const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
|
||||
removeSeconds: hideTimerSeconds,
|
||||
removeLeadingZero: removeLeadingZeros,
|
||||
@@ -181,12 +178,11 @@ 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={timerDisplay.phase}
|
||||
data-phase={time.phase}
|
||||
>
|
||||
{display}
|
||||
</div>
|
||||
)}
|
||||
{timerDisplay.isGroup && !showEndMessage && <div className='group-indicator'>group</div>}
|
||||
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
|
||||
<FitText mode='multi' min={64} max={256}>
|
||||
{secondaryContent}
|
||||
@@ -197,12 +193,13 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
{showProgressBar && (
|
||||
<MultiPartProgressBar
|
||||
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
||||
now={timerDisplay.source.current}
|
||||
complete={timerDisplay.total}
|
||||
now={time.current}
|
||||
complete={totalTime}
|
||||
eventId={eventNow?.id}
|
||||
normalColor={viewSettings.normalColor}
|
||||
warning={timerDisplay.warning}
|
||||
warning={eventNow?.timeWarning}
|
||||
warningColor={viewSettings.warningColor}
|
||||
danger={timerDisplay.danger}
|
||||
danger={eventNow?.timeDanger}
|
||||
dangerColor={viewSettings.dangerColor}
|
||||
hideOvertime={!showFinished}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
GroupTimerState,
|
||||
MaybeNumber,
|
||||
MessageState,
|
||||
OntimeEvent,
|
||||
@@ -7,7 +6,6 @@ import {
|
||||
RundownEntries,
|
||||
TimerMessage,
|
||||
TimerPhase,
|
||||
TimerState,
|
||||
TimerType,
|
||||
} from 'ontime-types';
|
||||
import { isPlaybackActive } from 'ontime-utils';
|
||||
@@ -116,57 +114,6 @@ export function getShowModifiers(
|
||||
};
|
||||
}
|
||||
|
||||
interface ResolveTimerDisplayOptions {
|
||||
time: TimerState;
|
||||
/** when present, views show the group instead of the running event */
|
||||
groupTimer: GroupTimerState | null;
|
||||
event: Pick<OntimeEvent, 'timeWarning' | 'timeDanger'> | 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
|
||||
*/
|
||||
|
||||
@@ -411,7 +411,6 @@ 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,7 +316,6 @@ describe('v3 to v4', () => {
|
||||
targetDuration: null,
|
||||
timeEnd: null,
|
||||
timeStart: null,
|
||||
useGroupTimer: false,
|
||||
},
|
||||
event2: {
|
||||
id: 'event2',
|
||||
@@ -393,7 +392,6 @@ describe('v3 to v4', () => {
|
||||
targetDuration: null,
|
||||
timeEnd: null,
|
||||
timeStart: null,
|
||||
useGroupTimer: false,
|
||||
},
|
||||
delay: {
|
||||
type: SupportedEntry.Delay,
|
||||
|
||||
@@ -301,23 +301,6 @@ 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,7 +15,6 @@ import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } fr
|
||||
import { parseRundown } from '../rundown.parser.js';
|
||||
import {
|
||||
calculateDayOffset,
|
||||
createGroupPatch,
|
||||
deleteById,
|
||||
doesInvalidateMetadata,
|
||||
getIntegerAndFraction,
|
||||
@@ -611,25 +610,3 @@ 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,8 +179,6 @@ 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,7 +147,6 @@ 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)
|
||||
|
||||
@@ -207,7 +207,6 @@ 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,
|
||||
|
||||
@@ -39,7 +39,6 @@ export const stageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -173,7 +172,6 @@ export const stageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['0aaa7d'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#3E75E8',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -221,7 +219,6 @@ export const stageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -363,7 +360,6 @@ export const backstageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#A790F5',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -480,7 +476,6 @@ export const backstageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -593,7 +588,6 @@ export const backstageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['bs0301'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#3E75E8',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -640,7 +634,6 @@ export const backstageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -775,7 +768,6 @@ export const broadcastRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['br0101', 'br0102', 'br0103'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#ED3333',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -859,7 +851,6 @@ export const broadcastRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['br0201', 'br0202', 'br0203', 'br0204'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -968,7 +959,6 @@ export const broadcastRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['br0301', 'br0302'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#3E75E8',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -1039,7 +1029,6 @@ export const broadcastRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['br0401', 'br0402', 'br0403'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
import {
|
||||
EndAction,
|
||||
MaybeNumber,
|
||||
OntimeEvent,
|
||||
OntimeGroup,
|
||||
Playback,
|
||||
Rundown,
|
||||
SupportedEntry,
|
||||
TimeOfDay,
|
||||
TimeStrategy,
|
||||
TimerPhase,
|
||||
TimerType,
|
||||
} from 'ontime-types';
|
||||
import { EndAction, Playback, 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';
|
||||
@@ -19,8 +7,6 @@ import {
|
||||
getCurrent,
|
||||
getElapsed,
|
||||
getExpectedFinish,
|
||||
getGroupTimer,
|
||||
getGroupTiming,
|
||||
getRuntimeOffset,
|
||||
getTimerPhase,
|
||||
hasCrossedMidnight,
|
||||
@@ -1466,138 +1452,3 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -709,18 +709,6 @@ 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
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
import {
|
||||
Day,
|
||||
EntryId,
|
||||
GroupTimerState,
|
||||
MaybeNumber,
|
||||
OntimeGroup,
|
||||
Rundown,
|
||||
TimeOfDay,
|
||||
TimerPhase,
|
||||
isOntimeEvent,
|
||||
isPlayableEvent,
|
||||
} from 'ontime-types';
|
||||
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
|
||||
|
||||
import type { GroupTiming, RuntimeState } from '../stores/runtimeState.js';
|
||||
import type { RuntimeState } from '../stores/runtimeState.js';
|
||||
|
||||
/**
|
||||
* handle events that span over midnight
|
||||
@@ -242,76 +231,3 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ const baseState: RuntimeState = {
|
||||
eventNext: null,
|
||||
eventFlag: null,
|
||||
groupNow: null,
|
||||
groupTimer: null,
|
||||
_groupTiming: null,
|
||||
rundown: {
|
||||
selectedEventIndex: null,
|
||||
numEvents: 0,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
TimeOfDay,
|
||||
TimerPhase,
|
||||
TimerState,
|
||||
GroupTimerState,
|
||||
isOntimeEvent,
|
||||
runtimeStorePlaceholder,
|
||||
} from 'ontime-types';
|
||||
@@ -40,8 +39,6 @@ import {
|
||||
getCurrent,
|
||||
getElapsed,
|
||||
getExpectedFinish,
|
||||
getGroupTimer,
|
||||
getGroupTiming,
|
||||
getRuntimeOffset,
|
||||
getTimerPhase,
|
||||
hasCrossedMidnight,
|
||||
@@ -54,12 +51,6 @@ 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;
|
||||
@@ -68,8 +59,6 @@ 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: {
|
||||
@@ -83,7 +72,6 @@ export type RuntimeState = {
|
||||
totalDelay: number; // this value comes from rundown service
|
||||
};
|
||||
_group: ExpectedMetadata;
|
||||
_groupTiming: GroupTiming | null;
|
||||
_flag: ExpectedMetadata;
|
||||
_end: ExpectedMetadata;
|
||||
_startEpoch: Maybe<Instant>;
|
||||
@@ -98,7 +86,6 @@ const runtimeState: RuntimeState = {
|
||||
eventFlag: null,
|
||||
offset: { ...runtimeStorePlaceholder.offset },
|
||||
timer: { ...runtimeStorePlaceholder.timer },
|
||||
groupTimer: null,
|
||||
rundown: { ...runtimeStorePlaceholder.rundown },
|
||||
_timer: {
|
||||
forceFinish: null,
|
||||
@@ -111,7 +98,6 @@ const runtimeState: RuntimeState = {
|
||||
totalDelay: 0,
|
||||
},
|
||||
_group: null,
|
||||
_groupTiming: null,
|
||||
_flag: null,
|
||||
_end: null,
|
||||
_startEpoch: null,
|
||||
@@ -129,8 +115,6 @@ export function getState(): Readonly<RuntimeState> {
|
||||
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 },
|
||||
};
|
||||
@@ -172,7 +156,6 @@ export function clearState() {
|
||||
|
||||
runtimeState.groupNow = null;
|
||||
runtimeState._group = null;
|
||||
runtimeState._groupTiming = null;
|
||||
|
||||
runtimeState.rundown.actualStart = null;
|
||||
runtimeState.rundown.selectedEventIndex = null;
|
||||
@@ -914,7 +897,6 @@ 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;
|
||||
@@ -938,11 +920,6 @@ 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
|
||||
|
||||
@@ -46,8 +46,6 @@ 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
|
||||
|
||||
@@ -17,7 +17,6 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
|
||||
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: '',
|
||||
|
||||
@@ -3,14 +3,12 @@ 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 { GroupTimerState, TimerState } from './TimerState.type.js';
|
||||
import type { 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;
|
||||
|
||||
@@ -11,22 +11,6 @@ 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
|
||||
*/
|
||||
|
||||
@@ -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 GroupTimerState, type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
|
||||
export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
|
||||
|
||||
// ---> Extra Timer
|
||||
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
|
||||
|
||||
@@ -54,7 +54,6 @@ 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,7 +69,6 @@ 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