mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-05 14:29:20 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e28b06efed | |||
| 9efac0a60a | |||
| 0ebad09154 | |||
| f3cd541ef0 | |||
| 7ec1179ede |
@@ -1,81 +0,0 @@
|
||||
import { Playback, RuntimeStore, TimerPhase, TimerType, runtimeStorePlaceholder } from 'ontime-types';
|
||||
|
||||
import { resolveTimerDisplay } from '../useSocket.utils';
|
||||
|
||||
const eventTimer = { ...runtimeStorePlaceholder.timer, current: 5_000 };
|
||||
const groupTimer = {
|
||||
...runtimeStorePlaceholder.timer,
|
||||
current: 25_000,
|
||||
phase: TimerPhase.Default,
|
||||
playback: Playback.Play,
|
||||
};
|
||||
|
||||
function makeState(patch: Partial<RuntimeStore> = {}): RuntimeStore {
|
||||
return {
|
||||
...runtimeStorePlaceholder,
|
||||
timer: eventTimer,
|
||||
eventNow: {
|
||||
id: 'event-1',
|
||||
timerType: TimerType.CountUp,
|
||||
countToEnd: true,
|
||||
} as RuntimeStore['eventNow'],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveTimerDisplay()', () => {
|
||||
it('uses the event timer by default', () => {
|
||||
expect(resolveTimerDisplay(makeState())).toMatchObject({
|
||||
time: eventTimer,
|
||||
timerType: TimerType.CountUp,
|
||||
countToEnd: true,
|
||||
usesGroupTimer: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the group timer and display type when enabled', () => {
|
||||
const display = resolveTimerDisplay(
|
||||
makeState({
|
||||
groupNow: { useGroupTimer: true, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
|
||||
groupTimer,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(display).toMatchObject({
|
||||
time: groupTimer,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
usesGroupTimer: true,
|
||||
eventTimer,
|
||||
eventTimerType: TimerType.CountUp,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a group timer when the group setting is disabled', () => {
|
||||
const display = resolveTimerDisplay(
|
||||
makeState({
|
||||
groupNow: { useGroupTimer: false, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
|
||||
groupTimer,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(display.time).toBe(eventTimer);
|
||||
expect(display.usesGroupTimer).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back entirely to the event display while group timer data is unavailable', () => {
|
||||
const display = resolveTimerDisplay(
|
||||
makeState({
|
||||
groupNow: { useGroupTimer: true, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
|
||||
groupTimer: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(display).toMatchObject({
|
||||
time: eventTimer,
|
||||
timerType: TimerType.CountUp,
|
||||
countToEnd: true,
|
||||
usesGroupTimer: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
||||
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage, TimerType } from 'ontime-types';
|
||||
|
||||
import { useRuntimeStore } from '../stores/runtime';
|
||||
import { sendSocket } from '../utils/socket';
|
||||
import { resolveTimerDisplay } from './useSocket.utils';
|
||||
|
||||
const createSelector =
|
||||
<T>(selector: (state: RuntimeStore) => T) =>
|
||||
@@ -39,19 +38,15 @@ export const useExternalMessageInput = createSelector((state: RuntimeStore) => (
|
||||
visible: state.message.timer.secondarySource === 'secondary',
|
||||
}));
|
||||
|
||||
export const useMessagePreview = createSelector((state: RuntimeStore) => {
|
||||
const timerDisplay = resolveTimerDisplay(state);
|
||||
return {
|
||||
blink: state.message.timer.blink,
|
||||
blackout: state.message.timer.blackout,
|
||||
phase: timerDisplay.time.phase,
|
||||
secondarySource: state.message.timer.secondarySource,
|
||||
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
||||
timerType: timerDisplay.timerType,
|
||||
countToEnd: timerDisplay.countToEnd,
|
||||
usesGroupTimer: timerDisplay.usesGroupTimer,
|
||||
};
|
||||
});
|
||||
export const useMessagePreview = createSelector((state: RuntimeStore) => ({
|
||||
blink: state.message.timer.blink,
|
||||
blackout: state.message.timer.blackout,
|
||||
phase: state.timer.phase,
|
||||
secondarySource: state.message.timer.secondarySource,
|
||||
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
||||
timerType: state.eventNow?.timerType ?? null,
|
||||
countToEnd: state.eventNow?.countToEnd ?? false,
|
||||
}));
|
||||
|
||||
export const setMessage = {
|
||||
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }),
|
||||
@@ -235,26 +230,20 @@ export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
|
||||
|
||||
/* ======================= View specific subscriptions ======================= */
|
||||
|
||||
export const useTimerSocket = createSelector((state: RuntimeStore) => {
|
||||
const timerDisplay = resolveTimerDisplay(state);
|
||||
return {
|
||||
eventNext: state.eventNext,
|
||||
eventNow: state.eventNow,
|
||||
message: state.message,
|
||||
time: timerDisplay.time,
|
||||
eventTimer: timerDisplay.eventTimer,
|
||||
clock: state.clock,
|
||||
timerTypeNow: timerDisplay.timerType,
|
||||
eventTimerType: timerDisplay.eventTimerType,
|
||||
countToEndNow: timerDisplay.countToEnd,
|
||||
usesGroupTimer: timerDisplay.usesGroupTimer,
|
||||
auxTimer: {
|
||||
aux1: state.auxtimer1.current,
|
||||
aux2: state.auxtimer2.current,
|
||||
aux3: state.auxtimer3.current,
|
||||
},
|
||||
};
|
||||
});
|
||||
export const useTimerSocket = createSelector((state: RuntimeStore) => ({
|
||||
eventNext: state.eventNext,
|
||||
eventNow: state.eventNow,
|
||||
message: state.message,
|
||||
time: state.timer,
|
||||
clock: state.clock,
|
||||
timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown,
|
||||
countToEndNow: state.eventNow?.countToEnd ?? false,
|
||||
auxTimer: {
|
||||
aux1: state.auxtimer1.current,
|
||||
aux2: state.auxtimer2.current,
|
||||
aux3: state.auxtimer3.current,
|
||||
},
|
||||
}));
|
||||
|
||||
export const useCountdownSocket = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { RuntimeStore, TimerType } from 'ontime-types';
|
||||
|
||||
type TimerDisplaySource = Pick<RuntimeStore, 'eventNow' | 'groupNow' | 'groupTimer' | 'timer'>;
|
||||
|
||||
export function resolveTimerDisplay(state: TimerDisplaySource) {
|
||||
const eventTimerType = state.eventNow?.timerType ?? TimerType.CountDown;
|
||||
|
||||
if (state.groupNow?.useGroupTimer === true && state.groupTimer !== null) {
|
||||
return {
|
||||
time: state.groupTimer,
|
||||
timerType: state.groupNow.timerType,
|
||||
countToEnd: false,
|
||||
usesGroupTimer: true,
|
||||
eventTimer: state.timer,
|
||||
eventTimerType,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
time: state.timer,
|
||||
timerType: eventTimerType,
|
||||
countToEnd: state.eventNow?.countToEnd ?? false,
|
||||
usesGroupTimer: false,
|
||||
eventTimer: state.timer,
|
||||
eventTimerType,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getRememberedDimensions, rememberDimensions } from '../imageDimensions';
|
||||
|
||||
/** stand-in for a loaded HTMLImageElement */
|
||||
function makeImage(naturalWidth: number, naturalHeight: number) {
|
||||
return { naturalWidth, naturalHeight } as HTMLImageElement;
|
||||
}
|
||||
|
||||
test('We remember the size of an image, so that we can reserve its space when it comes back', () => {
|
||||
expect(getRememberedDimensions('http://ontime.local/unseen.png')).toBe(null);
|
||||
|
||||
rememberDimensions('http://ontime.local/image.png', makeImage(1920, 1080));
|
||||
expect(getRememberedDimensions('http://ontime.local/image.png')).toMatchObject({ width: 1920, height: 1080 });
|
||||
|
||||
// an image which failed to load has no size to offer
|
||||
rememberDimensions('http://ontime.local/broken.png', makeImage(0, 0));
|
||||
expect(getRememberedDimensions('http://ontime.local/broken.png')).toBe(null);
|
||||
});
|
||||
|
||||
test('We keep the most recently seen images, older entries are forgotten', () => {
|
||||
for (let i = 0; i < 600; i++) {
|
||||
rememberDimensions(`http://ontime.local/${i}.png`, makeImage(100, 50));
|
||||
}
|
||||
|
||||
expect(getRememberedDimensions('http://ontime.local/0.png')).toBe(null);
|
||||
expect(getRememberedDimensions('http://ontime.local/599.png')).toMatchObject({ width: 100, height: 50 });
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry, TimerType } from 'ontime-types';
|
||||
import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { getFlatRundownMetadata, initRundownMetadata } from '../rundownMetadata';
|
||||
import { initRundownMetadata } from '../rundownMetadata';
|
||||
|
||||
describe('initRundownMetadata()', () => {
|
||||
it('processes nested rundown data', () => {
|
||||
@@ -300,36 +300,3 @@ describe('initRundownMetadata()', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFlatRundownMetadata()', () => {
|
||||
it('exposes group timer settings on a group and its events', () => {
|
||||
const group = {
|
||||
id: 'group',
|
||||
type: SupportedEntry.Group,
|
||||
entries: ['event'],
|
||||
colour: 'red',
|
||||
useGroupTimer: true,
|
||||
timerType: TimerType.CountUp,
|
||||
} as OntimeGroup;
|
||||
const event = {
|
||||
id: 'event',
|
||||
type: SupportedEntry.Event,
|
||||
parent: group.id,
|
||||
timeStart: 0,
|
||||
timeEnd: 1,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent;
|
||||
|
||||
const flat = getFlatRundownMetadata(
|
||||
{ entries: { [group.id]: group, [event.id]: event }, flatOrder: [group.id, event.id] },
|
||||
null,
|
||||
);
|
||||
|
||||
expect(flat[0]).toMatchObject({ groupUsesTimer: true, groupTimerType: TimerType.CountUp });
|
||||
expect(flat[1]).toMatchObject({ groupUsesTimer: true, groupTimerType: TimerType.CountUp });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Images in the cuesheet live inside a virtualised table:
|
||||
* rows are unmounted when they leave the viewport and mounted again when they come back.
|
||||
* A re-mounted image has no dimensions until it is available,
|
||||
* which makes the row change height and the table shift under the user.
|
||||
*
|
||||
* We remember the size of the images we have already seen
|
||||
* so that we can reserve the space they will take.
|
||||
* This only holds two numbers per image: we leave the image data itself to the browser cache,
|
||||
* which knows better than us when memory should be released.
|
||||
*/
|
||||
|
||||
export interface ImageDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** how many sizes we remember, this is only a few bytes per entry */
|
||||
const maxSize = 500;
|
||||
|
||||
const dimensions = new Map<string, ImageDimensions>();
|
||||
|
||||
/**
|
||||
* @returns the size of a previously loaded image, if we have seen it before
|
||||
*/
|
||||
export function getRememberedDimensions(src: string): ImageDimensions | null {
|
||||
return dimensions.get(src) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the size of a loaded image
|
||||
*/
|
||||
export function rememberDimensions(src: string, image: HTMLImageElement) {
|
||||
if (image.naturalHeight === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// the map iteration order is our LRU queue, re-adding the entry marks it as recently used
|
||||
dimensions.delete(src);
|
||||
dimensions.set(src, { width: image.naturalWidth, height: image.naturalHeight });
|
||||
|
||||
while (dimensions.size > maxSize) {
|
||||
const oldest = dimensions.keys().next();
|
||||
if (oldest.done) {
|
||||
return;
|
||||
}
|
||||
dimensions.delete(oldest.value);
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,9 @@ import {
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
OntimeGroup,
|
||||
OntimeMilestone,
|
||||
PlayableEvent,
|
||||
Rundown,
|
||||
TimerType,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
isPlayableEvent,
|
||||
@@ -31,11 +29,7 @@ export type RundownMetadata = {
|
||||
isFirstAfterGroup: boolean;
|
||||
};
|
||||
|
||||
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T &
|
||||
RundownMetadata & {
|
||||
groupUsesTimer?: boolean;
|
||||
groupTimerType?: TimerType;
|
||||
};
|
||||
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
|
||||
|
||||
export const lastMetadataKey = 'LAST';
|
||||
|
||||
@@ -71,23 +65,10 @@ export function getFlatRundownMetadata(
|
||||
): ExtendedEntry[] {
|
||||
const { process } = initRundownMetadata(selectedEventId);
|
||||
const flatRundown: ExtendedEntry[] = [];
|
||||
let activeGroup: OntimeGroup | null = null;
|
||||
|
||||
for (const id of data.flatOrder) {
|
||||
const entry = data.entries[id];
|
||||
if (isOntimeGroup(entry)) {
|
||||
activeGroup = entry;
|
||||
} else if (entry.parent !== activeGroup?.id) {
|
||||
activeGroup = null;
|
||||
}
|
||||
|
||||
const timerGroup = isOntimeGroup(entry) ? entry : activeGroup;
|
||||
const extendedEntry = {
|
||||
...entry,
|
||||
...process(entry),
|
||||
groupUsesTimer: timerGroup?.useGroupTimer ?? false,
|
||||
groupTimerType: timerGroup?.timerType,
|
||||
};
|
||||
const extendedEntry = { ...entry, ...process(entry) };
|
||||
flatRundown.push(extendedEntry);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,13 +27,6 @@
|
||||
border-top: 1px solid $white-7;
|
||||
}
|
||||
|
||||
.timerSource {
|
||||
color: $active-indicator;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.blackout {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TimerPhase, TimerType } from 'ontime-types';
|
||||
import { IoArrowDown, IoArrowUp, IoBan, IoTime, IoTimerOutline } 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';
|
||||
@@ -20,8 +20,7 @@ const secondarySourceLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function TimerPreview() {
|
||||
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType, usesGroupTimer } =
|
||||
useMessagePreview();
|
||||
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
|
||||
const { data } = useViewSettings();
|
||||
|
||||
const main = (() => {
|
||||
@@ -36,9 +35,7 @@ export default function TimerPreview() {
|
||||
|
||||
const secondary = (() => {
|
||||
// message is a fullscreen overlay or secondary is not active
|
||||
if (showTimerMessage) return null;
|
||||
if (usesGroupTimer) return 'Event timer';
|
||||
if (!secondarySource) return null;
|
||||
if (showTimerMessage || !secondarySource) return null;
|
||||
|
||||
// we need to check aux first since it takes priority
|
||||
return secondarySourceLabels[secondarySource];
|
||||
@@ -58,7 +55,6 @@ export default function TimerPreview() {
|
||||
<div className={style.preview}>
|
||||
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
|
||||
<div className={contentClasses}>
|
||||
{usesGroupTimer && <div className={style.timerSource}>Group timer</div>}
|
||||
<div
|
||||
className={style.mainContent}
|
||||
data-phase={showColourOverride && phase}
|
||||
@@ -69,14 +65,6 @@ export default function TimerPreview() {
|
||||
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
|
||||
</div>
|
||||
<div className={style.eventStatus}>
|
||||
<Tooltip
|
||||
text='Timer display controlled by group'
|
||||
render={<span />}
|
||||
className={style.statusIcon}
|
||||
data-active={usesGroupTimer}
|
||||
>
|
||||
<IoTimerOutline />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
text='Time type: Count down'
|
||||
render={<span />}
|
||||
|
||||
@@ -32,12 +32,6 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.timerDisplaySettings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { MaybeNumber, OntimeGroup, TimerType } from 'ontime-types';
|
||||
import { MaybeNumber, OntimeGroup } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
||||
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
|
||||
import AppLink from '../../../common/components/link/app-link/AppLink';
|
||||
import Select from '../../../common/components/select/Select';
|
||||
import Switch from '../../../common/components/switch/Switch';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||
import { getOffsetState } from '../../../common/utils/offset';
|
||||
@@ -109,38 +107,6 @@ export default function GroupEditor({ group }: GroupEditorProps) {
|
||||
<EventTextArea field='note' label='Note' initialValue={group.note} submitHandler={handleSubmit} />
|
||||
</div>
|
||||
|
||||
<div className={style.column}>
|
||||
<Editor.Title>Timer display</Editor.Title>
|
||||
<div className={style.timerDisplaySettings}>
|
||||
<div>
|
||||
<Editor.Label htmlFor='useGroupTimer'>Use group timer</Editor.Label>
|
||||
<Editor.Label className={style.switchLabel}>
|
||||
<Switch
|
||||
id='useGroupTimer'
|
||||
checked={group.useGroupTimer}
|
||||
onCheckedChange={(useGroupTimer) => updateEntry({ id: group.id, useGroupTimer })}
|
||||
/>
|
||||
{group.useGroupTimer ? 'On' : 'Off'}
|
||||
</Editor.Label>
|
||||
</div>
|
||||
<div>
|
||||
<Editor.Label htmlFor='groupTimerType'>Timer type</Editor.Label>
|
||||
<Select
|
||||
id='groupTimerType'
|
||||
disabled={!group.useGroupTimer}
|
||||
value={group.timerType}
|
||||
onValueChange={(timerType: TimerType | null) => {
|
||||
if (timerType !== null) updateEntry({ id: group.id, timerType });
|
||||
}}
|
||||
options={[
|
||||
{ value: TimerType.CountDown, label: 'Count down' },
|
||||
{ value: TimerType.CountUp, label: 'Count up' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.column}>
|
||||
<Editor.Title>
|
||||
Custom Fields
|
||||
|
||||
@@ -335,7 +335,6 @@ export default function RundownEvent({
|
||||
eventIndex={eventIndex}
|
||||
endAction={endAction}
|
||||
timerType={timerType}
|
||||
groupTimerType={parentGroup?.useGroupTimer ? parentGroup.timerType : undefined}
|
||||
title={title}
|
||||
note={note}
|
||||
delay={delay}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
IoPlayForward,
|
||||
IoPlaySkipForward,
|
||||
IoTime,
|
||||
IoTimerOutline,
|
||||
} from 'react-icons/io5';
|
||||
import { LuArrowDownToLine } from 'react-icons/lu';
|
||||
|
||||
@@ -36,7 +35,6 @@ interface RundownEventInnerProps {
|
||||
eventIndex: number;
|
||||
endAction: EndAction;
|
||||
timerType: TimerType;
|
||||
groupTimerType?: TimerType;
|
||||
title: string;
|
||||
note: string;
|
||||
delay: number;
|
||||
@@ -64,7 +62,6 @@ function RundownEventInner({
|
||||
countToEnd,
|
||||
endAction,
|
||||
timerType,
|
||||
groupTimerType,
|
||||
title,
|
||||
note,
|
||||
delay,
|
||||
@@ -153,14 +150,6 @@ function RundownEventInner({
|
||||
{loaded && <EventBlockProgressBar />}
|
||||
</div>
|
||||
<div className={style.eventStatus} tabIndex={-1}>
|
||||
{groupTimerType && (
|
||||
<Tooltip
|
||||
text={`Timer display controlled by group (${groupTimerType === TimerType.CountUp ? 'count up' : 'count down'})`}
|
||||
render={<span />}
|
||||
>
|
||||
<IoTimerOutline className={cx([style.statusIcon, style.active])} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip text={`Time type: ${timerType}`} render={<span />}>
|
||||
<TimerIcon type={timerType} className={style.statusIcon} />
|
||||
</Tooltip>
|
||||
|
||||
@@ -55,14 +55,6 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.timerIndicator {
|
||||
display: grid;
|
||||
flex: 0 0 1.5rem;
|
||||
place-items: center;
|
||||
color: $active-indicator;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
gap: $block-clearance; // same as RundownEvent.eventTimers
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { EntryId, OntimeGroup, TimerType } from 'ontime-types';
|
||||
import { EntryId, OntimeGroup } from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
import { MouseEvent, useCallback, useRef } from 'react';
|
||||
import {
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
IoDuplicateOutline,
|
||||
IoFolderOpenOutline,
|
||||
IoReorderTwo,
|
||||
IoTimerOutline,
|
||||
IoTrash,
|
||||
IoLockClosed,
|
||||
} from 'react-icons/io5';
|
||||
@@ -175,14 +174,6 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
<div className={style.header}>
|
||||
<div className={style.titleRow}>
|
||||
<TitleEditor title={data.title} entryId={data.id} placeholder='Group title' />
|
||||
{data.useGroupTimer && (
|
||||
<Tooltip
|
||||
text={`Group timer (${data.timerType === TimerType.CountUp ? 'count up' : 'count down'})`}
|
||||
render={<span className={style.timerIndicator} />}
|
||||
>
|
||||
<IoTimerOutline />
|
||||
</Tooltip>
|
||||
)}
|
||||
<IconButton aria-label='Collapse' variant='subtle-white' onClick={() => onCollapse(!collapsed, data.id)}>
|
||||
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
|
||||
</IconButton>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { isValidImageSource } from '../cuesheet-table/cuesheet-table-elements/EditableImage';
|
||||
|
||||
test('An image is referenced by link, anything else is rejected', () => {
|
||||
const testCases = [
|
||||
{ value: 'https://example.com/image.png', isValid: true },
|
||||
{ value: 'http://example.com/image.png', isValid: true },
|
||||
// a file is local to the machine running ontime, it would not resolve for the clients we serve
|
||||
{ value: '/user/image.png', isValid: false },
|
||||
{ value: 'file:///Users/me/image.png', isValid: false },
|
||||
{ value: 'C:\\images\\image.png', isValid: false },
|
||||
// values which do not describe a location we can reach
|
||||
{ value: 'www.example.com/image.png', isValid: false },
|
||||
{ value: 'https://', isValid: false },
|
||||
{ value: 'some text', isValid: false },
|
||||
];
|
||||
|
||||
testCases.forEach((t) => expect(isValidImageSource(t.value)).toBe(t.isValid));
|
||||
});
|
||||
+13
@@ -5,6 +5,19 @@
|
||||
&:not(:read-only):hover::placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&[data-invalid] {
|
||||
outline: 1px solid $red-500;
|
||||
}
|
||||
}
|
||||
|
||||
/** feedback on a value we cannot use, either rejected or failed to load */
|
||||
.message {
|
||||
display: block;
|
||||
padding: 0.25rem 0;
|
||||
color: $red-500;
|
||||
font-size: calc(1rem - 3px);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.imageCell {
|
||||
|
||||
+81
-19
@@ -1,27 +1,54 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import { getRememberedDimensions, rememberDimensions } from '../../../../common/utils/imageDimensions';
|
||||
|
||||
import style from './EditableImage.module.scss';
|
||||
|
||||
interface EditableImageProps {
|
||||
initialValue: string;
|
||||
fieldLabel: string;
|
||||
readOnly?: boolean;
|
||||
updateValue: (newValue: string) => void;
|
||||
}
|
||||
|
||||
export default memo(EditableImage);
|
||||
|
||||
function EditableImage({ initialValue, readOnly, updateValue }: EditableImageProps) {
|
||||
/**
|
||||
* Images are referenced by link: anything local to the machine running ontime
|
||||
* would not resolve for the clients we serve the cuesheet to
|
||||
*/
|
||||
export function isValidImageSource(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function EditableImage({ initialValue, fieldLabel, readOnly, updateValue }: EditableImageProps) {
|
||||
const [isRejected, setIsRejected] = useState(false);
|
||||
/** we keep track of the source itself, so that the state follows the value being shown */
|
||||
const [failedSource, setFailedSource] = useState<string | null>(null);
|
||||
const [loadedSource, setLoadedSource] = useState<string | null>(null);
|
||||
|
||||
const handleUpdate = (newValue: string) => {
|
||||
if (newValue === initialValue) {
|
||||
const value = newValue.trim();
|
||||
|
||||
if (value === initialValue) {
|
||||
setIsRejected(false);
|
||||
return;
|
||||
}
|
||||
if (newValue !== '' && !newValue.startsWith('http')) {
|
||||
|
||||
if (value !== '' && !isValidImageSource(value)) {
|
||||
setIsRejected(true);
|
||||
return;
|
||||
}
|
||||
updateValue(newValue);
|
||||
|
||||
setIsRejected(false);
|
||||
updateValue(value);
|
||||
};
|
||||
|
||||
const openInNewTab = () => {
|
||||
@@ -36,22 +63,42 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro
|
||||
|
||||
if (!initialValue) {
|
||||
return (
|
||||
<Input
|
||||
variant='ghosted'
|
||||
className={style.imageInput}
|
||||
fluid
|
||||
placeholder='Paste image URL'
|
||||
onBlur={(event) => handleUpdate(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleUpdate(event.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
defaultValue={initialValue}
|
||||
/>
|
||||
<>
|
||||
<Input
|
||||
variant='ghosted'
|
||||
className={style.imageInput}
|
||||
fluid
|
||||
placeholder='Paste image URL'
|
||||
data-invalid={isRejected || undefined}
|
||||
onChange={() => setIsRejected(false)}
|
||||
onBlur={(event) => handleUpdate(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleUpdate(event.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isRejected && <span className={style.message}>Images are referenced by link (https://...)</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cuesheet is virtualised: rows are unmounted once they leave the viewport.
|
||||
* When the row comes back, we reserve the space the image took
|
||||
* so that the table does not shift while the browser makes it available.
|
||||
* The reservation is given in CSS so that it follows the column being resized,
|
||||
* the same way the image itself does once it is shown.
|
||||
*/
|
||||
const knownDimensions = getRememberedDimensions(initialValue);
|
||||
const isLoaded = loadedSource === initialValue;
|
||||
const reservedSpace = knownDimensions
|
||||
? {
|
||||
aspectRatio: knownDimensions.width / knownDimensions.height,
|
||||
width: `min(100%, ${knownDimensions.width}px)`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className={style.imageCell}>
|
||||
{!readOnly && (
|
||||
@@ -62,7 +109,22 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(initialValue) && <img loading='lazy' src={initialValue} className={style.image} />}
|
||||
{failedSource === initialValue ? (
|
||||
<span className={style.message}>Could not load image</span>
|
||||
) : (
|
||||
<img
|
||||
src={initialValue}
|
||||
alt={fieldLabel}
|
||||
className={style.image}
|
||||
onLoad={(event) => {
|
||||
rememberDimensions(initialValue, event.currentTarget);
|
||||
setLoadedSource(initialValue);
|
||||
}}
|
||||
onError={() => setFailedSource(initialValue)}
|
||||
/** until the image is available, we reserve the space it took the last time we saw it */
|
||||
style={isLoaded ? undefined : reservedSpace}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
+14
-40
@@ -1,18 +1,8 @@
|
||||
import {
|
||||
CustomFields,
|
||||
TimeStrategy,
|
||||
TimerType,
|
||||
URLPreset,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
} from 'ontime-types';
|
||||
import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } 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';
|
||||
@@ -27,8 +17,6 @@ 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;
|
||||
}
|
||||
@@ -187,7 +175,14 @@ function LazyImage({ row, column, table }: CuesheetCellContext) {
|
||||
|
||||
const canWrite = column.columnDef.meta?.canWrite;
|
||||
const initialValue = event.custom[column.id];
|
||||
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
|
||||
return (
|
||||
<EditableImage
|
||||
initialValue={initialValue}
|
||||
fieldLabel={getColumnLabel(column)}
|
||||
updateValue={update}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
|
||||
@@ -205,38 +200,17 @@ function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
|
||||
}
|
||||
|
||||
const canWrite = column.columnDef.meta?.canWrite;
|
||||
const content = canWrite ? (
|
||||
if (!canWrite) {
|
||||
return <GhostedText>{initialValue}</GhostedText>;
|
||||
}
|
||||
|
||||
return (
|
||||
<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,14 +35,6 @@
|
||||
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,7 +7,6 @@ import { cx } from '../../../common/utils/styleUtils';
|
||||
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
|
||||
import {
|
||||
getEstimatedFontSize,
|
||||
getEventTimerSecondary,
|
||||
getIsPlaying,
|
||||
getSecondaryDisplay,
|
||||
getShowMessage,
|
||||
@@ -24,18 +23,7 @@ interface PipTimerProps {
|
||||
}
|
||||
|
||||
export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||
const {
|
||||
eventNow,
|
||||
message,
|
||||
time,
|
||||
eventTimer,
|
||||
clock,
|
||||
timerTypeNow,
|
||||
eventTimerType,
|
||||
countToEndNow,
|
||||
usesGroupTimer,
|
||||
auxTimer,
|
||||
} = useTimerSocket();
|
||||
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
|
||||
|
||||
// gather modifiers
|
||||
const showOverlay = getShowMessage(message.timer);
|
||||
@@ -71,9 +59,7 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||
return null;
|
||||
})();
|
||||
|
||||
const secondaryContent = usesGroupTimer
|
||||
? getEventTimerSecondary(eventTimer, eventTimerType, clock, 'min', false, true)
|
||||
: getSecondaryDisplay(message, currentAux, 'min', false, true, false);
|
||||
const secondaryContent = getSecondaryDisplay(message, currentAux, 'min', false, true, false);
|
||||
|
||||
// gather presentation styles
|
||||
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
|
||||
@@ -91,7 +77,6 @@ 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` }}
|
||||
@@ -111,11 +96,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
||||
now={time.current}
|
||||
complete={totalTime}
|
||||
eventId={usesGroupTimer ? undefined : eventNow?.id}
|
||||
eventId={eventNow?.id}
|
||||
normalColor={viewSettings.normalColor}
|
||||
warning={usesGroupTimer ? undefined : eventNow?.timeWarning}
|
||||
warning={eventNow?.timeWarning}
|
||||
warningColor={viewSettings.warningColor}
|
||||
danger={usesGroupTimer ? undefined : eventNow?.timeDanger}
|
||||
danger={eventNow?.timeDanger}
|
||||
dangerColor={viewSettings.dangerColor}
|
||||
hideOvertime={!showFinished}
|
||||
/>
|
||||
|
||||
@@ -89,14 +89,6 @@
|
||||
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,7 +22,6 @@ import { getTimerOptions, useTimerOptions } from './timer.options';
|
||||
import {
|
||||
getCardData,
|
||||
getEstimatedFontSize,
|
||||
getEventTimerSecondary,
|
||||
getIsPlaying,
|
||||
getSecondaryDisplay,
|
||||
getShowClock,
|
||||
@@ -53,19 +52,7 @@ export default function TimerLoader() {
|
||||
}
|
||||
|
||||
function Timer({ customFields, projectData, isMirrored, settings, viewSettings, entries }: TimerData) {
|
||||
const {
|
||||
eventNext,
|
||||
eventNow,
|
||||
message,
|
||||
time,
|
||||
eventTimer,
|
||||
clock,
|
||||
timerTypeNow,
|
||||
eventTimerType,
|
||||
countToEndNow,
|
||||
usesGroupTimer,
|
||||
auxTimer,
|
||||
} = useTimerSocket();
|
||||
const { eventNext, eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
|
||||
const {
|
||||
hideClock,
|
||||
hideCards,
|
||||
@@ -91,7 +78,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const localisedMinutes = getLocalizedString('common.minutes');
|
||||
|
||||
const showSoundPrompt = useTimerSound(eventTimer.phase, endSound);
|
||||
const showSoundPrompt = useTimerSound(time.phase, endSound);
|
||||
|
||||
// gather modifiers
|
||||
const viewTimerType = timerType ?? timerTypeNow;
|
||||
@@ -141,18 +128,14 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
return null;
|
||||
})();
|
||||
|
||||
const secondaryContent =
|
||||
usesGroupTimer && !hideSecondary
|
||||
? getEventTimerSecondary(
|
||||
eventTimer,
|
||||
eventTimerType,
|
||||
clock,
|
||||
localisedMinutes,
|
||||
hideTimerSeconds,
|
||||
removeLeadingZeros,
|
||||
timeformat,
|
||||
)
|
||||
: getSecondaryDisplay(message, currentAux, localisedMinutes, hideTimerSeconds, removeLeadingZeros, hideSecondary);
|
||||
const secondaryContent = getSecondaryDisplay(
|
||||
message,
|
||||
currentAux,
|
||||
localisedMinutes,
|
||||
hideTimerSeconds,
|
||||
removeLeadingZeros,
|
||||
hideSecondary,
|
||||
);
|
||||
|
||||
// gather presentation styles
|
||||
const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
|
||||
@@ -193,7 +176,6 @@ 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}
|
||||
@@ -220,11 +202,11 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
||||
now={time.current}
|
||||
complete={totalTime}
|
||||
eventId={usesGroupTimer ? undefined : eventNow?.id}
|
||||
eventId={eventNow?.id}
|
||||
normalColor={viewSettings.normalColor}
|
||||
warning={usesGroupTimer ? undefined : eventNow?.timeWarning}
|
||||
warning={eventNow?.timeWarning}
|
||||
warningColor={viewSettings.warningColor}
|
||||
danger={usesGroupTimer ? undefined : eventNow?.timeDanger}
|
||||
danger={eventNow?.timeDanger}
|
||||
dangerColor={viewSettings.dangerColor}
|
||||
hideOvertime={!showFinished}
|
||||
/>
|
||||
|
||||
@@ -1,32 +1,6 @@
|
||||
import { TimerPhase, TimerType } from 'ontime-types';
|
||||
import { TimerPhase } from 'ontime-types';
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
import { shouldPlayEndSound } from '../timer.utils';
|
||||
|
||||
describe('shouldPlayEndSound()', () => {
|
||||
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
|
||||
|
||||
@@ -6,12 +6,11 @@ import {
|
||||
RundownEntries,
|
||||
TimerMessage,
|
||||
TimerPhase,
|
||||
TimerState,
|
||||
TimerType,
|
||||
} from 'ontime-types';
|
||||
import { isPlaybackActive } from 'ontime-utils';
|
||||
|
||||
import { getFormattedTimer, getPropertyValue, getTimerByType } from '../common/viewUtils';
|
||||
import { getFormattedTimer, getPropertyValue } from '../common/viewUtils';
|
||||
|
||||
/**
|
||||
* Whether a message should be shown
|
||||
@@ -145,25 +144,6 @@ 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?
|
||||
*/
|
||||
|
||||
@@ -417,7 +417,7 @@ export function migrateRundown(
|
||||
timeEnd: null,
|
||||
duration: 0,
|
||||
isFirstLinked: false,
|
||||
} as unknown as OntimeEntry);
|
||||
});
|
||||
} else if (entry.type === 'delay') {
|
||||
append({ id: entry.id, type: SupportedEntry.Delay, duration: entry.duration, parent });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CustomFields, OntimeEvent, OntimeGroup, Rundown, SupportedEntry, TimerType } from 'ontime-types';
|
||||
import { CustomFields, OntimeEvent, OntimeGroup, Rundown, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { makeNewRundown } from '../../../models/dataModel.js';
|
||||
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js';
|
||||
@@ -276,13 +276,7 @@ describe('parseRundown()', () => {
|
||||
expect(parsedRundown.order).toStrictEqual(['group']);
|
||||
expect(parsedRundown.flatOrder).toStrictEqual(['group', '1', '2']);
|
||||
expect(parsedRundown.entries).toMatchObject({
|
||||
group: {
|
||||
id: 'group',
|
||||
type: SupportedEntry.Group,
|
||||
entries: ['1', '2'],
|
||||
useGroupTimer: false,
|
||||
timerType: TimerType.CountDown,
|
||||
},
|
||||
group: { id: 'group', type: SupportedEntry.Group, entries: ['1', '2'] },
|
||||
'1': { id: '1', type: SupportedEntry.Event },
|
||||
'2': { id: '2', type: SupportedEntry.Milestone },
|
||||
});
|
||||
|
||||
@@ -25,7 +25,6 @@ import { parseRundown } from '../rundown.parser.js';
|
||||
import {
|
||||
calculateDayOffset,
|
||||
cloneEntryData,
|
||||
createGroupPatch,
|
||||
deleteById,
|
||||
doesInvalidateMetadata,
|
||||
getIntegerAndFraction,
|
||||
@@ -37,25 +36,6 @@ import {
|
||||
} from '../rundown.utils.js';
|
||||
|
||||
describe('test event validator', () => {
|
||||
it('creates groups with the shared timer disabled by default', () => {
|
||||
expect(createGroup({ id: 'group' })).toMatchObject({
|
||||
useGroupTimer: false,
|
||||
timerType: TimerType.CountDown,
|
||||
});
|
||||
});
|
||||
|
||||
it('limits group timers to count down and count up', () => {
|
||||
expect(createGroup({ timerType: TimerType.CountUp }).timerType).toBe(TimerType.CountUp);
|
||||
expect(createGroup({ timerType: TimerType.Clock }).timerType).toBe(TimerType.CountDown);
|
||||
});
|
||||
|
||||
it('rejects non-boolean group timer updates', () => {
|
||||
const group = createGroup({ useGroupTimer: false });
|
||||
const updated = createGroupPatch(group, { useGroupTimer: 'true' as never });
|
||||
|
||||
expect(updated.useGroupTimer).toBe(false);
|
||||
});
|
||||
|
||||
it('validates a good object', () => {
|
||||
const event = {
|
||||
title: 'test',
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
makeString,
|
||||
maxDuration,
|
||||
validateEndAction,
|
||||
validateGroupTimerType,
|
||||
validateTimerType,
|
||||
validateTimes,
|
||||
} from 'ontime-utils';
|
||||
@@ -181,9 +180,6 @@ export function createGroupPatch(originalGroup: OntimeGroup, patchGroup: Partial
|
||||
note: makeString(patchGroup.note, originalGroup.note),
|
||||
entries: patchGroup.entries ?? originalGroup.entries,
|
||||
targetDuration: maybeTargetDuration(),
|
||||
useGroupTimer:
|
||||
typeof patchGroup.useGroupTimer === 'boolean' ? patchGroup.useGroupTimer : originalGroup.useGroupTimer,
|
||||
timerType: validateGroupTimerType(patchGroup.timerType, originalGroup.timerType),
|
||||
colour: makeString(patchGroup.colour, originalGroup.colour),
|
||||
revision: originalGroup.revision,
|
||||
timeStart: originalGroup.timeStart,
|
||||
|
||||
@@ -206,7 +206,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,8 +39,6 @@ export const stageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
timerType: TimerType.CountDown,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -174,8 +172,6 @@ export const stageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['0aaa7d'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
timerType: TimerType.CountDown,
|
||||
colour: '#3E75E8',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -223,8 +219,6 @@ export const stageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
timerType: TimerType.CountDown,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -366,8 +360,6 @@ export const backstageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
timerType: TimerType.CountDown,
|
||||
colour: '#A790F5',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -484,8 +476,6 @@ export const backstageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
timerType: TimerType.CountDown,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -598,8 +588,6 @@ export const backstageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['bs0301'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
timerType: TimerType.CountDown,
|
||||
colour: '#3E75E8',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -646,8 +634,6 @@ export const backstageRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
timerType: TimerType.CountDown,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -782,8 +768,6 @@ export const broadcastRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['br0101', 'br0102', 'br0103'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
timerType: TimerType.CountDown,
|
||||
colour: '#ED3333',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -867,8 +851,6 @@ export const broadcastRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['br0201', 'br0202', 'br0203', 'br0204'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
timerType: TimerType.CountDown,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -977,8 +959,6 @@ export const broadcastRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['br0301', 'br0302'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
timerType: TimerType.CountDown,
|
||||
colour: '#3E75E8',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
@@ -1049,8 +1029,6 @@ export const broadcastRundown: Rundown = {
|
||||
note: '',
|
||||
entries: ['br0401', 'br0402', 'br0403'],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
timerType: TimerType.CountDown,
|
||||
colour: '#339E4E',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisT
|
||||
import type { RuntimeState } from '../../stores/runtimeState.js';
|
||||
import {
|
||||
findDayOffset,
|
||||
getGroupTimer,
|
||||
getCurrent,
|
||||
getElapsed,
|
||||
getExpectedFinish,
|
||||
@@ -17,91 +16,6 @@ import {
|
||||
|
||||
const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock'];
|
||||
|
||||
describe('getGroupTimer()', () => {
|
||||
const makeState = (patch: Partial<RuntimeState> = {}) =>
|
||||
({
|
||||
clock: asTimeOfDay(10_000),
|
||||
groupNow: {
|
||||
duration: 20_000,
|
||||
},
|
||||
rundown: {
|
||||
actualGroupStart: 5_000,
|
||||
},
|
||||
timer: {
|
||||
addedTime: 4_000,
|
||||
current: 1_000,
|
||||
elapsed: 99_000,
|
||||
phase: TimerPhase.Danger,
|
||||
playback: Playback.Play,
|
||||
},
|
||||
...patch,
|
||||
}) as RuntimeState;
|
||||
|
||||
it('returns null outside a group', () => {
|
||||
expect(getGroupTimer(makeState({ groupNow: null }))).toBeNull();
|
||||
});
|
||||
|
||||
it('derives elapsed and remaining time from the group start and duration', () => {
|
||||
expect(getGroupTimer(makeState())).toMatchObject({
|
||||
addedTime: 0,
|
||||
current: 15_000,
|
||||
duration: 20_000,
|
||||
elapsed: 5_000,
|
||||
expectedFinish: null,
|
||||
phase: TimerPhase.Default,
|
||||
playback: Playback.Play,
|
||||
secondaryTimer: null,
|
||||
startedAt: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not inherit event timer progress or added time', () => {
|
||||
const first = getGroupTimer(makeState());
|
||||
const second = getGroupTimer(
|
||||
makeState({
|
||||
timer: {
|
||||
...makeState().timer,
|
||||
addedTime: -8_000,
|
||||
current: -50_000,
|
||||
elapsed: 500_000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
it('keeps running while the loaded event is paused', () => {
|
||||
const timer = getGroupTimer(makeState({ timer: { ...makeState().timer, playback: Playback.Pause } }));
|
||||
|
||||
expect(timer?.playback).toBe(Playback.Play);
|
||||
});
|
||||
|
||||
it('handles a group running across midnight', () => {
|
||||
const timer = getGroupTimer(
|
||||
makeState({
|
||||
clock: asTimeOfDay(1_000),
|
||||
rundown: { ...makeState().rundown, actualGroupStart: 86_399_000 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(timer).toMatchObject({ current: 18_000, elapsed: 2_000 });
|
||||
});
|
||||
|
||||
it('uses the event phase before the group starts and overtime afterwards', () => {
|
||||
const pending = getGroupTimer(
|
||||
makeState({
|
||||
rundown: { ...makeState().rundown, actualGroupStart: null },
|
||||
timer: { ...makeState().timer, phase: TimerPhase.Pending },
|
||||
}),
|
||||
);
|
||||
const overtime = getGroupTimer(makeState({ clock: asTimeOfDay(30_001) }));
|
||||
|
||||
expect(pending).toMatchObject({ current: 20_000, elapsed: null, phase: TimerPhase.Pending });
|
||||
expect(overtime).toMatchObject({ current: -5_001, elapsed: 25_001, phase: TimerPhase.Overtime });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getElapsed()', () => {
|
||||
it('returns active elapsed time from startedAt without add-time adjustments', () => {
|
||||
const state = {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
findPreviousPlayableId,
|
||||
getEventAtIndex,
|
||||
getShouldClockUpdate,
|
||||
getShouldGroupTimerUpdate,
|
||||
getShouldOffsetUpdate,
|
||||
getShouldTimerUpdate,
|
||||
isNewSecond,
|
||||
@@ -96,34 +95,6 @@ describe('getShouldTimerUpdate()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShouldGroupTimerUpdate()', () => {
|
||||
const timer: TimerState = {
|
||||
addedTime: 0,
|
||||
current: 10_000,
|
||||
duration: 10_000,
|
||||
elapsed: 0,
|
||||
expectedFinish: null,
|
||||
phase: TimerPhase.Default,
|
||||
playback: Playback.Play,
|
||||
secondaryTimer: null,
|
||||
startedAt: 0,
|
||||
};
|
||||
|
||||
it('updates when a group timer appears or disappears', () => {
|
||||
expect(getShouldGroupTimerUpdate(null, timer)).toBe(true);
|
||||
expect(getShouldGroupTimerUpdate(timer, null)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not repeatedly publish an absent group timer', () => {
|
||||
expect(getShouldGroupTimerUpdate(null, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses normal timer tick semantics while a group timer exists', () => {
|
||||
expect(getShouldGroupTimerUpdate(timer, { ...timer, current: 9_500 })).toBe(false);
|
||||
expect(getShouldGroupTimerUpdate(timer, { ...timer, current: 8_999 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShouldOffsetUpdate()', () => {
|
||||
const baseOffset: Offset = {
|
||||
absolute: 0,
|
||||
|
||||
@@ -25,7 +25,7 @@ import { logger } from '../../classes/Logger.js';
|
||||
import { timerConfig } from '../../setup/config.js';
|
||||
import { eventStore } from '../../stores/EventStore.js';
|
||||
import * as runtimeState from '../../stores/runtimeState.js';
|
||||
import type { RuntimeState, RuntimeStateSnapshot } from '../../stores/runtimeState.js';
|
||||
import type { RuntimeState } from '../../stores/runtimeState.js';
|
||||
import { EventTimer } from '../EventTimer.js';
|
||||
import { restoreService } from '../restore-service/restore.service.js';
|
||||
import type { RestorePoint } from '../restore-service/restore.type.js';
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
findPreviousPlayableId,
|
||||
getEventAtIndex,
|
||||
getShouldClockUpdate,
|
||||
getShouldGroupTimerUpdate,
|
||||
getShouldOffsetUpdate,
|
||||
getShouldTimerUpdate,
|
||||
isNewSecond,
|
||||
@@ -52,11 +51,11 @@ class RuntimeService {
|
||||
private lastIntegrationTimerValue = -1;
|
||||
|
||||
/** last known state */
|
||||
static previousState: RuntimeStateSnapshot;
|
||||
static previousState: RuntimeState;
|
||||
|
||||
constructor(eventTimer: EventTimer) {
|
||||
this.eventTimer = eventTimer;
|
||||
RuntimeService.previousState = {} as RuntimeStateSnapshot;
|
||||
RuntimeService.previousState = {} as RuntimeState;
|
||||
}
|
||||
|
||||
@broadcastResult
|
||||
@@ -711,12 +710,6 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
||||
RuntimeService.previousState.timer = { ...state.timer };
|
||||
}
|
||||
|
||||
const updateGroupTimer = getShouldGroupTimerUpdate(RuntimeService.previousState.groupTimer, state.groupTimer);
|
||||
if (updateGroupTimer) {
|
||||
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
|
||||
|
||||
@@ -54,15 +54,6 @@ export function getShouldTimerUpdate(previousValue: TimerState | undefined, curr
|
||||
);
|
||||
}
|
||||
|
||||
export function getShouldGroupTimerUpdate(
|
||||
previousValue: TimerState | null | undefined,
|
||||
currentValue: TimerState | null,
|
||||
): boolean {
|
||||
if (previousValue === undefined) return true;
|
||||
if (previousValue === null || currentValue === null) return previousValue !== currentValue;
|
||||
return getShouldTimerUpdate(previousValue, currentValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether we should update the offset values
|
||||
* - `mode` triggers update
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Day, MaybeNumber, Playback, TimeOfDay, TimerPhase, TimerState } from 'ontime-types';
|
||||
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
|
||||
|
||||
import type { RuntimeState } from '../stores/runtimeState.js';
|
||||
@@ -111,37 +111,6 @@ export function getElapsed(state: RuntimeState): MaybeNumber {
|
||||
return Math.max(0, activeElapsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a timer for the active group from wall-clock time.
|
||||
* Event timer controls such as pause and add time intentionally do not affect it.
|
||||
*/
|
||||
export function getGroupTimer(state: RuntimeState): TimerState | null {
|
||||
if (state.groupNow === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { actualGroupStart } = state.rundown;
|
||||
const elapsed = actualGroupStart === null ? null : getTimeSinceStart(state.clock, actualGroupStart);
|
||||
const current = elapsed === null ? state.groupNow.duration : state.groupNow.duration - elapsed;
|
||||
|
||||
let phase = state.timer.phase;
|
||||
if (actualGroupStart !== null) {
|
||||
phase = current < 0 ? TimerPhase.Overtime : TimerPhase.Default;
|
||||
}
|
||||
|
||||
return {
|
||||
addedTime: 0,
|
||||
current,
|
||||
duration: state.groupNow.duration,
|
||||
elapsed,
|
||||
expectedFinish: null,
|
||||
phase,
|
||||
playback: actualGroupStart === null ? state.timer.playback : Playback.Play,
|
||||
secondaryTimer: null,
|
||||
startedAt: actualGroupStart,
|
||||
};
|
||||
}
|
||||
|
||||
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
|
||||
if (clock < startedAt) {
|
||||
return clock + dayInMs - startedAt;
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
Playback,
|
||||
Rundown,
|
||||
RundownState,
|
||||
RuntimeStore,
|
||||
TimeOfDay,
|
||||
TimerPhase,
|
||||
TimerState,
|
||||
@@ -40,7 +39,6 @@ import {
|
||||
getCurrent,
|
||||
getElapsed,
|
||||
getExpectedFinish,
|
||||
getGroupTimer,
|
||||
getRuntimeOffset,
|
||||
getTimerPhase,
|
||||
hasCrossedMidnight,
|
||||
@@ -106,9 +104,7 @@ const runtimeState: RuntimeState = {
|
||||
_startDayOffset: null,
|
||||
};
|
||||
|
||||
export type RuntimeStateSnapshot = RuntimeState & Pick<RuntimeStore, 'groupTimer'>;
|
||||
|
||||
export function getState(): Readonly<RuntimeStateSnapshot> {
|
||||
export function getState(): Readonly<RuntimeState> {
|
||||
// create a shallow copy of the state
|
||||
return {
|
||||
...runtimeState,
|
||||
@@ -119,7 +115,6 @@ export function getState(): Readonly<RuntimeStateSnapshot> {
|
||||
offset: { ...runtimeState.offset },
|
||||
rundown: { ...runtimeState.rundown },
|
||||
timer: { ...runtimeState.timer },
|
||||
groupTimer: getGroupTimer(runtimeState),
|
||||
_timer: { ...runtimeState._timer },
|
||||
_rundown: { ...runtimeState._rundown },
|
||||
};
|
||||
|
||||
@@ -44,8 +44,6 @@ export type OntimeGroup = OntimeBaseEvent & {
|
||||
note: string;
|
||||
entries: EntryId[];
|
||||
targetDuration: MaybeNumber;
|
||||
useGroupTimer: boolean;
|
||||
timerType: TimerType;
|
||||
colour: string;
|
||||
custom: EntryCustomFields;
|
||||
// !==== RUNTIME METADATA ====! //
|
||||
|
||||
@@ -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,
|
||||
message: {
|
||||
timer: {
|
||||
text: '',
|
||||
|
||||
@@ -9,7 +9,6 @@ export type RuntimeStore = {
|
||||
// timer data
|
||||
clock: number;
|
||||
timer: TimerState;
|
||||
groupTimer: TimerState | null;
|
||||
|
||||
// messages service
|
||||
message: MessageState;
|
||||
|
||||
@@ -34,14 +34,7 @@ export {
|
||||
group as groupDef,
|
||||
milestone as milestoneDef,
|
||||
} from './src/rundown-utils/entryDefinitions.js';
|
||||
export {
|
||||
createDelay,
|
||||
createEvent,
|
||||
createGroup,
|
||||
createMilestone,
|
||||
makeString,
|
||||
validateGroupTimerType,
|
||||
} from './src/rundown-utils/entryUtils.js';
|
||||
export { createDelay, createEvent, createGroup, createMilestone, makeString } from './src/rundown-utils/entryUtils.js';
|
||||
|
||||
// time format utils
|
||||
export {
|
||||
|
||||
@@ -52,8 +52,6 @@ export const group: Omit<OntimeGroup, 'id'> = {
|
||||
note: '',
|
||||
entries: [],
|
||||
targetDuration: null,
|
||||
useGroupTimer: false,
|
||||
timerType: TimerType.CountDown,
|
||||
colour: '',
|
||||
custom: {},
|
||||
// !==== RUNTIME METADATA ====! //
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
|
||||
import { SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import { SupportedEntry, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import { generateId } from '../generate-id/generateId.js';
|
||||
import { validateEndAction, validateTimerType } from '../validate-events/validateEvent.js';
|
||||
@@ -67,8 +67,6 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
|
||||
note: patch.note ?? '',
|
||||
entries: patch.entries ?? [],
|
||||
targetDuration: patch.targetDuration ?? null,
|
||||
useGroupTimer: patch.useGroupTimer === true,
|
||||
timerType: validateGroupTimerType(patch.timerType),
|
||||
colour: makeString(patch.colour, ''),
|
||||
custom: patch.custom ?? {},
|
||||
revision: 0,
|
||||
@@ -79,16 +77,6 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
|
||||
};
|
||||
}
|
||||
|
||||
export function validateGroupTimerType(
|
||||
value: unknown,
|
||||
fallback: unknown = TimerType.CountDown,
|
||||
): TimerType.CountDown | TimerType.CountUp {
|
||||
if (value === TimerType.CountDown || value === TimerType.CountUp) {
|
||||
return value;
|
||||
}
|
||||
return fallback === TimerType.CountUp ? TimerType.CountUp : TimerType.CountDown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new milestone from an optional patch
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user