Compare commits

..

1 Commits

Author SHA1 Message Date
Carlos Valente 9d2c86d6be feat: count to group completion 2026-09-05 08:36:57 +02:00
61 changed files with 836 additions and 394 deletions
+2 -4
View File
@@ -7,15 +7,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#101010" /> <meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" /> <meta name="ontime" content="ontime - time keeping for live events" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Ontime" />
<link rel="apple-touch-icon" href="ontime-logo.png" /> <link rel="apple-touch-icon" href="ontime-logo.png" />
<link rel="icon" type="image/png" href="ontime-logo.png" /> <link rel="icon" type="image/png" href="ontime-logo.png" />
<link rel="manifest" href="site.webmanifest" />
<link rel="manifest" href="manifest.json" /> <link rel="manifest" href="manifest.json" />
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<title>Ontime</title> <title>ontime</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
+7 -9
View File
@@ -1,21 +1,19 @@
{ {
"name": "Ontime", "name": "ontime",
"short_name": "Ontime", "short_name": "ontime",
"icons": [ "icons": [
{ {
"src": "ontime-logo-192.png", "src": "favicon.ico",
"sizes": "192x192", "type": "image/x-icon"
"type": "image/png"
}, },
{ {
"src": "ontime-logo-512.png", "src": "ontime-logo.png",
"sizes": "512x512",
"type": "image/png" "type": "image/png"
} }
], ],
"scope": "./", "scope": "./",
"start_url": "./", "start_url": "./",
"display": "standalone", "display": "",
"theme_color": "#101010", "theme_color": "#121212",
"background_color": "#101010" "background_color": "#101010"
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

+8
View File
@@ -0,0 +1,8 @@
{
"name": "",
"short_name": "",
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
"theme_color": "#121212",
"background_color": "#101010",
"display": "standalone"
}
@@ -0,0 +1,81 @@
import { Playback, RuntimeStore, TimerPhase, TimerType, runtimeStorePlaceholder } from 'ontime-types';
import { resolveTimerDisplay } from '../useSocket.utils';
const eventTimer = { ...runtimeStorePlaceholder.timer, current: 5_000 };
const groupTimer = {
...runtimeStorePlaceholder.timer,
current: 25_000,
phase: TimerPhase.Default,
playback: Playback.Play,
};
function makeState(patch: Partial<RuntimeStore> = {}): 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,
});
});
});
@@ -2,8 +2,8 @@ import { MaybeString } from 'ontime-types';
import { RefObject, useCallback, useEffect } from 'react'; import { RefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>( function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef | null>, componentRef: RefObject<ComponentRef>,
scrollRef: RefObject<ScrollRef | null>, scrollRef: RefObject<ScrollRef>,
topOffset: number, topOffset: number,
) { ) {
if (!componentRef.current || !scrollRef.current) { if (!componentRef.current || !scrollRef.current) {
@@ -21,16 +21,18 @@ interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>; followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>; scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean; doFollow: boolean;
followTrigger: MaybeString; // this would be an entry id or null topOffset?: number;
getTopOffset: () => number; setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null
} }
export default function useFollowComponent({ export default function useFollowComponent({
followRef, followRef,
scrollRef, scrollRef,
doFollow, doFollow,
topOffset = 100,
setScrollFlag,
followTrigger, followTrigger,
getTopOffset,
}: UseFollowComponentProps) { }: UseFollowComponentProps) {
// when trigger moves, view should follow // when trigger moves, view should follow
useEffect(() => { useEffect(() => {
@@ -39,17 +41,25 @@ export default function useFollowComponent({
} }
if (followRef.current && scrollRef.current) { if (followRef.current && scrollRef.current) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded // Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
// resolve the offset after layout, so that measured values are up to date scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
scrollToComponent(followRef, scrollRef, getTopOffset()); setScrollFlag?.(false);
}); });
} }
}, [followTrigger, doFollow, followRef, scrollRef, getTopOffset]); }, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
const scrollToRefComponent = useCallback(() => { const scrollToRefComponent = useCallback(
scrollToComponent(followRef, scrollRef, getTopOffset()); (componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
}, [followRef, scrollRef, getTopOffset]); if (componentRef && containerRef) {
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, containerRef!, offset);
}
},
[followRef, scrollRef, topOffset],
);
return scrollToRefComponent; return scrollToRefComponent;
} }
+35 -24
View File
@@ -1,7 +1,8 @@
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage, TimerType } from 'ontime-types'; import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime'; import { useRuntimeStore } from '../stores/runtime';
import { sendSocket } from '../utils/socket'; import { sendSocket } from '../utils/socket';
import { resolveTimerDisplay } from './useSocket.utils';
const createSelector = const createSelector =
<T>(selector: (state: RuntimeStore) => T) => <T>(selector: (state: RuntimeStore) => T) =>
@@ -38,15 +39,19 @@ export const useExternalMessageInput = createSelector((state: RuntimeStore) => (
visible: state.message.timer.secondarySource === 'secondary', visible: state.message.timer.secondarySource === 'secondary',
})); }));
export const useMessagePreview = createSelector((state: RuntimeStore) => ({ export const useMessagePreview = createSelector((state: RuntimeStore) => {
blink: state.message.timer.blink, const timerDisplay = resolveTimerDisplay(state);
blackout: state.message.timer.blackout, return {
phase: state.timer.phase, blink: state.message.timer.blink,
secondarySource: state.message.timer.secondarySource, blackout: state.message.timer.blackout,
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text), phase: timerDisplay.time.phase,
timerType: state.eventNow?.timerType ?? null, secondarySource: state.message.timer.secondarySource,
countToEnd: state.eventNow?.countToEnd ?? false, showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
})); timerType: timerDisplay.timerType,
countToEnd: timerDisplay.countToEnd,
usesGroupTimer: timerDisplay.usesGroupTimer,
};
});
export const setMessage = { export const setMessage = {
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }), timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }),
@@ -230,20 +235,26 @@ export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
/* ======================= View specific subscriptions ======================= */ /* ======================= View specific subscriptions ======================= */
export const useTimerSocket = createSelector((state: RuntimeStore) => ({ export const useTimerSocket = createSelector((state: RuntimeStore) => {
eventNext: state.eventNext, const timerDisplay = resolveTimerDisplay(state);
eventNow: state.eventNow, return {
message: state.message, eventNext: state.eventNext,
time: state.timer, eventNow: state.eventNow,
clock: state.clock, message: state.message,
timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown, time: timerDisplay.time,
countToEndNow: state.eventNow?.countToEnd ?? false, eventTimer: timerDisplay.eventTimer,
auxTimer: { clock: state.clock,
aux1: state.auxtimer1.current, timerTypeNow: timerDisplay.timerType,
aux2: state.auxtimer2.current, eventTimerType: timerDisplay.eventTimerType,
aux3: state.auxtimer3.current, countToEndNow: timerDisplay.countToEnd,
}, usesGroupTimer: timerDisplay.usesGroupTimer,
})); auxTimer: {
aux1: state.auxtimer1.current,
aux2: state.auxtimer2.current,
aux3: state.auxtimer3.current,
},
};
});
export const useCountdownSocket = createSelector((state: RuntimeStore) => ({ export const useCountdownSocket = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback, playback: state.timer.playback,
@@ -0,0 +1,27 @@
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,
};
}
@@ -6,6 +6,6 @@ import { useEffect } from 'react';
*/ */
export function useWindowTitle(title: string) { export function useWindowTitle(title: string) {
useEffect(() => { useEffect(() => {
document.title = `Ontime - ${title}`; document.title = `ontime - ${title}`;
}, []); }, []);
} }
@@ -1,6 +1,6 @@
import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types'; import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry, TimerType } from 'ontime-types';
import { initRundownMetadata } from '../rundownMetadata'; import { getFlatRundownMetadata, initRundownMetadata } from '../rundownMetadata';
describe('initRundownMetadata()', () => { describe('initRundownMetadata()', () => {
it('processes nested rundown data', () => { it('processes nested rundown data', () => {
@@ -300,3 +300,36 @@ describe('initRundownMetadata()', () => {
}); });
}); });
}); });
describe('getFlatRundownMetadata()', () => {
it('exposes group timer settings on a group and its events', () => {
const group = {
id: 'group',
type: SupportedEntry.Group,
entries: ['event'],
colour: 'red',
useGroupTimer: true,
timerType: TimerType.CountUp,
} as OntimeGroup;
const event = {
id: 'event',
type: SupportedEntry.Event,
parent: group.id,
timeStart: 0,
timeEnd: 1,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent;
const flat = getFlatRundownMetadata(
{ entries: { [group.id]: group, [event.id]: event }, flatOrder: [group.id, event.id] },
null,
);
expect(flat[0]).toMatchObject({ groupUsesTimer: true, groupTimerType: TimerType.CountUp });
expect(flat[1]).toMatchObject({ groupUsesTimer: true, groupTimerType: TimerType.CountUp });
});
});
@@ -3,9 +3,11 @@ import {
OntimeDelay, OntimeDelay,
OntimeEntry, OntimeEntry,
OntimeEvent, OntimeEvent,
OntimeGroup,
OntimeMilestone, OntimeMilestone,
PlayableEvent, PlayableEvent,
Rundown, Rundown,
TimerType,
isOntimeEvent, isOntimeEvent,
isOntimeGroup, isOntimeGroup,
isPlayableEvent, isPlayableEvent,
@@ -29,7 +31,11 @@ export type RundownMetadata = {
isFirstAfterGroup: boolean; isFirstAfterGroup: boolean;
}; };
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata; export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T &
RundownMetadata & {
groupUsesTimer?: boolean;
groupTimerType?: TimerType;
};
export const lastMetadataKey = 'LAST'; export const lastMetadataKey = 'LAST';
@@ -65,10 +71,23 @@ export function getFlatRundownMetadata(
): ExtendedEntry[] { ): ExtendedEntry[] {
const { process } = initRundownMetadata(selectedEventId); const { process } = initRundownMetadata(selectedEventId);
const flatRundown: ExtendedEntry[] = []; const flatRundown: ExtendedEntry[] = [];
let activeGroup: OntimeGroup | null = null;
for (const id of data.flatOrder) { for (const id of data.flatOrder) {
const entry = data.entries[id]; const entry = data.entries[id];
const extendedEntry = { ...entry, ...process(entry) }; if (isOntimeGroup(entry)) {
activeGroup = entry;
} else if (entry.parent !== activeGroup?.id) {
activeGroup = null;
}
const timerGroup = isOntimeGroup(entry) ? entry : activeGroup;
const extendedEntry = {
...entry,
...process(entry),
groupUsesTimer: timerGroup?.useGroupTimer ?? false,
groupTimerType: timerGroup?.timerType,
};
flatRundown.push(extendedEntry); flatRundown.push(extendedEntry);
} }
@@ -40,7 +40,7 @@ export default function ShutdownPanel() {
{!isOntimeCloud && ( {!isOntimeCloud && (
<Panel.Section> <Panel.Section>
<Button variant='destructive' onClick={handler.open} disabled={!canShutdown}> <Button variant='destructive' onClick={handler.open} disabled={!canShutdown}>
Shutdown Ontime Shutdown ontime
</Button> </Button>
{!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>} {!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>}
</Panel.Section> </Panel.Section>
@@ -27,6 +27,13 @@
border-top: 1px solid $white-7; border-top: 1px solid $white-7;
} }
.timerSource {
color: $active-indicator;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.blackout { .blackout {
display: none; display: none;
} }
@@ -1,5 +1,5 @@
import { TimerPhase, TimerType } from 'ontime-types'; import { TimerPhase, TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5'; import { IoArrowDown, IoArrowUp, IoBan, IoTime, IoTimerOutline } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu'; import { LuArrowDownToLine } from 'react-icons/lu';
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils'; import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
@@ -20,7 +20,8 @@ const secondarySourceLabels: Record<string, string> = {
}; };
export default function TimerPreview() { export default function TimerPreview() {
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview(); const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType, usesGroupTimer } =
useMessagePreview();
const { data } = useViewSettings(); const { data } = useViewSettings();
const main = (() => { const main = (() => {
@@ -35,7 +36,9 @@ export default function TimerPreview() {
const secondary = (() => { const secondary = (() => {
// message is a fullscreen overlay or secondary is not active // message is a fullscreen overlay or secondary is not active
if (showTimerMessage || !secondarySource) return null; if (showTimerMessage) return null;
if (usesGroupTimer) return 'Event timer';
if (!secondarySource) return null;
// we need to check aux first since it takes priority // we need to check aux first since it takes priority
return secondarySourceLabels[secondarySource]; return secondarySourceLabels[secondarySource];
@@ -55,6 +58,7 @@ export default function TimerPreview() {
<div className={style.preview}> <div className={style.preview}>
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} /> <CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
<div className={contentClasses}> <div className={contentClasses}>
{usesGroupTimer && <div className={style.timerSource}>Group timer</div>}
<div <div
className={style.mainContent} className={style.mainContent}
data-phase={showColourOverride && phase} data-phase={showColourOverride && phase}
@@ -65,6 +69,14 @@ export default function TimerPreview() {
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>} {secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
</div> </div>
<div className={style.eventStatus}> <div className={style.eventStatus}>
<Tooltip
text='Timer display controlled by group'
render={<span />}
className={style.statusIcon}
data-active={usesGroupTimer}
>
<IoTimerOutline />
</Tooltip>
<Tooltip <Tooltip
text='Time type: Count down' text='Time type: Count down'
render={<span />} render={<span />}
@@ -18,12 +18,6 @@
padding-bottom: 95vh; padding-bottom: 95vh;
} }
.groupSection {
display: flex;
flex-direction: column;
gap: 2px;
}
.editPrompt { .editPrompt {
position: fixed; position: fixed;
z-index: $zindex-dialog; z-index: $zindex-dialog;
+14 -27
View File
@@ -1,5 +1,5 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types'; import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill'; import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
@@ -25,10 +25,7 @@ import { OperatorData, useOperatorData } from './useOperatorData';
import style from './Operator.module.scss'; import style from './Operator.module.scss';
/** Keeps the running event clear of the list edge when no group header is pinned above it */ const selectedOffset = 50;
const edgeOffset = 50;
/** How far the running event may drift from where we placed it before we stop following */
const followTolerance = 50;
export default function OperatorLoader() { export default function OperatorLoader() {
const { data, status } = useOperatorData(); const { data, status } = useOperatorData();
@@ -57,20 +54,11 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
// The header height varies with the viewport, so measure it at scroll time.
const getTopOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// Sit right under the pinned header, so it covers the previous event instead of half of it.
return header ? header.offsetHeight + 2 : edgeOffset;
}, []);
const scrollToComponent = useFollowComponent({ const scrollToComponent = useFollowComponent({
followRef: selectedRef, followRef: selectedRef,
scrollRef, scrollRef,
doFollow: !lockAutoScroll, doFollow: !lockAutoScroll,
getTopOffset, topOffset: selectedOffset,
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
@@ -94,16 +82,15 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
// prevent considering automated scrolls as user scrolls // prevent considering automated scrolls as user scrolls
const handleUserScroll = () => { const handleUserScroll = () => {
if (!selectedRef.current || !scrollRef.current) { if (selectedRef?.current && scrollRef?.current) {
return; const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
} }
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
// Measure the drift from where an automated scroll would place the event.
const distanceFromTop = selectedRect.top - scrollerRect.top - getTopOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > followTolerance;
setLockAutoScroll(hasScrolledOutOfThreshold);
}; };
const throttledHandleScroll = throttle(handleUserScroll, 1000); const throttledHandleScroll = throttle(handleUserScroll, 1000);
@@ -199,9 +186,9 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
} }
return ( return (
<div className={style.groupSection} key={entry.id}> <Fragment key={entry.id}>
<OperatorGroup <OperatorGroup
ref={isCurrentParent ? stickyHeaderRef : undefined} key={entry.id}
title={entry.title} title={entry.title}
colour={entry.colour} colour={entry.colour}
count={entry.entries.length} count={entry.entries.length}
@@ -252,7 +239,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
/> />
); );
})} })}
</div> </Fragment>
); );
} }
return null; return null;
@@ -1,20 +1,13 @@
.group { .group {
width: 100%; width: 100%;
/* Padding is kept under the min-height so a single line fits without the list having to shrink the header,
while a taller title still grows the row. */
min-height: 2.5rem; min-height: 2.5rem;
padding: 0.25rem 0.75rem; padding: 0.4rem 0.75rem;
border-left: 0.35rem solid var(--group-colour, $gray-500); border-left: 0.35rem solid var(--group-colour, $gray-500);
background-color: $gray-1350; background-color: $gray-1350;
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%); background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
position: sticky;
/* Cover the list padding so rows cannot scroll above the header. */
top: -0.25rem;
z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
@@ -1,4 +1,4 @@
import { type CSSProperties, type Ref, memo } from 'react'; import { CSSProperties, memo } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time'; import { formatDuration } from '../../../common/utils/time';
@@ -10,16 +10,15 @@ interface OperatorGroup {
colour: string; colour: string;
count: number; count: number;
duration: number; duration: number;
ref?: Ref<HTMLDivElement>;
} }
export default memo(OperatorGroup); export default memo(OperatorGroup);
function OperatorGroup({ title, colour, count, duration, ref }: OperatorGroup) { function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
const groupColour = colour || '#929292'; const groupColour = colour || '#929292';
const groupColours = getAccessibleColour(groupColour); const groupColours = getAccessibleColour(groupColour);
return ( return (
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties} ref={ref}> <div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
<span className={style.title}>{title}</span> <span className={style.title}>{title}</span>
<span className={style.meta}> <span className={style.meta}>
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span> <span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
@@ -32,6 +32,12 @@
gap: 1rem; gap: 1rem;
} }
.timerDisplaySettings {
display: flex;
flex-direction: column;
gap: 1rem;
}
.column { .column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1,10 +1,12 @@
import { MaybeNumber, OntimeGroup } from 'ontime-types'; import { MaybeNumber, OntimeGroup, TimerType } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { useCallback } from 'react'; import { useCallback } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import AppLink from '../../../common/components/link/app-link/AppLink'; 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 { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getOffsetState } from '../../../common/utils/offset'; import { getOffsetState } from '../../../common/utils/offset';
@@ -107,6 +109,38 @@ export default function GroupEditor({ group }: GroupEditorProps) {
<EventTextArea field='note' label='Note' initialValue={group.note} submitHandler={handleSubmit} /> <EventTextArea field='note' label='Note' initialValue={group.note} submitHandler={handleSubmit} />
</div> </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}> <div className={style.column}>
<Editor.Title> <Editor.Title>
Custom Fields Custom Fields
@@ -335,6 +335,7 @@ export default function RundownEvent({
eventIndex={eventIndex} eventIndex={eventIndex}
endAction={endAction} endAction={endAction}
timerType={timerType} timerType={timerType}
groupTimerType={parentGroup?.useGroupTimer ? parentGroup.timerType : undefined}
title={title} title={title}
note={note} note={note}
delay={delay} delay={delay}
@@ -9,6 +9,7 @@ import {
IoPlayForward, IoPlayForward,
IoPlaySkipForward, IoPlaySkipForward,
IoTime, IoTime,
IoTimerOutline,
} from 'react-icons/io5'; } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu'; import { LuArrowDownToLine } from 'react-icons/lu';
@@ -35,6 +36,7 @@ interface RundownEventInnerProps {
eventIndex: number; eventIndex: number;
endAction: EndAction; endAction: EndAction;
timerType: TimerType; timerType: TimerType;
groupTimerType?: TimerType;
title: string; title: string;
note: string; note: string;
delay: number; delay: number;
@@ -62,6 +64,7 @@ function RundownEventInner({
countToEnd, countToEnd,
endAction, endAction,
timerType, timerType,
groupTimerType,
title, title,
note, note,
delay, delay,
@@ -150,6 +153,14 @@ function RundownEventInner({
{loaded && <EventBlockProgressBar />} {loaded && <EventBlockProgressBar />}
</div> </div>
<div className={style.eventStatus} tabIndex={-1}> <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 />}> <Tooltip text={`Time type: ${timerType}`} render={<span />}>
<TimerIcon type={timerType} className={style.statusIcon} /> <TimerIcon type={timerType} className={style.statusIcon} />
</Tooltip> </Tooltip>
@@ -55,6 +55,14 @@
gap: 0.5rem; gap: 0.5rem;
} }
.timerIndicator {
display: grid;
flex: 0 0 1.5rem;
place-items: center;
color: $active-indicator;
font-size: 1rem;
}
.metaRow { .metaRow {
display: flex; display: flex;
gap: $block-clearance; // same as RundownEvent.eventTimers gap: $block-clearance; // same as RundownEvent.eventTimers
@@ -1,6 +1,6 @@
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { EntryId, OntimeGroup } from 'ontime-types'; import { EntryId, OntimeGroup, TimerType } from 'ontime-types';
import { MILLIS_PER_MINUTE } from 'ontime-utils'; import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { MouseEvent, useCallback, useRef } from 'react'; import { MouseEvent, useCallback, useRef } from 'react';
import { import {
@@ -9,6 +9,7 @@ import {
IoDuplicateOutline, IoDuplicateOutline,
IoFolderOpenOutline, IoFolderOpenOutline,
IoReorderTwo, IoReorderTwo,
IoTimerOutline,
IoTrash, IoTrash,
IoLockClosed, IoLockClosed,
} from 'react-icons/io5'; } from 'react-icons/io5';
@@ -174,6 +175,14 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
<div className={style.header}> <div className={style.header}>
<div className={style.titleRow}> <div className={style.titleRow}>
<TitleEditor title={data.title} entryId={data.id} placeholder='Group title' /> <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)}> <IconButton aria-label='Collapse' variant='subtle-white' onClick={() => onCollapse(!collapsed, data.id)}>
{collapsed ? <IoChevronUp /> : <IoChevronDown />} {collapsed ? <IoChevronUp /> : <IoChevronDown />}
</IconButton> </IconButton>
+3 -31
View File
@@ -100,20 +100,7 @@ $item-height: 3.5rem;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
padding-bottom: 95vh; padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
}
/* Flex prevents row margins collapsing and bounds the sticky header to its group. */
.sub-section {
display: flex;
flex-direction: column;
/* The select view renders the same cards in a flat list. */
.sub--group {
position: sticky;
top: 0;
z-index: 1;
}
} }
/* ====================== LIST-ITEM ======================*/ /* ====================== LIST-ITEM ======================*/
@@ -209,14 +196,9 @@ $item-height: 3.5rem;
.sub--group { .sub--group {
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325); box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
/* The opaque base prevents rows showing through; background shorthand cannot layer this colour. */ background:
background-color: var(--background-color-override, $viewer-background-color);
background-image:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%), linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
linear-gradient( var(--card-background-color-override, $viewer-card-bg-color);
var(--card-background-color-override, $viewer-card-bg-color),
var(--card-background-color-override, $viewer-card-bg-color)
);
.sub__binder { .sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color)); background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
@@ -248,16 +230,6 @@ $item-height: 3.5rem;
} }
} }
/* Reserve the green fill for the running event. */
.sub--group.sub--live {
box-shadow: inset 0 0 0 2px $active-green;
}
/* Keep the armed state quieter than the live ring. */
.sub--group.sub--armed {
box-shadow: inset 0 0 0 2px $gray-1000;
}
.sub__title { .sub__title {
grid-area: title; grid-area: title;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@@ -1,6 +1,6 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types'; import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils'; import { dayInMs } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { IoPencil } from 'react-icons/io5'; import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
@@ -22,7 +22,6 @@ import {
CountdownTarget, CountdownTarget,
extendEventData, extendEventData,
getIsLive, getIsLive,
groupSubscriptionTargets,
isOutsideRange, isOutsideRange,
preferredFormat12, preferredFormat12,
preferredFormat24, preferredFormat24,
@@ -49,22 +48,11 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const sections = useMemo(() => groupSubscriptionTargets(subscribedEvents), [subscribedEvents]);
// Responsive sizing and wrapped titles make the sticky header height variable, so measure it at scroll time.
const getStickyOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// Preserve the combined margins between the header and running event.
return header ? header.offsetHeight + 4 : 0;
}, []);
const scrollToComponent = useFollowComponent({ const scrollToComponent = useFollowComponent({
followRef: selectedRef, followRef: selectedRef,
scrollRef, scrollRef,
doFollow: !lockAutoScroll, doFollow: !lockAutoScroll,
getTopOffset: getStickyOffset, topOffset: 0,
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
@@ -87,16 +75,15 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
// prevent considering automated scrolls as user scrolls // prevent considering automated scrolls as user scrolls
const handleUserScroll = () => { const handleUserScroll = () => {
if (!selectedRef.current || !scrollRef.current) { if (selectedRef?.current && scrollRef?.current) {
return; const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
} }
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
// Keep the threshold relative to the visible rows below the sticky header.
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
setLockAutoScroll(hasScrolledOutOfThreshold);
}; };
const throttledHandleScroll = throttle(handleUserScroll, 1000); const throttledHandleScroll = throttle(handleUserScroll, 1000);
@@ -111,63 +98,41 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
return ( return (
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> <div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{sections.map((section) => { {subscribedEvents.map((event) => {
const rows = section.group ? [section.group, ...section.events] : section.events; // while a group is live, surface the running event's title as the secondary line
// the running event anchors the scroll, the group header stays pinned above it const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
const anchorId = section.events.find((event) => getIsLive(event.id, selectedEventId, playback))?.id ?? null; const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
// a subscribed group is live when any of its children is the selected/running event
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
const isArmed = !isLive && activeEntryId === selectedEventId;
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return ( return (
<div key={section.group?.id ?? rows[0].id} className='sub-section'> <div
{rows.map((event) => { key={event.id}
// while a group is live, surface the running event's title as the secondary line ref={isLive ? selectedRef : undefined}
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined; className={cx([
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource); 'sub',
const isGroupedEvent = !event.isGroup && Boolean(event.parent); isLive && 'sub--live',
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id; isArmed && 'sub--armed',
// a subscribed group is live when any of its children is the selected/running event event.isGroup && 'sub--group',
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false; isGroupedEvent && 'sub--in-group',
const isArmed = !isLive && activeEntryId === selectedEventId; ])}
// only ever hand the ref to a single row, sharing it would null it out on the next commit data-testid={event.cue}
const isAnchor = isLive && (anchorId === null || event.id === anchorId); >
const rowRef = isAnchor ? selectedRef : event.isGroup && anchorId ? stickyHeaderRef : undefined; <div
const countdownEvent = extendEventData( className='sub__binder'
event, style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
currentDay, />
actualStart, <ScheduleTime event={countdownEvent} showExpected={showExpected} />
plannedStart, <SubscriptionStatus event={countdownEvent} />
offset, <div className={cx(['sub__title', !displayTitle && 'subdued'])}>
mode, {event.isGroup && <span className='sub__eyebrow'>Group</span>}
reportData, {displayTitle}
); </div>
const displayTitle = getPropertyValue(event, mainSource ?? 'title'); {secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
return (
<div
key={event.id}
ref={rowRef}
className={cx([
'sub',
isLive && 'sub--live',
isArmed && 'sub--armed',
event.isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
data-testid={event.cue}
>
<div
className='sub__binder'
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
/>
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
{displayTitle}
</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div>
);
})}
</div> </div>
); );
})} })}
@@ -1,7 +1,7 @@
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types'; import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { ExtendedEntry } from '../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { CountdownTarget, groupSubscriptionTargets, resolveSubscriptionTarget } from './countdown.utils'; import { resolveSubscriptionTarget } from './countdown.utils';
/** /**
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes. * Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
@@ -126,89 +126,3 @@ describe('resolveSubscriptionTarget()', () => {
expect(result?.liveEntry).toBeNull(); expect(result?.liveEntry).toBeNull();
}); });
}); });
describe('groupSubscriptionTargets()', () => {
/**
* Resolves a group the same way the view does, so that the tests exercise the real target shape
* (a resolved group carries type Event, so the helper cannot rely on the entry type)
*/
function resolveGroup(group: ExtendedEntry<OntimeGroup>, flat: ExtendedEntry<OntimeEntry>[]): CountdownTarget {
const resolved = resolveSubscriptionTarget(group, flat);
if (resolved === null) {
throw new Error('test setup: group has no playable children');
}
return resolved;
}
it('returns no sections for an empty subscription list', () => {
expect(groupSubscriptionTargets([])).toEqual([]);
});
it('gives each ungrouped event its own section', () => {
const e1 = makeEvent({ id: 'e1' });
const e2 = makeEvent({ id: 'e2' });
expect(groupSubscriptionTargets([e1, e2])).toEqual([
{ group: null, events: [e1] },
{ group: null, events: [e2] },
]);
});
it('absorbs the children of a subscribed group into its section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const c2 = makeEvent({ id: 'c2', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1, c2]);
expect(groupSubscriptionTargets([resolved, c1, c2])).toEqual([{ group: resolved, events: [c1, c2] }]);
});
it('keeps a subscribed group with no subscribed children as an empty section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved])).toEqual([{ group: resolved, events: [] }]);
});
it('does not absorb an event which belongs to a different group', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const flat = [group1, c1, group2, c2];
const resolved1 = resolveGroup(group1, flat);
const resolved2 = resolveGroup(group2, flat);
expect(groupSubscriptionTargets([resolved1, c1, resolved2, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: resolved2, events: [c2] },
]);
});
it('does not absorb an event whose parent group is not subscribed', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const resolved1 = resolveGroup(group1, [group1, c1, group2, c2]);
// only the first group is subscribed, so the second group's child stands alone
expect(groupSubscriptionTargets([resolved1, c1, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: null, events: [c2] },
]);
});
it('closes a section when an ungrouped event follows a group', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const e1 = makeEvent({ id: 'e1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved, c1, e1])).toEqual([
{ group: resolved, events: [c1] },
{ group: null, events: [e1] },
]);
});
});
@@ -252,42 +252,6 @@ export function resolveSubscriptionTarget(
}; };
} }
/**
* A subscribed group along with the subscribed events which belong to it.
* Events without a subscribed parent group form their own section with no group.
*/
export type CountdownSection = {
group: CountdownTarget | null;
events: CountdownTarget[];
};
/**
* Folds the flat, rundown ordered subscription targets into sections.
* A group opens a section which absorbs the following targets that declare it as parent,
* which allows the group to be rendered as a sticky header for its own events.
*/
export function groupSubscriptionTargets(targets: CountdownTarget[]): CountdownSection[] {
const sections: CountdownSection[] = [];
for (const target of targets) {
// resolveSubscriptionTarget spreads the first child, so we cannot rely on the entry type here
if (target.isGroup) {
sections.push({ group: target, events: [] });
continue;
}
const previousSection = sections.at(-1);
if (previousSection?.group?.id === target.parent) {
previousSection.events.push(target);
continue;
}
sections.push({ group: null, events: [target] });
}
return sections;
}
export function extendEventData( export function extendEventData(
event: CountdownTarget, event: CountdownTarget,
currentDay: number, currentDay: number,
@@ -0,0 +1,13 @@
.timerOverrideCell {
display: grid;
grid-template-columns: minmax(0, 1fr) 1.5rem;
align-items: center;
gap: 0.25rem;
}
.timerOverrideIndicator {
display: grid;
place-items: center;
color: $active-indicator;
font-size: 1rem;
}
@@ -1,8 +1,18 @@
import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types'; import {
CustomFields,
TimeStrategy,
TimerType,
URLPreset,
isOntimeDelay,
isOntimeEvent,
isOntimeGroup,
} from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { IoTimerOutline } from 'react-icons/io5';
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator'; import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata'; import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { formatDuration, formatTime } from '../../../../common/utils/time'; import { formatDuration, formatTime } from '../../../../common/utils/time';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
@@ -17,6 +27,8 @@ import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell'; import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput'; import TimeInput from './TimeInput';
import style from './cuesheetColsFactory.module.scss';
function getColumnLabel(column: CuesheetCellContext['column']): string { function getColumnLabel(column: CuesheetCellContext['column']): string {
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id; return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
} }
@@ -193,17 +205,38 @@ function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
} }
const canWrite = column.columnDef.meta?.canWrite; const canWrite = column.columnDef.meta?.canWrite;
if (!canWrite) { const content = canWrite ? (
return <GhostedText>{initialValue}</GhostedText>;
}
return (
<SingleLineCell <SingleLineCell
initialValue={initialValue as string} initialValue={initialValue as string}
fieldId={column.id} fieldId={column.id}
fieldLabel={getColumnLabel(column)} fieldLabel={getColumnLabel(column)}
handleUpdate={update} handleUpdate={update}
/> />
) : (
<GhostedText>{initialValue}</GhostedText>
);
if (column.id !== 'title') {
return content;
}
const isGroupOverride = isOntimeGroup(row.original) && row.original.useGroupTimer;
const isEventOverride = isOntimeEvent(row.original) && row.original.groupUsesTimer;
if (!isGroupOverride && !isEventOverride) {
return content;
}
const timerType = isOntimeGroup(row.original) ? row.original.timerType : row.original.groupTimerType;
const direction = timerType === TimerType.CountUp ? 'count up' : 'count down';
const tooltip = isGroupOverride ? `Group timer (${direction})` : `Timer display controlled by group (${direction})`;
return (
<div className={style.timerOverrideCell}>
{content}
<Tooltip text={tooltip} render={<span className={style.timerOverrideIndicator} />}>
<IoTimerOutline />
</Tooltip>
</div>
); );
} }
@@ -35,6 +35,14 @@
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
.timer-source {
color: $viewer-label-color;
font-size: 0.75rem;
font-weight: 600;
text-align: center;
text-transform: uppercase;
}
.timer { .timer {
opacity: 1; opacity: 1;
font-family: $viewer-font-family; font-family: $viewer-font-family;
@@ -7,6 +7,7 @@ import { cx } from '../../../common/utils/styleUtils';
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils'; import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
import { import {
getEstimatedFontSize, getEstimatedFontSize,
getEventTimerSecondary,
getIsPlaying, getIsPlaying,
getSecondaryDisplay, getSecondaryDisplay,
getShowMessage, getShowMessage,
@@ -23,7 +24,18 @@ interface PipTimerProps {
} }
export function PipTimer({ viewSettings }: PipTimerProps) { export function PipTimer({ viewSettings }: PipTimerProps) {
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket(); const {
eventNow,
message,
time,
eventTimer,
clock,
timerTypeNow,
eventTimerType,
countToEndNow,
usesGroupTimer,
auxTimer,
} = useTimerSocket();
// gather modifiers // gather modifiers
const showOverlay = getShowMessage(message.timer); const showOverlay = getShowMessage(message.timer);
@@ -59,7 +71,9 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
return null; return null;
})(); })();
const secondaryContent = getSecondaryDisplay(message, currentAux, 'min', false, true, false); const secondaryContent = usesGroupTimer
? getEventTimerSecondary(eventTimer, eventTimerType, clock, 'min', false, true)
: getSecondaryDisplay(message, currentAux, 'min', false, true, false);
// gather presentation styles // gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger); const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
@@ -77,6 +91,7 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
</div> </div>
<div className='timer-container'> <div className='timer-container'>
{usesGroupTimer && <div className='timer-source'>Group timer</div>}
<div <div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])} className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }} style={{ fontSize: `${timerFontSize}vw` }}
@@ -96,11 +111,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])} className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current} now={time.current}
complete={totalTime} complete={totalTime}
eventId={eventNow?.id} eventId={usesGroupTimer ? undefined : eventNow?.id}
normalColor={viewSettings.normalColor} normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning} warning={usesGroupTimer ? undefined : eventNow?.timeWarning}
warningColor={viewSettings.warningColor} warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger} danger={usesGroupTimer ? undefined : eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor} dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished} hideOvertime={!showFinished}
/> />
+8
View File
@@ -89,6 +89,14 @@
width: 100%; width: 100%;
overflow: hidden; 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 { .end-message {
text-align: center; text-align: center;
font-size: 11.5vw; font-size: 11.5vw;
+31 -13
View File
@@ -22,6 +22,7 @@ import { getTimerOptions, useTimerOptions } from './timer.options';
import { import {
getCardData, getCardData,
getEstimatedFontSize, getEstimatedFontSize,
getEventTimerSecondary,
getIsPlaying, getIsPlaying,
getSecondaryDisplay, getSecondaryDisplay,
getShowClock, getShowClock,
@@ -52,7 +53,19 @@ export default function TimerLoader() {
} }
function Timer({ customFields, projectData, isMirrored, settings, viewSettings, entries }: TimerData) { function Timer({ customFields, projectData, isMirrored, settings, viewSettings, entries }: TimerData) {
const { eventNext, eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket(); const {
eventNext,
eventNow,
message,
time,
eventTimer,
clock,
timerTypeNow,
eventTimerType,
countToEndNow,
usesGroupTimer,
auxTimer,
} = useTimerSocket();
const { const {
hideClock, hideClock,
hideCards, hideCards,
@@ -78,7 +91,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes'); const localisedMinutes = getLocalizedString('common.minutes');
const showSoundPrompt = useTimerSound(time.phase, endSound); const showSoundPrompt = useTimerSound(eventTimer.phase, endSound);
// gather modifiers // gather modifiers
const viewTimerType = timerType ?? timerTypeNow; const viewTimerType = timerType ?? timerTypeNow;
@@ -128,14 +141,18 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
return null; return null;
})(); })();
const secondaryContent = getSecondaryDisplay( const secondaryContent =
message, usesGroupTimer && !hideSecondary
currentAux, ? getEventTimerSecondary(
localisedMinutes, eventTimer,
hideTimerSeconds, eventTimerType,
removeLeadingZeros, clock,
hideSecondary, localisedMinutes,
); hideTimerSeconds,
removeLeadingZeros,
timeformat,
)
: getSecondaryDisplay(message, currentAux, localisedMinutes, hideTimerSeconds, removeLeadingZeros, hideSecondary);
// gather presentation styles // gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger); const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
@@ -176,6 +193,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
{showClock && <TimerAutoTickingClock clockFormat={timeformat} />} {showClock && <TimerAutoTickingClock clockFormat={timeformat} />}
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}> <div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
{usesGroupTimer && <div className='timer-source'>Group timer</div>}
{showEndMessage ? ( {showEndMessage ? (
<FitText mode='multi' min={64} max={256} className='end-message'> <FitText mode='multi' min={64} max={256} className='end-message'>
{freezeMessage} {freezeMessage}
@@ -202,11 +220,11 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])} className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current} now={time.current}
complete={totalTime} complete={totalTime}
eventId={eventNow?.id} eventId={usesGroupTimer ? undefined : eventNow?.id}
normalColor={viewSettings.normalColor} normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning} warning={usesGroupTimer ? undefined : eventNow?.timeWarning}
warningColor={viewSettings.warningColor} warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger} danger={usesGroupTimer ? undefined : eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor} dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished} hideOvertime={!showFinished}
/> />
@@ -1,6 +1,32 @@
import { TimerPhase } from 'ontime-types'; import { TimerPhase, TimerType } from 'ontime-types';
import { shouldPlayEndSound } from '../timer.utils'; import { getEventTimerSecondary, shouldPlayEndSound } from '../timer.utils';
describe('getEventTimerSecondary()', () => {
it('formats the event countdown as a labelled secondary value', () => {
expect(
getEventTimerSecondary({ current: 65_000, elapsed: 5_000 }, TimerType.CountDown, 0, 'min', false, false),
).toBe('Event timer 00:01:05');
});
it('preserves the event count-up display', () => {
expect(getEventTimerSecondary({ current: 55_000, elapsed: 5_000 }, TimerType.CountUp, 0, 'min', false, false)).toBe(
'Event timer 00:00:05',
);
});
it('falls back to remaining time when the event timer is hidden', () => {
expect(getEventTimerSecondary({ current: 5_000, elapsed: 55_000 }, TimerType.None, 0, 'min', false, false)).toBe(
'Event timer 00:00:05',
);
});
it('shows event progress instead of wall-clock time for clock events', () => {
expect(
getEventTimerSecondary({ current: 5_000, elapsed: 55_000 }, TimerType.Clock, 12_000, 'min', false, false),
).toBe('Event timer 00:00:05');
});
});
describe('shouldPlayEndSound()', () => { describe('shouldPlayEndSound()', () => {
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])( test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
+21 -1
View File
@@ -6,11 +6,12 @@ import {
RundownEntries, RundownEntries,
TimerMessage, TimerMessage,
TimerPhase, TimerPhase,
TimerState,
TimerType, TimerType,
} from 'ontime-types'; } from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils'; import { isPlaybackActive } from 'ontime-utils';
import { getFormattedTimer, getPropertyValue } from '../common/viewUtils'; import { getFormattedTimer, getPropertyValue, getTimerByType } from '../common/viewUtils';
/** /**
* Whether a message should be shown * Whether a message should be shown
@@ -144,6 +145,25 @@ export function getSecondaryDisplay(
return; 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? * What should we be showing in the cards?
*/ */
+2 -2
View File
@@ -6,7 +6,7 @@
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'unsafe-inline'; script-src 'self'" content="default-src 'self'; style-src 'unsafe-inline'; script-src 'self'"
/> />
<title>Ontime</title> <title>ontime</title>
<style> <style>
body { body {
-webkit-user-select: none; -webkit-user-select: none;
@@ -92,7 +92,7 @@
<body> <body>
<div class="container"> <div class="container">
<img src="../assets/logo.png" /> <img src="../assets/logo.png" />
<h1>Ontime · event timers</h1> <h1>ontime · event timers</h1>
<div class="lds-ellipsis"> <div class="lds-ellipsis">
<div></div> <div></div>
<div></div> <div></div>
@@ -417,7 +417,7 @@ export function migrateRundown(
timeEnd: null, timeEnd: null,
duration: 0, duration: 0,
isFirstLinked: false, isFirstLinked: false,
}); } as unknown as OntimeEntry);
} else if (entry.type === 'delay') { } else if (entry.type === 'delay') {
append({ id: entry.id, type: SupportedEntry.Delay, duration: entry.duration, parent }); append({ id: entry.id, type: SupportedEntry.Delay, duration: entry.duration, parent });
} }
@@ -1,4 +1,4 @@
import { CustomFields, OntimeEvent, OntimeGroup, Rundown, SupportedEntry } from 'ontime-types'; import { CustomFields, OntimeEvent, OntimeGroup, Rundown, SupportedEntry, TimerType } from 'ontime-types';
import { makeNewRundown } from '../../../models/dataModel.js'; import { makeNewRundown } from '../../../models/dataModel.js';
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js'; import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js';
@@ -276,7 +276,13 @@ describe('parseRundown()', () => {
expect(parsedRundown.order).toStrictEqual(['group']); expect(parsedRundown.order).toStrictEqual(['group']);
expect(parsedRundown.flatOrder).toStrictEqual(['group', '1', '2']); expect(parsedRundown.flatOrder).toStrictEqual(['group', '1', '2']);
expect(parsedRundown.entries).toMatchObject({ expect(parsedRundown.entries).toMatchObject({
group: { id: 'group', type: SupportedEntry.Group, entries: ['1', '2'] }, group: {
id: 'group',
type: SupportedEntry.Group,
entries: ['1', '2'],
useGroupTimer: false,
timerType: TimerType.CountDown,
},
'1': { id: '1', type: SupportedEntry.Event }, '1': { id: '1', type: SupportedEntry.Event },
'2': { id: '2', type: SupportedEntry.Milestone }, '2': { id: '2', type: SupportedEntry.Milestone },
}); });
@@ -25,6 +25,7 @@ import { parseRundown } from '../rundown.parser.js';
import { import {
calculateDayOffset, calculateDayOffset,
cloneEntryData, cloneEntryData,
createGroupPatch,
deleteById, deleteById,
doesInvalidateMetadata, doesInvalidateMetadata,
getIntegerAndFraction, getIntegerAndFraction,
@@ -36,6 +37,25 @@ import {
} from '../rundown.utils.js'; } from '../rundown.utils.js';
describe('test event validator', () => { 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', () => { it('validates a good object', () => {
const event = { const event = {
title: 'test', title: 'test',
@@ -33,6 +33,7 @@ import {
makeString, makeString,
maxDuration, maxDuration,
validateEndAction, validateEndAction,
validateGroupTimerType,
validateTimerType, validateTimerType,
validateTimes, validateTimes,
} from 'ontime-utils'; } from 'ontime-utils';
@@ -180,6 +181,9 @@ export function createGroupPatch(originalGroup: OntimeGroup, patchGroup: Partial
note: makeString(patchGroup.note, originalGroup.note), note: makeString(patchGroup.note, originalGroup.note),
entries: patchGroup.entries ?? originalGroup.entries, entries: patchGroup.entries ?? originalGroup.entries,
targetDuration: maybeTargetDuration(), targetDuration: maybeTargetDuration(),
useGroupTimer:
typeof patchGroup.useGroupTimer === 'boolean' ? patchGroup.useGroupTimer : originalGroup.useGroupTimer,
timerType: validateGroupTimerType(patchGroup.timerType, originalGroup.timerType),
colour: makeString(patchGroup.colour, originalGroup.colour), colour: makeString(patchGroup.colour, originalGroup.colour),
revision: originalGroup.revision, revision: originalGroup.revision,
timeStart: originalGroup.timeStart, timeStart: originalGroup.timeStart,
+1
View File
@@ -206,6 +206,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
eventStore.init({ eventStore.init({
clock: state.clock, clock: state.clock,
timer: state.timer, timer: state.timer,
groupTimer: state.groupTimer,
message: { ...runtimeStorePlaceholder.message }, message: { ...runtimeStorePlaceholder.message },
offset: state.offset, offset: state.offset,
rundown: state.rundown, rundown: state.rundown,
+1 -1
View File
@@ -16,7 +16,7 @@
body { body {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: #101010; background: #121212;
color: #ffffff; color: #ffffff;
font-family: sans-serif; font-family: sans-serif;
overflow: hidden; overflow: hidden;
@@ -29,19 +29,15 @@ function makeHeadersWithFailingAuthorization(cookie?: string) {
describe('isPublicAssetRequest()', () => { describe('isPublicAssetRequest()', () => {
it('allows root public assets without a prefix', () => { it('allows root public assets without a prefix', () => {
expect(isPublicAssetRequest('/site.webmanifest', '')).toBe(true);
expect(isPublicAssetRequest('/manifest.json', '')).toBe(true); expect(isPublicAssetRequest('/manifest.json', '')).toBe(true);
}); });
it('allows prefixed public assets in cloud deployments', () => { it('allows prefixed public assets in cloud deployments', () => {
expect(isPublicAssetRequest('/stage-hash/manifest.json', '/stage-hash')).toBe(true); expect(isPublicAssetRequest('/stage-hash/site.webmanifest', '/stage-hash')).toBe(true);
expect(isPublicAssetRequest('/stage-hash/ontime-logo.png?cache=1', '/stage-hash')).toBe(true); expect(isPublicAssetRequest('/stage-hash/ontime-logo.png?cache=1', '/stage-hash')).toBe(true);
}); });
it('allows the PWA install icons', () => {
expect(isPublicAssetRequest('/ontime-logo-192.png', '')).toBe(true);
expect(isPublicAssetRequest('/ontime-logo-512.png', '')).toBe(true);
});
it('keeps non-public paths protected', () => { it('keeps non-public paths protected', () => {
expect(isPublicAssetRequest('/stage-hash/data', '/stage-hash')).toBe(false); expect(isPublicAssetRequest('/stage-hash/data', '/stage-hash')).toBe(false);
expect(isPublicAssetRequest('/backstage', '')).toBe(false); expect(isPublicAssetRequest('/backstage', '')).toBe(false);
+1 -2
View File
@@ -17,9 +17,8 @@ const publicAssets = new Set([
'/favicon.ico', '/favicon.ico',
'/manifest.json', '/manifest.json',
'/ontime-logo.png', '/ontime-logo.png',
'/ontime-logo-192.png',
'/ontime-logo-512.png',
'/robots.txt', '/robots.txt',
'/site.webmanifest',
]); ]);
export function isPublicAssetRequest(originalUrl: string, prefix: string): boolean { export function isPublicAssetRequest(originalUrl: string, prefix: string): boolean {
+22
View File
@@ -39,6 +39,8 @@ export const stageRundown: Rundown = {
note: '', note: '',
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'], entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -172,6 +174,8 @@ export const stageRundown: Rundown = {
note: '', note: '',
entries: ['0aaa7d'], entries: ['0aaa7d'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8', colour: '#3E75E8',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -219,6 +223,8 @@ export const stageRundown: Rundown = {
note: '', note: '',
entries: ['02afca', '75ce86', 'e10ed9', '07df89'], entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -360,6 +366,8 @@ export const backstageRundown: Rundown = {
note: '', note: '',
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'], entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#A790F5', colour: '#A790F5',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -476,6 +484,8 @@ export const backstageRundown: Rundown = {
note: '', note: '',
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'], entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -588,6 +598,8 @@ export const backstageRundown: Rundown = {
note: '', note: '',
entries: ['bs0301'], entries: ['bs0301'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8', colour: '#3E75E8',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -634,6 +646,8 @@ export const backstageRundown: Rundown = {
note: '', note: '',
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'], entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -768,6 +782,8 @@ export const broadcastRundown: Rundown = {
note: '', note: '',
entries: ['br0101', 'br0102', 'br0103'], entries: ['br0101', 'br0102', 'br0103'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#ED3333', colour: '#ED3333',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -851,6 +867,8 @@ export const broadcastRundown: Rundown = {
note: '', note: '',
entries: ['br0201', 'br0202', 'br0203', 'br0204'], entries: ['br0201', 'br0202', 'br0203', 'br0204'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -959,6 +977,8 @@ export const broadcastRundown: Rundown = {
note: '', note: '',
entries: ['br0301', 'br0302'], entries: ['br0301', 'br0302'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8', colour: '#3E75E8',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -1029,6 +1049,8 @@ export const broadcastRundown: Rundown = {
note: '', note: '',
entries: ['br0401', 'br0402', 'br0403'], entries: ['br0401', 'br0402', 'br0403'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -4,6 +4,7 @@ import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisT
import type { RuntimeState } from '../../stores/runtimeState.js'; import type { RuntimeState } from '../../stores/runtimeState.js';
import { import {
findDayOffset, findDayOffset,
getGroupTimer,
getCurrent, getCurrent,
getElapsed, getElapsed,
getExpectedFinish, getExpectedFinish,
@@ -16,6 +17,91 @@ import {
const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock']; 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()', () => { describe('getElapsed()', () => {
it('returns active elapsed time from startedAt without add-time adjustments', () => { it('returns active elapsed time from startedAt without add-time adjustments', () => {
const state = { const state = {
@@ -7,6 +7,7 @@ import {
findPreviousPlayableId, findPreviousPlayableId,
getEventAtIndex, getEventAtIndex,
getShouldClockUpdate, getShouldClockUpdate,
getShouldGroupTimerUpdate,
getShouldOffsetUpdate, getShouldOffsetUpdate,
getShouldTimerUpdate, getShouldTimerUpdate,
isNewSecond, isNewSecond,
@@ -95,6 +96,34 @@ 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()', () => { describe('getShouldOffsetUpdate()', () => {
const baseOffset: Offset = { const baseOffset: Offset = {
absolute: 0, absolute: 0,
@@ -25,7 +25,7 @@ import { logger } from '../../classes/Logger.js';
import { timerConfig } from '../../setup/config.js'; import { timerConfig } from '../../setup/config.js';
import { eventStore } from '../../stores/EventStore.js'; import { eventStore } from '../../stores/EventStore.js';
import * as runtimeState from '../../stores/runtimeState.js'; import * as runtimeState from '../../stores/runtimeState.js';
import type { RuntimeState } from '../../stores/runtimeState.js'; import type { RuntimeState, RuntimeStateSnapshot } from '../../stores/runtimeState.js';
import { EventTimer } from '../EventTimer.js'; import { EventTimer } from '../EventTimer.js';
import { restoreService } from '../restore-service/restore.service.js'; import { restoreService } from '../restore-service/restore.service.js';
import type { RestorePoint } from '../restore-service/restore.type.js'; import type { RestorePoint } from '../restore-service/restore.type.js';
@@ -36,6 +36,7 @@ import {
findPreviousPlayableId, findPreviousPlayableId,
getEventAtIndex, getEventAtIndex,
getShouldClockUpdate, getShouldClockUpdate,
getShouldGroupTimerUpdate,
getShouldOffsetUpdate, getShouldOffsetUpdate,
getShouldTimerUpdate, getShouldTimerUpdate,
isNewSecond, isNewSecond,
@@ -51,11 +52,11 @@ class RuntimeService {
private lastIntegrationTimerValue = -1; private lastIntegrationTimerValue = -1;
/** last known state */ /** last known state */
static previousState: RuntimeState; static previousState: RuntimeStateSnapshot;
constructor(eventTimer: EventTimer) { constructor(eventTimer: EventTimer) {
this.eventTimer = eventTimer; this.eventTimer = eventTimer;
RuntimeService.previousState = {} as RuntimeState; RuntimeService.previousState = {} as RuntimeStateSnapshot;
} }
@broadcastResult @broadcastResult
@@ -710,6 +711,12 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
RuntimeService.previousState.timer = { ...state.timer }; 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. * clock has changed by a second or more.
* or the timer updated so we ensure that the timer and clock ticks are in sync * or the timer updated so we ensure that the timer and clock ticks are in sync
@@ -54,6 +54,15 @@ 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 * Checks whether we should update the offset values
* - `mode` triggers update * - `mode` triggers update
+32 -1
View File
@@ -1,4 +1,4 @@
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types'; import { Day, MaybeNumber, Playback, TimeOfDay, TimerPhase, TimerState } from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils'; import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import type { RuntimeState } from '../stores/runtimeState.js'; import type { RuntimeState } from '../stores/runtimeState.js';
@@ -111,6 +111,37 @@ export function getElapsed(state: RuntimeState): MaybeNumber {
return Math.max(0, activeElapsed); 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 { function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
if (clock < startedAt) { if (clock < startedAt) {
return clock + dayInMs - startedAt; return clock + dayInMs - startedAt;
+6 -1
View File
@@ -13,6 +13,7 @@ import {
Playback, Playback,
Rundown, Rundown,
RundownState, RundownState,
RuntimeStore,
TimeOfDay, TimeOfDay,
TimerPhase, TimerPhase,
TimerState, TimerState,
@@ -39,6 +40,7 @@ import {
getCurrent, getCurrent,
getElapsed, getElapsed,
getExpectedFinish, getExpectedFinish,
getGroupTimer,
getRuntimeOffset, getRuntimeOffset,
getTimerPhase, getTimerPhase,
hasCrossedMidnight, hasCrossedMidnight,
@@ -104,7 +106,9 @@ const runtimeState: RuntimeState = {
_startDayOffset: null, _startDayOffset: null,
}; };
export function getState(): Readonly<RuntimeState> { export type RuntimeStateSnapshot = RuntimeState & Pick<RuntimeStore, 'groupTimer'>;
export function getState(): Readonly<RuntimeStateSnapshot> {
// create a shallow copy of the state // create a shallow copy of the state
return { return {
...runtimeState, ...runtimeState,
@@ -115,6 +119,7 @@ export function getState(): Readonly<RuntimeState> {
offset: { ...runtimeState.offset }, offset: { ...runtimeState.offset },
rundown: { ...runtimeState.rundown }, rundown: { ...runtimeState.rundown },
timer: { ...runtimeState.timer }, timer: { ...runtimeState.timer },
groupTimer: getGroupTimer(runtimeState),
_timer: { ...runtimeState._timer }, _timer: { ...runtimeState._timer },
_rundown: { ...runtimeState._rundown }, _rundown: { ...runtimeState._rundown },
}; };
+7 -7
View File
@@ -6,7 +6,7 @@ test.describe('pages routes are available', () => {
test('editor', async ({ page }) => { test('editor', async ({ page }) => {
await page.goto('/editor'); await page.goto('/editor');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
await expect(page.getByTestId('editor-container')).toBeVisible(); await expect(page.getByTestId('editor-container')).toBeVisible();
await expect(page.getByTestId('panel-rundown')).toBeVisible(); await expect(page.getByTestId('panel-rundown')).toBeVisible();
await expect(page.getByTestId('panel-timer-control')).toBeVisible(); await expect(page.getByTestId('panel-timer-control')).toBeVisible();
@@ -16,38 +16,38 @@ test.describe('pages routes are available', () => {
test('cuesheet', async ({ page }) => { test('cuesheet', async ({ page }) => {
await page.goto('/cuesheet'); await page.goto('/cuesheet');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
await expect(page.getByTestId('cuesheet')).toBeVisible(); await expect(page.getByTestId('cuesheet')).toBeVisible();
}); });
test('operator', async ({ page }) => { test('operator', async ({ page }) => {
await page.goto('/op'); await page.goto('/op');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
}); });
test('timer', async ({ page }) => { test('timer', async ({ page }) => {
await page.goto('/timer'); await page.goto('/timer');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
}); });
test('backstage', async ({ page }) => { test('backstage', async ({ page }) => {
await page.goto('/backstage'); await page.goto('/backstage');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
}); });
test('studio', async ({ page }) => { test('studio', async ({ page }) => {
await page.goto('/studio'); await page.goto('/studio');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
}); });
test('countdown', async ({ page }) => { test('countdown', async ({ page }) => {
await page.goto('/countdown?sub=32d31'); await page.goto('/countdown?sub=32d31');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
await expect(page.getByText('Albania')).toBeVisible(); await expect(page.getByText('Albania')).toBeVisible();
await expect(page.getByText('Latvia')).toBeHidden(); await expect(page.getByText('Latvia')).toBeHidden();
+2 -2
View File
@@ -92,12 +92,12 @@ test.describe('test view navigation feature', () => {
test('not-found', async ({ page }) => { test('not-found', async ({ page }) => {
await page.goto('/not-found'); await page.goto('/not-found');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
await page.goto('/preset/not-found'); await page.goto('/preset/not-found');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
}); });
}); });
@@ -44,6 +44,8 @@ export type OntimeGroup = OntimeBaseEvent & {
note: string; note: string;
entries: EntryId[]; entries: EntryId[];
targetDuration: MaybeNumber; targetDuration: MaybeNumber;
useGroupTimer: boolean;
timerType: TimerType;
colour: string; colour: string;
custom: EntryCustomFields; custom: EntryCustomFields;
// !==== RUNTIME METADATA ====! // // !==== RUNTIME METADATA ====! //
@@ -17,6 +17,7 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
secondaryTimer: null, // change on every update secondaryTimer: null, // change on every update
startedAt: null, // change can only be initiated by user startedAt: null, // change can only be initiated by user
}, },
groupTimer: null,
message: { message: {
timer: { timer: {
text: '', text: '',
@@ -9,6 +9,7 @@ export type RuntimeStore = {
// timer data // timer data
clock: number; clock: number;
timer: TimerState; timer: TimerState;
groupTimer: TimerState | null;
// messages service // messages service
message: MessageState; message: MessageState;
+8 -1
View File
@@ -34,7 +34,14 @@ export {
group as groupDef, group as groupDef,
milestone as milestoneDef, milestone as milestoneDef,
} from './src/rundown-utils/entryDefinitions.js'; } from './src/rundown-utils/entryDefinitions.js';
export { createDelay, createEvent, createGroup, createMilestone, makeString } from './src/rundown-utils/entryUtils.js'; export {
createDelay,
createEvent,
createGroup,
createMilestone,
makeString,
validateGroupTimerType,
} from './src/rundown-utils/entryUtils.js';
// time format utils // time format utils
export { export {
@@ -52,6 +52,8 @@ export const group: Omit<OntimeGroup, 'id'> = {
note: '', note: '',
entries: [], entries: [],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '', colour: '',
custom: {}, custom: {},
// !==== RUNTIME METADATA ====! // // !==== RUNTIME METADATA ====! //
+13 -1
View File
@@ -1,5 +1,5 @@
import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types'; import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
import { SupportedEntry, TimeStrategy } from 'ontime-types'; import { SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
import { generateId } from '../generate-id/generateId.js'; import { generateId } from '../generate-id/generateId.js';
import { validateEndAction, validateTimerType } from '../validate-events/validateEvent.js'; import { validateEndAction, validateTimerType } from '../validate-events/validateEvent.js';
@@ -67,6 +67,8 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
note: patch.note ?? '', note: patch.note ?? '',
entries: patch.entries ?? [], entries: patch.entries ?? [],
targetDuration: patch.targetDuration ?? null, targetDuration: patch.targetDuration ?? null,
useGroupTimer: patch.useGroupTimer === true,
timerType: validateGroupTimerType(patch.timerType),
colour: makeString(patch.colour, ''), colour: makeString(patch.colour, ''),
custom: patch.custom ?? {}, custom: patch.custom ?? {},
revision: 0, revision: 0,
@@ -77,6 +79,16 @@ 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 * Creates a new milestone from an optional patch
*/ */