Compare commits

..

7 Commits

Author SHA1 Message Date
Claude e28b06efed refactor(cuesheet): reserve the space an image will actually take
We were remembering the aspect ratio and reserving the full width of the
column, which over-reserves for an image smaller than the column: the row
would settle to a smaller size once the image was shown.

We now remember the size of the image and express the reservation in CSS
as min(100%, width), which is what the image itself resolves to at any
column width. Column sizes are applied as CSS variables and do not
re-render the cells, so the reservation follows a resize on its own.

Also groups the tests around the behaviour they describe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKXcDrZoQXbJpaXiLi1aff
2026-08-30 10:52:39 +00:00
Claude 9efac0a60a refactor(cuesheet): images are referenced by link only
A path to a local file resolves on the machine running ontime, but not
for the clients we serve the cuesheet to, so we no longer accept it.

Validation now parses the value as a URL and checks the protocol, which
also rejects malformed values that the previous prefix check let through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKXcDrZoQXbJpaXiLi1aff
2026-08-30 10:37:25 +00:00
Claude 0ebad09154 Merge remote-tracking branch 'origin/claude/cuesheet-image-unload-scroll-k26i4u' into claude/cuesheet-image-unload-scroll-k26i4u
# Conflicts:
#	apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EditableImage.tsx
2026-08-29 14:03:29 +00:00
Claude f3cd541ef0 fix(cuesheet): report image problems instead of failing silently
A value which is not a link was discarded on blur without any feedback:
the text stayed in the field, so it looked like it had been saved. We now
mark the field and say what is expected. Paths served by ontime itself
(eg. /user/slide.png) are now accepted, they were rejected before.

An image which cannot be loaded showed a broken icon with no explanation.
This is what a dropbox share link does, since it serves an html page
rather than the image, so it is worth naming the problem.

We also reserve the space of an image while it is loading, using the
aspect ratio of the last time we saw it. Rows are unmounted while out of
view, so without it the row collapses and grows again on the way back,
shifting the table under the user.

Smaller items in the same cell: the lazy loading attribute only added a
gate before the request (the row is only mounted when it is already close
to the viewport), the image had no alt text, and two expressions could
never run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKXcDrZoQXbJpaXiLi1aff
2026-08-29 14:02:34 +00:00
Carlos Valente 9ec12f4927 docs(llm): request for useeffect documentation 2026-08-28 12:29:12 +02:00
Carlos Valente 4c08ca2908 feat(timer): playing sound on time end 2026-08-28 12:29:12 +02:00
Claude 7ec1179ede fix(cuesheet): avoid layout shifts when images scroll back into view
The cuesheet is a virtualised table: rows are unmounted once they leave
the viewport, taking the <img> elements with them. On the way back the
image has no dimensions until the browser makes it available, so the row
grows under the user as it loads.

We now remember the aspect ratio of the images we have seen and use it to
reserve the space the image will take. This holds two numbers per image:
the image data itself is left to the browser cache, which is better
placed than us to decide when memory should be released.

Also drops the lazy loading attribute: the row is only mounted when it is
already close to the viewport, so it only adds a gate before the request.

