import { MaybeNumber, OntimeEvent } from 'ontime-types'; import { dayInMs } from 'ontime-utils'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { IoPencil } from 'react-icons/io5'; import Button from '../../common/components/buttons/Button'; import useReport from '../../common/hooks-query/useReport'; import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity'; import useFollowComponent from '../../common/hooks/useFollowComponent'; import { useExpectedStartData, usePlayback, useSelectedEventId } from '../../common/hooks/useSocket'; import { getOffsetState } from '../../common/utils/offset'; import { ExtendedEntry } from '../../common/utils/rundownMetadata'; import { cx } from '../../common/utils/styleUtils'; import { throttle } from '../../common/utils/throttle'; import FollowButton from '../../features/operator/follow-button/FollowButton'; import ClockTime from '../common/clock-time/ClockTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import { getPropertyValue } from '../common/viewUtils'; import { useCountdownOptions } from './countdown.options'; import { CountdownEvent, CountdownTarget, extendEventData, getIsLive, groupSubscriptionTargets, isOutsideRange, preferredFormat12, preferredFormat24, useSubscriptionDisplayData, } from './countdown.utils'; import './Countdown.scss'; interface CountdownSubscriptionsProps { subscribedEvents: CountdownTarget[]; goToEditMode: () => void; } export default function CountdownSubscriptions({ subscribedEvents, goToEditMode }: CountdownSubscriptionsProps) { const { mainSource, secondarySource, showExpected } = useCountdownOptions(); const playback = usePlayback(); const selectedEventId = useSelectedEventId(); const showFab = useFadeOutOnInactivity(true); const { data: reportData } = useReport(); const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData(); const timeoutId = useRef(null); 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, getTopOffset: getStickyOffset, followTrigger: selectedEventId, }); // reset scroll if nothing is selected useEffect(() => { if (!selectedEventId) { if (!lockAutoScroll) { scrollRef.current?.scrollTo(0, 0); } } }, [selectedEventId, lockAutoScroll, scrollRef]); // scroll to component if user clicks the Follow button const handleOffset = () => { if (selectedEventId) { scrollToComponent(); } setLockAutoScroll(false); }; // prevent considering automated scrolls as user scrolls const handleUserScroll = () => { 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); // when the user scrolls we check if we need to show the button const handleScroll = () => { if (timeoutId.current) { clearTimeout(timeoutId.current); } throttledHandleScroll(); }; return (
{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 (
{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}
}
); })}
); })}
); } type ScheduleTimeProps = { event: CountdownEvent; showExpected: boolean; }; //TODO: consider relative mode export function ScheduleTime(props: ScheduleTimeProps) { const { event, showExpected } = props; const { timeStart, duration, delay, expectedStart, expectedEnd } = event; const plannedStart = timeStart + delay + event.dayOffset * dayInMs; // only show new exacted value if outside range of the planned value const isExpectedValueShow = showExpected && isOutsideRange(plannedStart, expectedStart); const plannedStateClass = isExpectedValueShow ? 'sub__schedule--strike' : delay !== 0 ? 'sub__schedule--delayed' : ''; const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`; const plannedEnd = plannedStart + duration + delay; const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`; return (
{!isExpectedValueShow && ( <> → )} {isExpectedValueShow && ( <> )}
); } interface SubscriptionStatusProps { event: ExtendedEntry & { endedAt: MaybeNumber; expectedStart: number }; } function SubscriptionStatus({ event }: SubscriptionStatusProps) { const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event); return ( <>
{statusDisplay}
{status === 'done' ? ( ) : (
{timeDisplay}
)} ); }