mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-31 20:09:11 +00:00
262 lines
9.9 KiB
TypeScript
262 lines
9.9 KiB
TypeScript
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<NodeJS.Timeout | null>(null);
|
|
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,
|
|
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 (
|
|
<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;
|
|
|
|
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>
|
|
);
|
|
})}
|
|
<div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}>
|
|
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
|
|
<IoPencil /> Edit
|
|
</Button>
|
|
</div>
|
|
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className='sub__schedule'>
|
|
<ClockTime
|
|
value={plannedStart}
|
|
preferredFormat12={preferredFormat12}
|
|
preferredFormat24={preferredFormat24}
|
|
className={plannedStateClass}
|
|
/>
|
|
{!isExpectedValueShow && (
|
|
<>
|
|
→
|
|
<ClockTime
|
|
value={plannedEnd}
|
|
preferredFormat12={preferredFormat12}
|
|
preferredFormat24={preferredFormat24}
|
|
className={plannedStateClass}
|
|
/>
|
|
</>
|
|
)}
|
|
{isExpectedValueShow && (
|
|
<>
|
|
<ClockTime
|
|
value={expectedStart}
|
|
className={expectedStateClass}
|
|
preferredFormat12={preferredFormat12}
|
|
preferredFormat24={preferredFormat24}
|
|
/>
|
|
→
|
|
<ClockTime
|
|
value={expectedEnd}
|
|
className={expectedEndClass}
|
|
preferredFormat12={preferredFormat12}
|
|
preferredFormat24={preferredFormat24}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface SubscriptionStatusProps {
|
|
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
|
|
}
|
|
|
|
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
|
const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event);
|
|
|
|
return (
|
|
<>
|
|
<div className='sub__status'>{statusDisplay}</div>
|
|
{status === 'done' ? (
|
|
<SuperscriptTime className='sub__timer' time={timeDisplay} />
|
|
) : (
|
|
<div className='sub__timer'>{timeDisplay}</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|