Whether the image is fetched again on scroll back is decided by the cache
headers of the host serving it, and cannot be worked around from here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKXcDrZoQXbJpaXiLi1aff
2026-07-25 19:28:58 +00:00
26 changed files with 502 additions and 308 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 | null>,
scrollRef: RefObject<ScrollRef | null>,
componentRef: RefObject<ComponentRef>,
scrollRef: RefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
@@ -21,16 +21,18 @@ interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean;
followTrigger: MaybeString; // this would be an entry id or null
getTopOffset: () => number;
topOffset?: number;
setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null
}
export default function useFollowComponent({
followRef,
scrollRef,
doFollow,
topOffset = 100,
setScrollFlag,
followTrigger,
getTopOffset,
}: UseFollowComponentProps) {
// when trigger moves, view should follow
useEffect(() => {
@@ -39,17 +41,25 @@ export default function useFollowComponent({
}
if (followRef.current && scrollRef.current) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
// resolve the offset after layout, so that measured values are up to date
scrollToComponent(followRef, scrollRef, getTopOffset());
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
setScrollFlag?.(false);
});
}
}, [followTrigger, doFollow, followRef, scrollRef, getTopOffset]);
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
const scrollToRefComponent = useCallback(() => {
scrollToComponent(followRef, scrollRef, getTopOffset());
}, [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],
);
return scrollToRefComponent;
}
@@ -0,0 +1,26 @@
import { getRememberedDimensions, rememberDimensions } from '../imageDimensions';
/** stand-in for a loaded HTMLImageElement */
function makeImage(naturalWidth: number, naturalHeight: number) {
return { naturalWidth, naturalHeight } as HTMLImageElement;
}
test('We remember the size of an image, so that we can reserve its space when it comes back', () => {
expect(getRememberedDimensions('http://ontime.local/unseen.png')).toBe(null);
rememberDimensions('http://ontime.local/image.png', makeImage(1920, 1080));
expect(getRememberedDimensions('http://ontime.local/image.png')).toMatchObject({ width: 1920, height: 1080 });
// an image which failed to load has no size to offer
rememberDimensions('http://ontime.local/broken.png', makeImage(0, 0));
expect(getRememberedDimensions('http://ontime.local/broken.png')).toBe(null);
});
test('We keep the most recently seen images, older entries are forgotten', () => {
for (let i = 0; i < 600; i++) {
rememberDimensions(`http://ontime.local/${i}.png`, makeImage(100, 50));
}
expect(getRememberedDimensions('http://ontime.local/0.png')).toBe(null);
expect(getRememberedDimensions('http://ontime.local/599.png')).toMatchObject({ width: 100, height: 50 });
});
@@ -0,0 +1,49 @@
/**
* Images in the cuesheet live inside a virtualised table:
* rows are unmounted when they leave the viewport and mounted again when they come back.
* A re-mounted image has no dimensions until it is available,
* which makes the row change height and the table shift under the user.
*
* We remember the size of the images we have already seen
* so that we can reserve the space they will take.
* This only holds two numbers per image: we leave the image data itself to the browser cache,
* which knows better than us when memory should be released.
*/
export interface ImageDimensions {
width: number;
height: number;
}
/** how many sizes we remember, this is only a few bytes per entry */
const maxSize = 500;
const dimensions = new Map<string, ImageDimensions>();
/**
* @returns the size of a previously loaded image, if we have seen it before
*/
export function getRememberedDimensions(src: string): ImageDimensions | null {
return dimensions.get(src) ?? null;
}
/**
* Records the size of a loaded image
*/
export function rememberDimensions(src: string, image: HTMLImageElement) {
if (image.naturalHeight === 0) {
return;
}
// the map iteration order is our LRU queue, re-adding the entry marks it as recently used
dimensions.delete(src);
dimensions.set(src, { width: image.naturalWidth, height: image.naturalHeight });
while (dimensions.size > maxSize) {
const oldest = dimensions.keys().next();
if (oldest.done) {
return;
}
dimensions.delete(oldest.value);
}
}
@@ -18,12 +18,6 @@
padding-bottom: 95vh;
}
.groupSection {
display: flex;
flex-direction: column;
gap: 2px;
}
.editPrompt {
position: fixed;
z-index: $zindex-dialog;
+14 -27
View File
@@ -1,5 +1,5 @@
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 EmptyPage from '../../common/components/state/EmptyPage';
@@ -25,10 +25,7 @@ import { OperatorData, useOperatorData } from './useOperatorData';
import style from './Operator.module.scss';
/** 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;
const selectedOffset = 50;
export default function OperatorLoader() {
const { data, status } = useOperatorData();
@@ -57,20 +54,11 @@ 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,
getTopOffset,
topOffset: selectedOffset,
followTrigger: selectedEventId,
});
@@ -94,16 +82,15 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
// prevent considering automated scrolls as user scrolls
const handleUserScroll = () => {
if (!selectedRef.current || !scrollRef.current) {
return;
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);
}
}
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);
@@ -199,9 +186,9 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
}
return (
<div className={style.groupSection} key={entry.id}>
<Fragment key={entry.id}>
<OperatorGroup
ref={isCurrentParent ? stickyHeaderRef : undefined}
key={entry.id}
title={entry.title}
colour={entry.colour}
count={entry.entries.length}
@@ -252,7 +239,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
/>
);
})}
</div>
</Fragment>
);
}
return null;
@@ -1,7 +1,6 @@
.group {
width: 100%;
/* Fixed: title and meta never wrap, and the group wrapper keeps the list from shrinking the header. */
height: 2.5rem;
min-height: 2.5rem;
padding: 0.4rem 0.75rem;
border-left: 0.35rem solid var(--group-colour, $gray-500);
background-color: $gray-1350;
@@ -9,11 +8,6 @@
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 { type CSSProperties, type Ref, memo } from 'react';
import { CSSProperties, memo } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time';
@@ -10,16 +10,15 @@ interface OperatorGroup {
colour: string;
count: number;
duration: number;
ref?: Ref<HTMLDivElement>;
}
export default memo(OperatorGroup);
function OperatorGroup({ title, colour, count, duration, ref }: OperatorGroup) {
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
const groupColour = colour || '#929292';
const groupColours = getAccessibleColour(groupColour);
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.meta}>
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
+3 -31
View File
@@ -100,20 +100,7 @@ $item-height: 3.5rem;
flex-direction: column;
overflow-y: auto;
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;
}
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
}
/* ====================== LIST-ITEM ======================*/
@@ -209,14 +196,9 @@ $item-height: 3.5rem;
.sub--group {
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-color: var(--background-color-override, $viewer-background-color);
background-image:
background:
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 {
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 {
grid-area: title;
padding-bottom: 0.5rem;
@@ -1,6 +1,6 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types';
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 Button from '../../common/components/buttons/Button';
@@ -22,7 +22,6 @@ import {
CountdownTarget,
extendEventData,
getIsLive,
groupSubscriptionTargets,
isOutsideRange,
preferredFormat12,
preferredFormat24,
@@ -49,22 +48,11 @@ 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,
getTopOffset: getStickyOffset,
topOffset: 0,
followTrigger: selectedEventId,
});
@@ -87,16 +75,15 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
// prevent considering automated scrolls as user scrolls
const handleUserScroll = () => {
if (!selectedRef.current || !scrollRef.current) {
return;
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);
}
}
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);
@@ -111,63 +98,41 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
return (
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{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;
{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');
return (
<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
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>
);
})}
@@ -1,7 +1,7 @@
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
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.
@@ -126,89 +126,3 @@ 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,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(
event: CountdownTarget,
currentDay: number,
@@ -0,0 +1,18 @@
import { isValidImageSource } from '../cuesheet-table/cuesheet-table-elements/EditableImage';
test('An image is referenced by link, anything else is rejected', () => {
const testCases = [
{ value: 'https://example.com/image.png', isValid: true },
{ value: 'http://example.com/image.png', isValid: true },
// a file is local to the machine running ontime, it would not resolve for the clients we serve
{ value: '/user/image.png', isValid: false },
{ value: 'file:///Users/me/image.png', isValid: false },
{ value: 'C:\\images\\image.png', isValid: false },
// values which do not describe a location we can reach
{ value: 'www.example.com/image.png', isValid: false },
{ value: 'https://', isValid: false },
{ value: 'some text', isValid: false },
];
testCases.forEach((t) => expect(isValidImageSource(t.value)).toBe(t.isValid));
});
@@ -5,6 +5,19 @@
&:not(:read-only):hover::placeholder {
opacity: 1;
}
&[data-invalid] {
outline: 1px solid $red-500;
}
}
/** feedback on a value we cannot use, either rejected or failed to load */
.message {
display: block;
padding: 0.25rem 0;
color: $red-500;
font-size: calc(1rem - 3px);
line-height: 1.2;
}
.imageCell {
@@ -1,27 +1,54 @@
import { memo } from 'react';
import { memo, useState } from 'react';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import { getRememberedDimensions, rememberDimensions } from '../../../../common/utils/imageDimensions';
import style from './EditableImage.module.scss';
interface EditableImageProps {
initialValue: string;
fieldLabel: string;
readOnly?: boolean;
updateValue: (newValue: string) => void;
}
export default memo(EditableImage);
function EditableImage({ initialValue, readOnly, updateValue }: EditableImageProps) {
/**
* Images are referenced by link: anything local to the machine running ontime
* would not resolve for the clients we serve the cuesheet to
*/
export function isValidImageSource(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
function EditableImage({ initialValue, fieldLabel, readOnly, updateValue }: EditableImageProps) {
const [isRejected, setIsRejected] = useState(false);
/** we keep track of the source itself, so that the state follows the value being shown */
const [failedSource, setFailedSource] = useState<string | null>(null);
const [loadedSource, setLoadedSource] = useState<string | null>(null);
const handleUpdate = (newValue: string) => {
if (newValue === initialValue) {
const value = newValue.trim();
if (value === initialValue) {
setIsRejected(false);
return;
}
if (newValue !== '' && !newValue.startsWith('http')) {
if (value !== '' && !isValidImageSource(value)) {
setIsRejected(true);
return;
}
updateValue(newValue);
setIsRejected(false);
updateValue(value);
};
const openInNewTab = () => {
@@ -36,22 +63,42 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro
if (!initialValue) {
return (
<Input
variant='ghosted'
className={style.imageInput}
fluid
placeholder='Paste image URL'
onBlur={(event) => handleUpdate(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleUpdate(event.currentTarget.value);
}
}}
defaultValue={initialValue}
/>
<>
<Input
variant='ghosted'
className={style.imageInput}
fluid
placeholder='Paste image URL'
data-invalid={isRejected || undefined}
onChange={() => setIsRejected(false)}
onBlur={(event) => handleUpdate(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleUpdate(event.currentTarget.value);
}
}}
/>
{isRejected && <span className={style.message}>Images are referenced by link (https://...)</span>}
</>
);
}
/**
* The cuesheet is virtualised: rows are unmounted once they leave the viewport.
* When the row comes back, we reserve the space the image took
* so that the table does not shift while the browser makes it available.
* The reservation is given in CSS so that it follows the column being resized,
* the same way the image itself does once it is shown.
*/
const knownDimensions = getRememberedDimensions(initialValue);
const isLoaded = loadedSource === initialValue;
const reservedSpace = knownDimensions
? {
aspectRatio: knownDimensions.width / knownDimensions.height,
width: `min(100%, ${knownDimensions.width}px)`,
}
: undefined;
return (
<div className={style.imageCell}>
{!readOnly && (
@@ -62,7 +109,22 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro
</Button>
</div>
)}
{Boolean(initialValue) && <img loading='lazy' src={initialValue} className={style.image} />}
{failedSource === initialValue ? (
<span className={style.message}>Could not load image</span>
) : (
<img
src={initialValue}
alt={fieldLabel}
className={style.image}
onLoad={(event) => {
rememberDimensions(initialValue, event.currentTarget);
setLoadedSource(initialValue);
}}
onError={() => setFailedSource(initialValue)}
/** until the image is available, we reserve the space it took the last time we saw it */
style={isLoaded ? undefined : reservedSpace}
/>
)}
</div>
);
}
@@ -175,7 +175,14 @@ function LazyImage({ row, column, table }: CuesheetCellContext) {
const canWrite = column.columnDef.meta?.canWrite;
const initialValue = event.custom[column.id];
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
return (
<EditableImage
initialValue={initialValue}
fieldLabel={getColumnLabel(column)}
updateValue={update}
readOnly={!canWrite}
/>
);
}
function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
+18
View File
@@ -195,6 +195,24 @@
font-weight: 600;
}
.sound-prompt {
position: absolute;
bottom: $view-block-padding;
left: $view-inline-padding;
padding: 0.5em 0.75em;
border-radius: $element-border-radius;
background-color: $viewer-card-bg-color;
color: $viewer-secondary-color;
font-size: $timer-label-size;
text-transform: uppercase;
pointer-events: none;
transition: opacity $viewer-transition-time;
&--hidden {
opacity: 0;
}
}
/* =================== LOGO ===================*/
.logo {
position: absolute;
+17
View File
@@ -8,6 +8,7 @@ import TitleCard from '../../common/components/title-card/TitleCard';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useTimerSocket } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { cx } from '../../common/utils/styleUtils';
@@ -30,6 +31,7 @@ import {
getTotalTime,
} from './timer.utils';
import { TimerData, useTimerData } from './useTimerData';
import { useTimerSound } from './useTimerSound';
import './Timer.scss';
@@ -66,6 +68,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
freezeOvertime,
freezeMessage,
hidePhase,
endSound,
font,
keyColour,
timerColour,
@@ -75,6 +78,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes');
const showSoundPrompt = useTimerSound(time.phase, endSound);
// gather modifiers
const viewTimerType = timerType ?? timerTypeNow;
const showOverlay = getShowMessage(message.timer);
@@ -156,6 +161,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
<ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} />
{showSoundPrompt && <SoundPermissionPrompt />}
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
{!hideMessage && (
@@ -227,3 +234,13 @@ function TimerAutoTickingClock({ clockFormat }: { clockFormat: MaybeString }) {
</div>
);
}
function SoundPermissionPrompt() {
const isUserActive = useFadeOutOnInactivity(true);
return (
<div className={cx(['sound-prompt', !isUserActive && 'sound-prompt--hidden'])} aria-live='polite'>
Interact with the page (click/tap or press any key) to enable sound
</div>
);
}
@@ -0,0 +1,34 @@
import { TimerPhase } from 'ontime-types';
import { shouldPlayEndSound } from '../timer.utils';
describe('shouldPlayEndSound()', () => {
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
'sounds when a running timer goes into overtime from %s',
(previousPhase) => {
expect(shouldPlayEndSound(previousPhase, TimerPhase.Overtime)).toBe(true);
},
);
it('stays silent on the first phase we see, a client could be joining mid-overtime', () => {
expect(shouldPlayEndSound(null, TimerPhase.Overtime)).toBe(false);
});
it('stays silent when the phase was reset, a reload during overtime starts from none', () => {
expect(shouldPlayEndSound(TimerPhase.None, TimerPhase.Overtime)).toBe(false);
});
it('stays silent for a roll timer waiting to start', () => {
expect(shouldPlayEndSound(TimerPhase.Pending, TimerPhase.Overtime)).toBe(false);
});
it('sounds once, not on every update while in overtime', () => {
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.Overtime)).toBe(false);
});
it('stays silent on phases which are not the end of the timer', () => {
expect(shouldPlayEndSound(TimerPhase.Default, TimerPhase.Warning)).toBe(false);
expect(shouldPlayEndSound(TimerPhase.Warning, TimerPhase.Danger)).toBe(false);
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.None)).toBe(false);
});
});
@@ -16,6 +16,7 @@ import { ViewOption } from '../../common/components/view-params-editor/viewParam
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean, makeColourString } from '../common/viewUtils';
import { endSoundOptions, isEndSound, type EndSound } from './timer.sound';
// manually match the properties of TimerType excluding the None
const timerDisplayOptions: SelectOption[] = [
@@ -76,6 +77,15 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
type: 'boolean',
defaultValue: false,
},
{
id: 'endSound',
title: 'Play sound on timer end',
description:
'Plays a sound in this screen when the timer reaches zero. The screen must be interacted with once before it can play',
type: 'option',
values: endSoundOptions,
defaultValue: 'none',
},
],
},
{
@@ -193,6 +203,7 @@ type TimerOptions = {
freezeOvertime: boolean;
freezeMessage: string;
hidePhase: boolean;
endSound: EndSound;
font?: string;
keyColour?: string;
timerColour?: string;
@@ -208,6 +219,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
// Get timerType from either source
const timerType = validateTimerType(getValue('timerType'), TimerType.None);
const endSoundValue = getValue('endSound');
return {
hideClock: isStringBoolean(getValue('hideClock')),
@@ -227,6 +239,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
freezeOvertime: isStringBoolean(getValue('freezeOvertime')),
freezeMessage: getValue('freezeMessage') ?? '',
hidePhase: isStringBoolean(getValue('hidePhase')),
endSound: isEndSound(endSoundValue) ? endSoundValue : 'none',
font: getValue('font') ?? undefined,
keyColour: makeColourString(getValue('keyColour')),
@@ -0,0 +1,20 @@
import arpeggio from '../../assets/sounds/arpeggio.wav';
import bell from '../../assets/sounds/bell.wav';
import chime from '../../assets/sounds/chime.wav';
import type { SelectOption } from '../../common/components/select/Select';
// synthesised tones bundled with the app, no external source or licence to track
export const endSoundSources = { chime, bell, arpeggio } as const;
export type EndSound = 'none' | keyof typeof endSoundSources;
export const endSoundOptions: SelectOption[] = [
{ value: 'none', label: 'None' },
{ value: 'chime', label: 'Chime' },
{ value: 'bell', label: 'Bell' },
{ value: 'arpeggio', label: 'Arpeggio' },
];
export function isEndSound(value: string | null): value is EndSound {
return value === 'none' || value === 'chime' || value === 'bell' || value === 'arpeggio';
}
@@ -189,3 +189,18 @@ export function getCardData(
nextSecondary,
};
}
/**
* Whether the end of timer sound should play for a given phase transition
* We only sound the transition into overtime from a phase that was already counting,
* which keeps a client that connects or reloads mid-overtime silent
*/
export function shouldPlayEndSound(previousPhase: TimerPhase | null, phase: TimerPhase): boolean {
if (phase !== TimerPhase.Overtime) {
return false;
}
return (
previousPhase === TimerPhase.Default || previousPhase === TimerPhase.Warning || previousPhase === TimerPhase.Danger
);
}
@@ -0,0 +1,99 @@
import { TimerPhase } from 'ontime-types';
import { useEffect, useRef, useState } from 'react';
import { endSoundSources, type EndSound } from './timer.sound';
import { shouldPlayEndSound } from './timer.utils';
/**
* Plays a sound when the timer reaches its end
*
* Browsers reject playback until the document has been interacted with, and that permission
* is lost on every page load. Since a timer screen is typically left unattended, we prime the
* audio element on the first interaction and let the view prompt for one if it never comes.
* Safari grants the permission per element, so priming has to call play() on this element from
* inside the event handler, it is not enough to know that an interaction happened.
*/
export function useTimerSound(phase: TimerPhase, sound: EndSound): boolean {
const audioRef = useRef<HTMLAudioElement | null>(null);
const previousPhaseRef = useRef<TimerPhase | null>(null);
const [isArmed, setIsArmed] = useState(false);
const enabled = sound !== 'none';
// Create and clean up the audio element; changing sounds requires re-arming it in Safari.
useEffect(() => {
setIsArmed(false);
if (sound === 'none') {
return;
}
const audio = new Audio(endSoundSources[sound]);
audioRef.current = audio;
return () => {
audio.pause();
audioRef.current = null;
};
}, [sound]);
// Listen for user interaction until muted playback succeeds and arms the selected audio element.
useEffect(() => {
if (!enabled || isArmed) {
return;
}
const controller = new AbortController();
const prime = () => {
const audio = audioRef.current;
if (!audio) {
return;
}
const wasMuted = audio.muted;
audio.muted = true;
audio
.play()
.then(() => {
if (audioRef.current !== audio) {
return;
}
audio.pause();
audio.currentTime = 0;
setIsArmed(true);
})
.catch(() => {
// playback is still blocked, a later interaction will try again
})
.finally(() => {
audio.muted = wasMuted;
});
};
document.addEventListener('pointerdown', prime, { capture: true, signal: controller.signal });
document.addEventListener('keydown', prime, { capture: true, signal: controller.signal });
return () => {
controller.abort();
};
}, [enabled, isArmed]);
// Track phase transitions and play only when a running timer enters overtime.
useEffect(() => {
const previousPhase = previousPhaseRef.current;
previousPhaseRef.current = phase;
if (!enabled || !shouldPlayEndSound(previousPhase, phase)) {
return;
}
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = 0;
audio.play().catch(() => {
// the screen has not been interacted with, the view shows a prompt for it
});
}, [enabled, phase]);
return enabled && !isArmed;
}
+4
View File
@@ -36,6 +36,10 @@ Remove comments that:
Update adjacent comments with code. Stale comments are defects.
Add a short comment immediately above every React `useEffect` describing the external synchronization or lifecycle
responsibility it owns. Explain non-obvious reasons or constraints when they matter; for files with multiple effects, a
brief responsibility label is useful even when the mechanics are straightforward.
## Naming and types
- Prefer Ontime terms over vague `data`, `result`, `item`.