Compare commits

..

4 Commits

Author SHA1 Message Date
Claude 650c5eb66a fix(cuesheet): keep original "Hide seconds in table" option label
Revert the label rename from the previous commit; only the behavior
scope changes (time tags only), not the option's display name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoUSFVFdmMQN5Ro86pRvBQ
2026-09-05 16:22:01 +00:00
Claude 36b05f499e fix(cuesheet): hide seconds option should not affect duration column
The "hide seconds" toggle in the Cuesheet table settings was also
stripping seconds from the Duration column. It should only apply to
the Start/End time tags, so the Duration column now always displays
its full precision (including seconds).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoUSFVFdmMQN5Ro86pRvBQ
2026-09-05 16:19:56 +00:00
Carlos Valente 977b01a072 fix: keep group visible as header for the running event 2026-09-05 09:24:42 +02:00
SMPTY dd718230ad fix(client): fix PWA manifest so "Install as App" works properly (#2192) 2026-09-05 09:15:08 +02:00
61 changed files with 396 additions and 839 deletions
+4 -2
View File
@@ -7,13 +7,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#101010" />
<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-title" content="Ontime" />
<link rel="apple-touch-icon" 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" />
<meta name="robots" content="noindex" />
<title>ontime</title>
<title>Ontime</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
+9 -7
View File
@@ -1,19 +1,21 @@
{
"name": "ontime",
"short_name": "ontime",
"name": "Ontime",
"short_name": "Ontime",
"icons": [
{
"src": "favicon.ico",
"type": "image/x-icon"
"src": "ontime-logo-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "ontime-logo.png",
"src": "ontime-logo-512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"scope": "./",
"start_url": "./",
"display": "",
"theme_color": "#121212",
"display": "standalone",
"theme_color": "#101010",
"background_color": "#101010"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

-8
View File
@@ -1,8 +0,0 @@
{
"name": "",
"short_name": "",
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
"theme_color": "#121212",
"background_color": "#101010",
"display": "standalone"
}
@@ -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,
});
});
});
@@ -2,8 +2,8 @@ import { MaybeString } from 'ontime-types';
import { RefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>,
scrollRef: RefObject<ScrollRef>,
componentRef: RefObject<ComponentRef | null>,
scrollRef: RefObject<ScrollRef | null>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
@@ -21,18 +21,16 @@ interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null
followTrigger: MaybeString; // this would be an entry id or null
getTopOffset: () => number;
}
export default function useFollowComponent({
followRef,
scrollRef,
doFollow,
topOffset = 100,
setScrollFlag,
followTrigger,
getTopOffset,
}: UseFollowComponentProps) {
// when trigger moves, view should follow
useEffect(() => {
@@ -41,25 +39,17 @@ export default function useFollowComponent({
}
if (followRef.current && scrollRef.current) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
setScrollFlag?.(false);
// resolve the offset after layout, so that measured values are up to date
scrollToComponent(followRef, scrollRef, getTopOffset());
});
}
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
}, [followTrigger, doFollow, followRef, scrollRef, getTopOffset]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
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],
);
const scrollToRefComponent = useCallback(() => {
scrollToComponent(followRef, scrollRef, getTopOffset());
}, [followRef, scrollRef, getTopOffset]);
return scrollToRefComponent;
}
+24 -35
View File
@@ -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,
};
}
@@ -6,6 +6,6 @@ import { useEffect } from 'react';
*/
export function useWindowTitle(title: string) {
useEffect(() => {
document.title = `ontime - ${title}`;
document.title = `Ontime - ${title}`;
}, []);
}
@@ -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 });
});
});
@@ -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);
}
@@ -40,7 +40,7 @@ export default function ShutdownPanel() {
{!isOntimeCloud && (
<Panel.Section>
<Button variant='destructive' onClick={handler.open} disabled={!canShutdown}>
Shutdown ontime
Shutdown Ontime
</Button>
{!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>}
</Panel.Section>
@@ -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 />}
@@ -18,6 +18,12 @@
padding-bottom: 95vh;
}
.groupSection {
display: flex;
flex-direction: column;
gap: 2px;
}
.editPrompt {
position: fixed;
z-index: $zindex-dialog;
+27 -14
View File
@@ -1,5 +1,5 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage';
@@ -25,7 +25,10 @@ import { OperatorData, useOperatorData } from './useOperatorData';
import style from './Operator.module.scss';
const selectedOffset = 50;
/** Keeps the running event clear of the list edge when no group header is pinned above it */
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() {
const { data, status } = useOperatorData();
@@ -54,11 +57,20 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = 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({
followRef: selectedRef,
scrollRef,
doFollow: !lockAutoScroll,
topOffset: selectedOffset,
getTopOffset,
followTrigger: selectedEventId,
});
@@ -82,15 +94,16 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
// prevent considering automated scrolls as user scrolls
const handleUserScroll = () => {
if (selectedRef?.current && scrollRef?.current) {
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);
}
if (!selectedRef.current || !scrollRef.current) {
return;
}
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);
@@ -186,9 +199,9 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
}
return (
<Fragment key={entry.id}>
<div className={style.groupSection} key={entry.id}>
<OperatorGroup
key={entry.id}
ref={isCurrentParent ? stickyHeaderRef : undefined}
title={entry.title}
colour={entry.colour}
count={entry.entries.length}
@@ -239,7 +252,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
/>
);
})}
</Fragment>
</div>
);
}
return null;
@@ -1,13 +1,20 @@
.group {
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;
padding: 0.4rem 0.75rem;
padding: 0.25rem 0.75rem;
border-left: 0.35rem solid var(--group-colour, $gray-500);
background-color: $gray-1350;
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
font-size: 1.25rem;
font-weight: 600;
position: sticky;
/* Cover the list padding so rows cannot scroll above the header. */
top: -0.25rem;
z-index: 1;
display: flex;
align-items: center;
gap: 1rem;
@@ -1,4 +1,4 @@
import { CSSProperties, memo } from 'react';
import { type CSSProperties, type Ref, memo } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time';
@@ -10,15 +10,16 @@ interface OperatorGroup {
colour: string;
count: number;
duration: number;
ref?: Ref<HTMLDivElement>;
}
export default memo(OperatorGroup);
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
function OperatorGroup({ title, colour, count, duration, ref }: OperatorGroup) {
const groupColour = colour || '#929292';
const groupColours = getAccessibleColour(groupColour);
return (
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties} ref={ref}>
<span className={style.title}>{title}</span>
<span className={style.meta}>
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</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>
+31 -3
View File
@@ -100,7 +100,20 @@ $item-height: 3.5rem;
flex-direction: column;
overflow-y: auto;
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
padding-bottom: 95vh;
}
/* 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 ======================*/
@@ -196,9 +209,14 @@ $item-height: 3.5rem;
.sub--group {
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
background:
/* The opaque base prevents rows showing through; background shorthand cannot layer this colour. */
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%),
var(--card-background-color-override, $viewer-card-bg-color);
linear-gradient(
var(--card-background-color-override, $viewer-card-bg-color),
var(--card-background-color-override, $viewer-card-bg-color)
);
.sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
@@ -230,6 +248,16 @@ $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 {
grid-area: title;
padding-bottom: 0.5rem;
@@ -1,6 +1,6 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
@@ -22,6 +22,7 @@ import {
CountdownTarget,
extendEventData,
getIsLive,
groupSubscriptionTargets,
isOutsideRange,
preferredFormat12,
preferredFormat24,
@@ -48,11 +49,22 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = 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({
followRef: selectedRef,
scrollRef,
doFollow: !lockAutoScroll,
topOffset: 0,
getTopOffset: getStickyOffset,
followTrigger: selectedEventId,
});
@@ -75,15 +87,16 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
// prevent considering automated scrolls as user scrolls
const handleUserScroll = () => {
if (selectedRef?.current && scrollRef?.current) {
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);
}
if (!selectedRef.current || !scrollRef.current) {
return;
}
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);
@@ -98,41 +111,63 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
return (
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{subscribedEvents.map((event) => {
// while a group is live, surface the running event's title as the secondary line
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
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');
{sections.map((section) => {
const rows = section.group ? [section.group, ...section.events] : section.events;
// the running event anchors the scroll, the group header stays pinned above it
const anchorId = section.events.find((event) => getIsLive(event.id, selectedEventId, playback))?.id ?? null;
return (
<div
key={event.id}
ref={isLive ? selectedRef : undefined}
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 key={section.group?.id ?? rows[0].id} className='sub-section'>
{rows.map((event) => {
// while a group is live, surface the running event's title as the secondary line
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
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;
// only ever hand the ref to a single row, sharing it would null it out on the next commit
const isAnchor = isLive && (anchorId === null || event.id === anchorId);
const rowRef = isAnchor ? selectedRef : event.isGroup && anchorId ? stickyHeaderRef : undefined;
const countdownEvent = extendEventData(
event,
currentDay,
actualStart,
plannedStart,
offset,
mode,
reportData,
);
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
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>
);
})}
@@ -1,7 +1,7 @@
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { resolveSubscriptionTarget } from './countdown.utils';
import { CountdownTarget, groupSubscriptionTargets, resolveSubscriptionTarget } from './countdown.utils';
/**
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
@@ -126,3 +126,89 @@ describe('resolveSubscriptionTarget()', () => {
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,6 +252,42 @@ 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(
event: CountdownTarget,
currentDay: number,
@@ -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;
}
@@ -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;
}
@@ -117,10 +105,9 @@ function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
return null;
}
const { hideTableSeconds } = table.options.meta.options;
const event = row.original;
if (!isOntimeEvent(event)) {
return <MutedText numeric>{formatDuration(getValue() as number, hideTableSeconds)}</MutedText>;
return <MutedText numeric>{formatDuration(getValue() as number, false)}</MutedText>;
}
const { handleUpdateTimer } = table.options.meta;
@@ -129,7 +116,7 @@ function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
const duration = getValue() as number;
const isDurationLocked = event.timeStrategy === TimeStrategy.LockDuration;
const formattedDuration = formatDuration(duration, hideTableSeconds);
const formattedDuration = formatDuration(duration, false);
const canWrite = column.columnDef.meta?.canWrite;
if (!canWrite) {
@@ -205,38 +192,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}
/>
-8
View File
@@ -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;
+13 -31
View File
@@ -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])(
+1 -21
View File
@@ -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?
*/
+2 -2
View File
@@ -6,7 +6,7 @@
http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'unsafe-inline'; script-src 'self'"
/>
<title>ontime</title>
<title>Ontime</title>
<style>
body {
-webkit-user-select: none;
@@ -92,7 +92,7 @@
<body>
<div class="container">
<img src="../assets/logo.png" />
<h1>ontime · event timers</h1>
<h1>Ontime · event timers</h1>
<div class="lds-ellipsis">
<div></div>
<div></div>
@@ -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,
-1
View File
@@ -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,
+1 -1
View File
@@ -16,7 +16,7 @@
body {
width: 100%;
height: 100%;
background: #121212;
background: #101010;
color: #ffffff;
font-family: sans-serif;
overflow: hidden;
@@ -29,15 +29,19 @@ function makeHeadersWithFailingAuthorization(cookie?: string) {
describe('isPublicAssetRequest()', () => {
it('allows root public assets without a prefix', () => {
expect(isPublicAssetRequest('/site.webmanifest', '')).toBe(true);
expect(isPublicAssetRequest('/manifest.json', '')).toBe(true);
});
it('allows prefixed public assets in cloud deployments', () => {
expect(isPublicAssetRequest('/stage-hash/site.webmanifest', '/stage-hash')).toBe(true);
expect(isPublicAssetRequest('/stage-hash/manifest.json', '/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', () => {
expect(isPublicAssetRequest('/stage-hash/data', '/stage-hash')).toBe(false);
expect(isPublicAssetRequest('/backstage', '')).toBe(false);
+2 -1
View File
@@ -17,8 +17,9 @@ const publicAssets = new Set([
'/favicon.ico',
'/manifest.json',
'/ontime-logo.png',
'/ontime-logo-192.png',
'/ontime-logo-512.png',
'/robots.txt',
'/site.webmanifest',
]);
export function isPublicAssetRequest(originalUrl: string, prefix: string): boolean {
-22
View File
@@ -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 -32
View File
@@ -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;
+1 -6
View File
@@ -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 },
};
+7 -7
View File
@@ -6,7 +6,7 @@ test.describe('pages routes are available', () => {
test('editor', async ({ page }) => {
await page.goto('/editor');
await expect(page).toHaveTitle(/ontime/);
await expect(page).toHaveTitle(/ontime/i);
await expect(page.getByTestId('editor-container')).toBeVisible();
await expect(page.getByTestId('panel-rundown')).toBeVisible();
await expect(page.getByTestId('panel-timer-control')).toBeVisible();
@@ -16,38 +16,38 @@ test.describe('pages routes are available', () => {
test('cuesheet', async ({ page }) => {
await page.goto('/cuesheet');
await expect(page).toHaveTitle(/ontime/);
await expect(page).toHaveTitle(/ontime/i);
await expect(page.getByTestId('cuesheet')).toBeVisible();
});
test('operator', async ({ page }) => {
await page.goto('/op');
await expect(page).toHaveTitle(/ontime/);
await expect(page).toHaveTitle(/ontime/i);
});
test('timer', async ({ page }) => {
await page.goto('/timer');
await expect(page).toHaveTitle(/ontime/);
await expect(page).toHaveTitle(/ontime/i);
});
test('backstage', async ({ page }) => {
await page.goto('/backstage');
await expect(page).toHaveTitle(/ontime/);
await expect(page).toHaveTitle(/ontime/i);
});
test('studio', async ({ page }) => {
await page.goto('/studio');
await expect(page).toHaveTitle(/ontime/);
await expect(page).toHaveTitle(/ontime/i);
});
test('countdown', async ({ page }) => {
await page.goto('/countdown?sub=32d31');
await expect(page).toHaveTitle(/ontime/);
await expect(page).toHaveTitle(/ontime/i);
await expect(page.getByText('Albania')).toBeVisible();
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 }) => {
await page.goto('/not-found');
await expect(page).toHaveTitle(/ontime/);
await expect(page).toHaveTitle(/ontime/i);
await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
await page.goto('/preset/not-found');
await expect(page).toHaveTitle(/ontime/);
await expect(page).toHaveTitle(/ontime/i);
await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
});
});
@@ -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;
+1 -8
View File
@@ -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 -13
View File
@@ -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
*/