diff --git a/apps/client/src/common/hooks/useFollowComponent.ts b/apps/client/src/common/hooks/useFollowComponent.ts index a757b2e9c..8a69cbc69 100644 --- a/apps/client/src/common/hooks/useFollowComponent.ts +++ b/apps/client/src/common/hooks/useFollowComponent.ts @@ -2,8 +2,8 @@ import { MaybeString } from 'ontime-types'; import { RefObject, useCallback, useEffect } from 'react'; function scrollToComponent( - componentRef: RefObject, - scrollRef: RefObject, + componentRef: RefObject, + scrollRef: RefObject, topOffset: number, ) { if (!componentRef.current || !scrollRef.current) { @@ -21,18 +21,16 @@ interface UseFollowComponentProps { followRef: RefObject; scrollRef: RefObject; 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, scrollRef as RefObject, 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; } diff --git a/apps/client/src/features/operator/Operator.module.scss b/apps/client/src/features/operator/Operator.module.scss index a8bcfa5a5..3617dd74e 100644 --- a/apps/client/src/features/operator/Operator.module.scss +++ b/apps/client/src/features/operator/Operator.module.scss @@ -18,6 +18,12 @@ padding-bottom: 95vh; } +.groupSection { + display: flex; + flex-direction: column; + gap: 2px; +} + .editPrompt { position: fixed; z-index: $zindex-dialog; diff --git a/apps/client/src/features/operator/Operator.tsx b/apps/client/src/features/operator/Operator.tsx index f1b6ffc41..bc9ccef9f 100644 --- a/apps/client/src/features/operator/Operator.tsx +++ b/apps/client/src/features/operator/Operator.tsx @@ -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(null); const scrollRef = useRef(null); + const stickyHeaderRef = useRef(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 ( - +
); })} - +
); } return null; diff --git a/apps/client/src/features/operator/operator-group/OperatorGroup.module.scss b/apps/client/src/features/operator/operator-group/OperatorGroup.module.scss index b545380a2..87e280881 100644 --- a/apps/client/src/features/operator/operator-group/OperatorGroup.module.scss +++ b/apps/client/src/features/operator/operator-group/OperatorGroup.module.scss @@ -1,6 +1,7 @@ .group { width: 100%; - min-height: 2.5rem; + /* Fixed: title and meta never wrap, and the group wrapper keeps the list from shrinking the header. */ + height: 2.5rem; padding: 0.4rem 0.75rem; border-left: 0.35rem solid var(--group-colour, $gray-500); background-color: $gray-1350; @@ -8,6 +9,11 @@ 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; diff --git a/apps/client/src/features/operator/operator-group/OperatorGroup.tsx b/apps/client/src/features/operator/operator-group/OperatorGroup.tsx index eb8036c2f..3c4d604ef 100644 --- a/apps/client/src/features/operator/operator-group/OperatorGroup.tsx +++ b/apps/client/src/features/operator/operator-group/OperatorGroup.tsx @@ -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; } 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 ( -
+
{title} {`${count} ${count === 1 ? 'event' : 'events'}`} diff --git a/apps/client/src/views/countdown/Countdown.scss b/apps/client/src/views/countdown/Countdown.scss index e1bd75235..d8d8b1c32 100644 --- a/apps/client/src/views/countdown/Countdown.scss +++ b/apps/client/src/views/countdown/Countdown.scss @@ -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; diff --git a/apps/client/src/views/countdown/CountdownSubscriptions.tsx b/apps/client/src/views/countdown/CountdownSubscriptions.tsx index ced544457..6c26e6ca5 100644 --- a/apps/client/src/views/countdown/CountdownSubscriptions.tsx +++ b/apps/client/src/views/countdown/CountdownSubscriptions.tsx @@ -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(null); const scrollRef = useRef(null); + const stickyHeaderRef = useRef(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 (
- {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 ( -
-
- - -
- {event.isGroup && Group} - {displayTitle} -
- {secondaryData &&
{secondaryData}
} +
+ {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 ( +
+
+ + +
+ {event.isGroup && Group} + {displayTitle} +
+ {secondaryData &&
{secondaryData}
} +
+ ); + })}
); })} diff --git a/apps/client/src/views/countdown/countdown.utils.test.ts b/apps/client/src/views/countdown/countdown.utils.test.ts index 13ba3a75c..524d71997 100644 --- a/apps/client/src/views/countdown/countdown.utils.test.ts +++ b/apps/client/src/views/countdown/countdown.utils.test.ts @@ -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, flat: ExtendedEntry[]): 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] }, + ]); + }); +}); diff --git a/apps/client/src/views/countdown/countdown.utils.ts b/apps/client/src/views/countdown/countdown.utils.ts index 746b38bbe..8bc37cf78 100644 --- a/apps/client/src/views/countdown/countdown.utils.ts +++ b/apps/client/src/views/countdown/countdown.utils.ts @@ -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,