mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-08 17:03:53 +00:00
210 lines
7.6 KiB
TypeScript
210 lines
7.6 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { IoPencil } from 'react-icons/io5';
|
||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||
import { dayInMs } from 'ontime-utils';
|
||
|
||
import Button from '../../common/components/buttons/Button';
|
||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||
import { useExpectedStartData, usePlayback, useSelectedEventId } from '../../common/hooks/useSocket';
|
||
import useReport from '../../common/hooks-query/useReport';
|
||
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 '../../features/viewers/common/clock-time/ClockTime';
|
||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
|
||
|
||
import { useCountdownOptions } from './countdown.options';
|
||
import {
|
||
CountdownEvent,
|
||
extendEventData,
|
||
getIsLive,
|
||
isOutsideRange,
|
||
preferredFormat12,
|
||
preferredFormat24,
|
||
useSubscriptionDisplayData,
|
||
} from './countdown.utils';
|
||
|
||
import './Countdown.scss';
|
||
|
||
interface CountdownSubscriptionsProps {
|
||
subscribedEvents: ExtendedEntry<OntimeEvent>[];
|
||
goToEditMode: () => void;
|
||
}
|
||
|
||
export default function CountdownSubscriptions({ subscribedEvents, goToEditMode }: CountdownSubscriptionsProps) {
|
||
const { 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 scrollToComponent = useFollowComponent({
|
||
followRef: selectedRef,
|
||
scrollRef,
|
||
doFollow: !lockAutoScroll,
|
||
topOffset: 0,
|
||
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) {
|
||
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 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}>
|
||
{subscribedEvents.map((event) => {
|
||
const secondaryData = getPropertyValue(event, secondarySource);
|
||
const isLive = getIsLive(event.id, selectedEventId, playback);
|
||
const isArmed = !isLive && event.id === selectedEventId;
|
||
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
|
||
const title = event.title.length ? event.title : ' '; // insert utf-8 empty space to avoid the line collapsing
|
||
return (
|
||
<div
|
||
key={event.id}
|
||
ref={isLive ? selectedRef : undefined}
|
||
className={cx(['sub', isLive && 'sub--live', isArmed && 'sub--armed'])}
|
||
>
|
||
<div className='sub__binder' style={{ '--user-color': event.colour }} />
|
||
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
|
||
<SubscriptionStatus event={countdownEvent} />
|
||
<div className={cx(['sub__title', !event.title && 'subdued'])}>{title}</div>
|
||
{secondaryData && <div className='sub__secondary'>{secondaryData}</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, countToEnd } = 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 expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
|
||
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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|