diff --git a/packages/webui/src/client/lib/VirtualElement.tsx b/packages/webui/src/client/lib/VirtualElement.tsx index 1b825b7292..d9883431f1 100644 --- a/packages/webui/src/client/lib/VirtualElement.tsx +++ b/packages/webui/src/client/lib/VirtualElement.tsx @@ -1,5 +1,6 @@ -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react' import { InView } from 'react-intersection-observer' +import { getViewPortScrollingState } from './viewPort' interface IElementMeasurements { width: string | number @@ -11,12 +12,12 @@ interface IElementMeasurements { id: string | undefined } -const OPTIMIZE_PERIOD = 5000 const IDLE_CALLBACK_TIMEOUT = 100 /** * This is a component that allows optimizing the amount of elements present in the DOM through replacing them * with placeholders when they aren't visible in the viewport. + * Scroll timing issues, should be handled in viewPort.tsx where the scrolling state is tracked. * * @export * @param {(React.PropsWithChildren<{ @@ -40,6 +41,7 @@ const IDLE_CALLBACK_TIMEOUT = 100 * } * @return {*} {(JSX.Element | null)} */ + export function VirtualElement({ initialShow, placeholderHeight, @@ -59,89 +61,272 @@ export function VirtualElement({ id?: string | undefined className?: string }>): JSX.Element | null { + const resizeObserverManager = ElementObserverManager.getInstance() const [inView, setInView] = useState(initialShow ?? false) + const [waitForInitialLoad, setWaitForInitialLoad] = useState(true) const [isShowingChildren, setIsShowingChildren] = useState(inView) + const [measurements, setMeasurements] = useState(null) const [ref, setRef] = useState(null) - const [childRef, setChildRef] = useState(null) - const isMeasured = !!measurements + // Timers for visibility changes: + const scrollTimeoutRef = useRef | undefined>(undefined) + const inViewChangeTimerRef = useRef | undefined>(undefined) + const skipInitialrunRef = useRef(true) + const isTransitioning = useRef(false) + + const isCurrentlyObserving = useRef(false) const styleObj = useMemo( () => ({ - width: width ?? measurements?.width ?? 'auto', - height: (measurements?.clientHeight ?? placeholderHeight ?? '0') + 'px', - marginTop: measurements?.marginTop, - marginLeft: measurements?.marginLeft, - marginRight: measurements?.marginRight, - marginBottom: measurements?.marginBottom, + width: width ?? 'auto', + height: ((placeholderHeight || ref?.clientHeight) ?? '0') + 'px', + marginTop: 0, + marginLeft: 0, + marginRight: 0, + marginBottom: 0, + // These properties are used to ensure that if a prior element is changed from + // placeHolder to element, the position of visible elements are not affected. + contentVisibility: 'auto', + containIntrinsicSize: `0 ${(placeholderHeight || ref?.clientHeight) ?? '0'}px`, + contain: 'size layout', }), - [width, measurements, placeholderHeight] + [width, placeholderHeight] ) - const onVisibleChanged = useCallback((visible: boolean) => { - setInView(visible) - }, []) + const handleResize = useCallback(() => { + if (ref) { + // Show children during measurement + setIsShowingChildren(true) + + requestAnimationFrame(() => { + const measurements = measureElement(ref, placeholderHeight) + if (measurements) { + setMeasurements(measurements) + + // Only hide children again if not in view + if (!inView && measurements.clientHeight > 0) { + setIsShowingChildren(false) + } else { + setIsShowingChildren(true) + } + } + }) + } + }, [ref, inView, placeholderHeight]) + // failsafe to ensure visible elements if resizing happens while scrolling useEffect(() => { - if (inView === true) { + if (!isShowingChildren) { + const checkVisibilityByPosition = () => { + if (ref) { + const rect = ref.getBoundingClientRect() + const isInViewport = rect.top < window.innerHeight && rect.bottom > 0 + + if (isInViewport) { + setIsShowingChildren(true) + setInView(true) + } + } + } + + // Check every second + const positionCheckInterval = setInterval(checkVisibilityByPosition, 1000) + + return () => { + clearInterval(positionCheckInterval) + } + } + }, [ref, isShowingChildren]) + + // Ensure elements are visible after a fast scroll: + useEffect(() => { + const checkVisibilityOnScroll = () => { + if (inView && !isShowingChildren) { + setIsShowingChildren(true) + } + + // Add a check after scroll stops + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current) + } + scrollTimeoutRef.current = setTimeout(() => { + // Recheck visibility after scroll appears to have stopped + if (inView && !isShowingChildren) { + setIsShowingChildren(true) + } + }, 200) + } + + window.addEventListener('scroll', checkVisibilityOnScroll, { passive: true }) + + return () => { + window.removeEventListener('scroll', checkVisibilityOnScroll) + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current) + } + } + }, [inView, isShowingChildren]) + + useEffect(() => { + if (inView) { setIsShowingChildren(true) + } + + // Startup skip: + if (skipInitialrunRef.current) { + skipInitialrunRef.current = false return } - let idleCallback: number | undefined - const optimizeTimeout = window.setTimeout(() => { - idleCallback = window.requestIdleCallback( - () => { - if (childRef) { - setMeasurements(measureElement(childRef)) + if (isTransitioning.current) { + return + } + + isTransitioning.current = true + + // Clear any existing timers + if (inViewChangeTimerRef.current) { + clearTimeout(inViewChangeTimerRef.current) + inViewChangeTimerRef.current = undefined + } + + // Delay the visibility change to avoid flickering + // But low enough for scrolling to be responsive + inViewChangeTimerRef.current = setTimeout(() => { + try { + if (inView) { + if (ref) { + if (!isCurrentlyObserving.current) { + resizeObserverManager.observe(ref, handleResize) + isCurrentlyObserving.current = true + } + } + } else { + if (ref && isCurrentlyObserving.current) { + resizeObserverManager.unobserve(ref) + isCurrentlyObserving.current = false } setIsShowingChildren(false) - }, - { - timeout: IDLE_CALLBACK_TIMEOUT, } - ) - }, OPTIMIZE_PERIOD) + } catch (error) { + console.error('Error in visibility change handler:', error) + } finally { + isTransitioning.current = false + inViewChangeTimerRef.current = undefined + } + }, 100) + }, [inView, ref, handleResize, resizeObserverManager]) - return () => { - if (idleCallback) { - window.cancelIdleCallback(idleCallback) + const onVisibleChanged = useCallback( + (visible: boolean) => { + // Only update state if there's a change + if (inView !== visible) { + setInView(visible) } + }, + [inView] + ) - window.clearTimeout(optimizeTimeout) + const isScrolling = (): boolean => { + // Don't do updates while scrolling: + if (getViewPortScrollingState().isProgrammaticScrollInProgress) { + return true } - }, [childRef, inView]) + // And wait if a programmatic scroll was done recently: + const timeSinceLastProgrammaticScroll = Date.now() - getViewPortScrollingState().lastProgrammaticScrollTime + if (timeSinceLastProgrammaticScroll < 100) { + return true + } + return false + } - const showPlaceholder = !isShowingChildren && (!initialShow || isMeasured) + useEffect(() => { + // Setup initial observer if element is in view + if (ref && inView && !isCurrentlyObserving.current) { + resizeObserverManager.observe(ref, handleResize) + isCurrentlyObserving.current = true + } + + // Cleanup function + return () => { + // Clean up resize observer + if (ref && isCurrentlyObserving.current) { + resizeObserverManager.unobserve(ref) + isCurrentlyObserving.current = false + } + + if (inViewChangeTimerRef.current) { + clearTimeout(inViewChangeTimerRef.current) + } + } + }, [ref, inView, handleResize]) - useLayoutEffect(() => { - if (!ref || showPlaceholder) return + useEffect(() => { + if (inView === true) { + setIsShowingChildren(true) - const el = ref?.firstElementChild - if (!el || el.classList.contains('virtual-element-placeholder') || !(el instanceof HTMLElement)) return + // Schedule a measurement after a short delay + if (waitForInitialLoad && ref) { + const initialMeasurementTimeout = window.setTimeout(() => { + const measurements = measureElement(ref, placeholderHeight) + if (measurements) { + setMeasurements(measurements) + setWaitForInitialLoad(false) + } + }, 800) - setChildRef(el) + return () => { + window.clearTimeout(initialMeasurementTimeout) + } + } + return + } let idleCallback: number | undefined - const refreshSizingTimeout = window.setTimeout(() => { + let optimizeTimeout: number | undefined + + const scheduleOptimization = () => { + if (optimizeTimeout) { + window.clearTimeout(optimizeTimeout) + } + // Don't proceed if we're scrolling + if (isScrolling()) { + // Reschedule for after the scroll should be complete + const scrollDelay = 400 + window.clearTimeout(optimizeTimeout) + optimizeTimeout = window.setTimeout(scheduleOptimization, scrollDelay) + return + } idleCallback = window.requestIdleCallback( () => { - setMeasurements(measureElement(el)) + // Measure the entire wrapper element instead of just the childRef + if (ref) { + const measurements = measureElement(ref, placeholderHeight) + if (measurements) { + setMeasurements(measurements) + } + } + setIsShowingChildren(false) }, { timeout: IDLE_CALLBACK_TIMEOUT, } ) - }, 1000) + } + + // Schedule the optimization: + scheduleOptimization() return () => { if (idleCallback) { window.cancelIdleCallback(idleCallback) } - window.clearTimeout(refreshSizingTimeout) + if (optimizeTimeout) { + window.clearTimeout(optimizeTimeout) + } } - }, [ref, showPlaceholder]) + }, [ref, inView, placeholderHeight]) return ( -
- {showPlaceholder ? ( +
+ {!isShowingChildren ? (
) } +function measureElement(wrapperEl: HTMLDivElement, placeholderHeight?: number): IElementMeasurements | null { + if (!wrapperEl || !wrapperEl.firstElementChild) { + return null + } -function measureElement(el: HTMLElement): IElementMeasurements | null { + const el = wrapperEl.firstElementChild as HTMLElement const style = window.getComputedStyle(el) - const clientRect = el.getBoundingClientRect() + let segmentTimeline: Element | null = null + let dashboardPanel: Element | null = null + + segmentTimeline = wrapperEl.querySelector('.segment-timeline') + dashboardPanel = wrapperEl.querySelector('.dashboard-panel') + + if (segmentTimeline) { + const segmentRect = segmentTimeline.getBoundingClientRect() + let totalHeight = segmentRect.height + + if (dashboardPanel) { + const panelRect = dashboardPanel.getBoundingClientRect() + totalHeight += panelRect.height + } + + if (totalHeight < 40) { + totalHeight = placeholderHeight ?? el.clientHeight + } + + return { + width: style.width || 'auto', + clientHeight: totalHeight, + marginTop: style.marginTop || undefined, + marginBottom: style.marginBottom || undefined, + marginLeft: style.marginLeft || undefined, + marginRight: style.marginRight || undefined, + id: el.id, + } + } + + // Fallback to just measuring the element itself if wrapper isn't found return { width: style.width || 'auto', - clientHeight: clientRect.height, + clientHeight: placeholderHeight ?? el.clientHeight, marginTop: style.marginTop || undefined, marginBottom: style.marginBottom || undefined, marginLeft: style.marginLeft || undefined, @@ -180,3 +409,88 @@ function measureElement(el: HTMLElement): IElementMeasurements | null { id: el.id, } } + +// Singleton class to manage ResizeObserver instances +export class ElementObserverManager { + private static instance: ElementObserverManager + private resizeObserver: ResizeObserver + private mutationObserver: MutationObserver + private observedElements: Map void> + + private constructor() { + this.observedElements = new Map() + + // Configure ResizeObserver + this.resizeObserver = new ResizeObserver((entries) => { + entries.forEach((entry) => { + const element = entry.target as HTMLElement + const callback = this.observedElements.get(element) + if (callback) { + callback() + } + }) + }) + + // Configure MutationObserver + this.mutationObserver = new MutationObserver((mutations) => { + const targets = new Set() + + mutations.forEach((mutation) => { + const target = mutation.target as HTMLElement + // Find the closest observed element + let element = target + while (element) { + if (this.observedElements.has(element)) { + targets.add(element) + break + } + if (!element.parentElement) break + element = element.parentElement + } + }) + + // Call callbacks for affected elements + targets.forEach((element) => { + const callback = this.observedElements.get(element) + if (callback) callback() + }) + }) + } + + public static getInstance(): ElementObserverManager { + if (!ElementObserverManager.instance) { + ElementObserverManager.instance = new ElementObserverManager() + } + return ElementObserverManager.instance + } + + public observe(element: HTMLElement, callback: () => void): void { + if (!element) return + + this.observedElements.set(element, callback) + this.resizeObserver.observe(element) + this.mutationObserver.observe(element, { + childList: true, + subtree: true, + attributes: true, + characterData: true, + }) + } + + public unobserve(element: HTMLElement): void { + if (!element) return + this.observedElements.delete(element) + this.resizeObserver.unobserve(element) + + // Disconnect and reconnect mutation observer to refresh the list of observed elements + this.mutationObserver.disconnect() + this.observedElements.forEach((_, el) => { + this.mutationObserver.observe(el, { + childList: true, + subtree: true, + attributes: true, + characterData: true, + }) + }) + } +} diff --git a/packages/webui/src/client/lib/viewPort.ts b/packages/webui/src/client/lib/viewPort.ts index d3f4696422..35c6f27c70 100644 --- a/packages/webui/src/client/lib/viewPort.ts +++ b/packages/webui/src/client/lib/viewPort.ts @@ -9,8 +9,24 @@ import { logger } from './logging' const HEADER_MARGIN = 24 // TODOSYNC: TV2 uses 15. If it's needed to be different, it needs to be made generic somehow.. const FALLBACK_HEADER_HEIGHT = 65 -let focusInterval: NodeJS.Timeout | undefined -let _dontClearInterval = false +// Replace the global variable with a more structured approach +const focusState = { + interval: undefined as NodeJS.Timeout | undefined, + isScrolling: false, + startTime: 0, +} + +const viewPortScrollingState = { + isProgrammaticScrollInProgress: false, + lastProgrammaticScrollTime: 0, +} + +export function getViewPortScrollingState(): { + isProgrammaticScrollInProgress: boolean + lastProgrammaticScrollTime: number +} { + return viewPortScrollingState +} export function maintainFocusOnPartInstance( partInstanceId: PartInstanceId, @@ -18,32 +34,47 @@ export function maintainFocusOnPartInstance( forceScroll?: boolean, noAnimation?: boolean ): void { - const startTime = Date.now() - const focus = () => { - if (Date.now() - startTime < timeWindow) { - _dontClearInterval = true - scrollToPartInstance(partInstanceId, forceScroll, noAnimation) - .then(() => { - _dontClearInterval = false - }) - .catch(() => { - _dontClearInterval = false - }) - } else { + focusState.startTime = Date.now() + + const focus = async () => { + // Only proceed if we're not already scrolling and within the time window + if (!focusState.isScrolling && Date.now() - focusState.startTime < timeWindow) { + focusState.isScrolling = true + + try { + await scrollToPartInstance(partInstanceId, forceScroll, noAnimation) + } catch (error) { + // Handle error if needed + } finally { + focusState.isScrolling = false + } + } else if (Date.now() - focusState.startTime >= timeWindow) { quitFocusOnPart() } } + document.addEventListener('wheel', onWheelWhenMaintainingFocus, { once: true, capture: true, passive: true, }) - focusInterval = setInterval(focus, 500) + + // Clear any existing interval before creating a new one + if (focusState.interval) { + clearInterval(focusState.interval) + } + focus() + .then(() => { + focusState.interval = setInterval(focus, 500) + }) + .catch(() => { + // Handle error if needed + }) } export function isMaintainingFocus(): boolean { - return !!focusInterval + return !!focusState.interval } function onWheelWhenMaintainingFocus() { @@ -54,9 +85,10 @@ function quitFocusOnPart() { document.removeEventListener('wheel', onWheelWhenMaintainingFocus, { capture: true, }) - if (!_dontClearInterval && focusInterval) { - clearInterval(focusInterval) - focusInterval = undefined + + if (focusState.interval) { + clearInterval(focusState.interval) + focusState.interval = undefined } } @@ -68,11 +100,7 @@ export async function scrollToPartInstance( quitFocusOnPart() const partInstance = UIPartInstances.findOne(partInstanceId) if (partInstance) { - RundownViewEventBus.emit(RundownViewEvents.GO_TO_PART_INSTANCE, { - segmentId: partInstance.segmentId, - partInstanceId: partInstanceId, - }) - return scrollToSegment(partInstance.segmentId, forceScroll, noAnimation, partInstanceId) + return scrollToSegment(partInstance.segmentId, forceScroll, noAnimation) } throw new Error('Could not find PartInstance') } @@ -121,39 +149,10 @@ let currentScrollingElement: HTMLElement | undefined export async function scrollToSegment( elementToScrollToOrSegmentId: HTMLElement | SegmentId, forceScroll?: boolean, - noAnimation?: boolean, - partInstanceId?: PartInstanceId | undefined + noAnimation?: boolean ): Promise { - const getElementToScrollTo = (showHistory: boolean): HTMLElement | null => { - if (isProtectedString(elementToScrollToOrSegmentId)) { - let targetElement = document.querySelector( - `#${SEGMENT_TIMELINE_ELEMENT_ID}${elementToScrollToOrSegmentId}` - ) - - if (showHistory && Settings.followOnAirSegmentsHistory && targetElement) { - let i = Settings.followOnAirSegmentsHistory - while (i > 0) { - // Segment timeline is wrapped by
...
when rendered - const next: any = targetElement?.parentElement?.parentElement?.previousElementSibling?.children - .item(0) - ?.children.item(0) - if (next) { - targetElement = next - i-- - } else { - i = 0 - } - } - } - - return targetElement - } - - return elementToScrollToOrSegmentId - } - - const elementToScrollTo: HTMLElement | null = getElementToScrollTo(false) - const historyTarget: HTMLElement | null = getElementToScrollTo(true) + const elementToScrollTo: HTMLElement | null = getElementToScrollTo(elementToScrollToOrSegmentId, false) + const historyTarget: HTMLElement | null = getElementToScrollTo(elementToScrollToOrSegmentId, true) // historyTarget will be === to elementToScrollTo if history is not used / not found if (!elementToScrollTo || !historyTarget) { @@ -164,24 +163,71 @@ export async function scrollToSegment( historyTarget, forceScroll || !regionInViewport(historyTarget, elementToScrollTo), noAnimation, - false, - partInstanceId + false ) } +function getElementToScrollTo( + elementToScrollToOrSegmentId: HTMLElement | SegmentId, + showHistory: boolean +): HTMLElement | null { + if (isProtectedString(elementToScrollToOrSegmentId)) { + // Get the current segment element + let targetElement = document.querySelector( + `#${SEGMENT_TIMELINE_ELEMENT_ID}${elementToScrollToOrSegmentId}` + ) + if (showHistory && Settings.followOnAirSegmentsHistory && targetElement) { + let i = Settings.followOnAirSegmentsHistory + + // Find previous segments + while (i > 0 && targetElement) { + const currentSegmentId = targetElement.id + const allSegments = Array.from(document.querySelectorAll(`[id^="${SEGMENT_TIMELINE_ELEMENT_ID}"]`)) + + // Find current segment's index in the array of all segments + const currentIndex = allSegments.findIndex((el) => el.id === currentSegmentId) + + // Find the previous segment + if (currentIndex > 0) { + targetElement = allSegments[currentIndex - 1] as HTMLElement + i-- + } else { + // No more previous segments + break + } + } + } + + return targetElement + } + + return elementToScrollToOrSegmentId +} + +let pendingFirstStageTimeout: NodeJS.Timeout | undefined + async function innerScrollToSegment( elementToScrollTo: HTMLElement, forceScroll?: boolean, noAnimation?: boolean, - secondStage?: boolean, - partInstanceId?: PartInstanceId | undefined + secondStage?: boolean ): Promise { if (!secondStage) { + if (pendingFirstStageTimeout) { + clearTimeout(pendingFirstStageTimeout) + pendingFirstStageTimeout = undefined + } currentScrollingElement = elementToScrollTo } else if (secondStage && elementToScrollTo !== currentScrollingElement) { throw new Error('Scroll overriden by another scroll') } + // Ensure that the element is ready to be scrolled: + if (!secondStage) { + await new Promise((resolve) => setTimeout(resolve, 100)) + } + await new Promise((resolve) => requestAnimationFrame(resolve)) + let { top, bottom } = elementToScrollTo.getBoundingClientRect() top = Math.floor(top) bottom = Math.floor(bottom) @@ -194,36 +240,25 @@ async function innerScrollToSegment( return scrollToPosition(top + window.scrollY, noAnimation).then( async () => { - // retry scroll in case we have to load some data - if (pendingSecondStageScroll) window.cancelIdleCallback(pendingSecondStageScroll) return new Promise((resolve, reject) => { - // scrollToPosition will resolve after some time, at which point a new pendingSecondStageScroll may have been created - - pendingSecondStageScroll = window.requestIdleCallback( - () => { - if (!secondStage) { - let { top, bottom } = elementToScrollTo.getBoundingClientRect() - top = Math.floor(top) - bottom = Math.floor(bottom) - - if (bottom > Math.floor(window.innerHeight) || top < headerHeight) { - innerScrollToSegment( - elementToScrollTo, - forceScroll, - true, - true, - partInstanceId - ).then(resolve, reject) - } else { - resolve(true) - } + if (!secondStage) { + // Wait to settle 1 atemt to scroll + pendingFirstStageTimeout = setTimeout(() => { + pendingFirstStageTimeout = undefined + let { top, bottom } = elementToScrollTo.getBoundingClientRect() + top = Math.floor(top) + bottom = Math.floor(bottom) + if (bottom > Math.floor(window.innerHeight) || top < headerHeight) { + // If not in place atempt to scroll again + innerScrollToSegment(elementToScrollTo, forceScroll, true, true).then(resolve, reject) } else { - currentScrollingElement = undefined resolve(true) } - }, - { timeout: 250 } - ) + }, 420) + } else { + currentScrollingElement = undefined + resolve(true) + } }) }, (error) => { @@ -253,41 +288,29 @@ function getRegionPosition(topElement: HTMLElement, bottomElement: HTMLElement): return { top, bottom } } -let scrollToPositionRequest: number | undefined -let scrollToPositionRequestReject: ((reason?: any) => void) | undefined - export async function scrollToPosition(scrollPosition: number, noAnimation?: boolean): Promise { + // Calculate the exact position + const headerOffset = getHeaderHeight() + HEADER_MARGIN + const targetTop = Math.max(0, scrollPosition - headerOffset) + if (noAnimation) { window.scroll({ - top: Math.max(0, scrollPosition - getHeaderHeight() - HEADER_MARGIN), + top: targetTop, left: 0, + behavior: 'instant', }) return Promise.resolve() } else { - return new Promise((resolve, reject) => { - if (scrollToPositionRequest !== undefined) window.cancelIdleCallback(scrollToPositionRequest) - if (scrollToPositionRequestReject !== undefined) - scrollToPositionRequestReject('Prevented by another scroll') - - scrollToPositionRequestReject = reject - const currentTop = window.scrollY - const targetTop = Math.max(0, scrollPosition - getHeaderHeight() - HEADER_MARGIN) - scrollToPositionRequest = window.requestIdleCallback( - () => { - window.scroll({ - top: targetTop, - left: 0, - behavior: 'smooth', - }) - setTimeout(() => { - resolve() - scrollToPositionRequestReject = undefined - // this formula was experimentally created from Chrome 86 behavior - }, 3000 * Math.log(Math.abs(currentTop - targetTop) / 2000 + 1)) - }, - { timeout: 250 } - ) + viewPortScrollingState.isProgrammaticScrollInProgress = true + viewPortScrollingState.lastProgrammaticScrollTime = Date.now() + + window.scroll({ + top: targetTop, + left: 0, + behavior: 'smooth', }) + await new Promise((resolve) => setTimeout(resolve, 300)) + viewPortScrollingState.isProgrammaticScrollInProgress = false } } diff --git a/packages/webui/src/client/ui/RundownView.tsx b/packages/webui/src/client/ui/RundownView.tsx index d89bd3d0fc..4d96a51930 100644 --- a/packages/webui/src/client/ui/RundownView.tsx +++ b/packages/webui/src/client/ui/RundownView.tsx @@ -1982,9 +1982,14 @@ const RundownViewContent = translateWithTracker { - if (!error.toString().match(/another scroll/)) console.warn(error) - }) + // add small delay to ensure the nextPartInfo is available + setTimeout(() => { + if (this.props.playlist && this.props.playlist.nextPartInfo) { + scrollToPartInstance(this.props.playlist.nextPartInfo.partInstanceId).catch((error) => { + if (!error.toString().match(/another scroll/)) console.warn(error) + }) + } + }, 120) } else if ( // after take this.props.playlist && @@ -2166,24 +2171,36 @@ const RundownViewContent = translateWithTracker { - if (this.state.followLiveSegments && this.props.playlist && this.props.playlist.activationId) { - const liveSegmentComponent = document.querySelector('.segment-timeline.live') - if (liveSegmentComponent) { - const offsetPosition = liveSegmentComponent.getBoundingClientRect() - // if it's closer to the top edge than the headerHeight - const segmentComponentTooHigh = offsetPosition.top < getHeaderHeight() - // or if it's closer to the bottom edge than very close to the top - const segmentComponentTooLow = - offsetPosition.bottom < window.innerHeight - getHeaderHeight() - 20 - (offsetPosition.height * 3) / 2 - if (segmentComponentTooHigh || segmentComponentTooLow) { - this.setState({ - followLiveSegments: false, - }) + onWheelScrollInner = _.throttle( + () => { + if (this.state.followLiveSegments && this.props.playlist && this.props.playlist.activationId) { + const liveSegmentComponent = document.querySelector('.segment-timeline.live') + if (liveSegmentComponent) { + const offsetPosition = liveSegmentComponent.getBoundingClientRect() + const headerHeight = getHeaderHeight() + + // Use a buffer zone to prevent oscillation + const topBuffer = headerHeight + 10 + const bottomBuffer = window.innerHeight - headerHeight - 20 - (offsetPosition.height * 3) / 2 + + // Check if segment is outside the comfortable viewing area + const segmentComponentTooHigh = offsetPosition.top < topBuffer + const segmentComponentTooLow = offsetPosition.bottom < bottomBuffer + + if (segmentComponentTooHigh || segmentComponentTooLow) { + // Only change state if we need to + if (this.state.followLiveSegments) { + this.setState({ + followLiveSegments: false, + }) + } + } } } - } - }, 250) + }, + 100, + { leading: true, trailing: true } + ) onWheel = (e: React.WheelEvent) => { if (e.deltaX === 0 && e.deltaY !== 0 && !e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey) { @@ -2216,9 +2233,14 @@ const RundownViewContent = translateWithTracker { - if (!error.toString().match(/another scroll/)) console.warn(error) - }) + // Small delay to ensure the nextPartInfo is available + setTimeout(() => { + if (this.props.playlist && this.props.playlist.nextPartInfo) { + scrollToPartInstance(this.props.playlist.nextPartInfo.partInstanceId, true).catch((error) => { + if (!error.toString().match(/another scroll/)) console.warn(error) + }) + } + }, 120) setTimeout(() => { this.setState({ followLiveSegments: true, diff --git a/packages/webui/src/client/ui/RundownView/RundownRightHandControls.tsx b/packages/webui/src/client/ui/RundownView/RundownRightHandControls.tsx index 974415f5fd..d2fdc6241e 100644 --- a/packages/webui/src/client/ui/RundownView/RundownRightHandControls.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownRightHandControls.tsx @@ -168,19 +168,21 @@ export function RundownRightHandControls(props: Readonly): JSX.Element { > - {!props.isFollowingOnAir && ( - - )} +
+ {!props.isFollowingOnAir && ( + + )} +
isVisible: boolean + visibilityChangeTimeout: NodeJS.Timeout | undefined rundownCurrentPartInstanceId: PartInstanceId | null = null timelineDiv: HTMLDivElement | null = null intersectionObserver: IntersectionObserver | undefined @@ -194,14 +195,17 @@ const SegmentTimelineContainerContent = withResolvedSegment( RundownViewEventBus.on(RundownViewEvents.REWIND_SEGMENTS, this.onRewindSegment) RundownViewEventBus.on(RundownViewEvents.GO_TO_PART, this.onGoToPart) RundownViewEventBus.on(RundownViewEvents.GO_TO_PART_INSTANCE, this.onGoToPartInstance) - window.requestAnimationFrame(() => { - this.mountedTime = Date.now() - if (this.state.isLiveSegment && this.props.followLiveSegments && !this.isVisible) { - scrollToSegment(this.props.segmentId, true).catch((error) => { - if (!error.toString().match(/another scroll/)) console.warn(error) - }) - } - }) + // Delay is to ensure UI has settled before checking: + setTimeout(() => { + window.requestAnimationFrame(() => { + this.mountedTime = Date.now() + if (this.state.isLiveSegment && this.props.followLiveSegments && !this.isVisible) { + scrollToSegment(this.props.segmentId, true).catch((error) => { + if (!error.toString().match(/another scroll/)) console.warn(error) + }) + } + }) + }, 500) window.addEventListener('resize', this.onWindowResize) this.updateMaxTimeScale() .then(() => this.showEntireSegment()) @@ -535,12 +539,19 @@ const SegmentTimelineContainerContent = withResolvedSegment( } visibleChanged = (entries: IntersectionObserverEntry[]) => { - if (entries[0].intersectionRatio < 0.99 && !isMaintainingFocus() && Date.now() - this.mountedTime > 2000) { - if (typeof this.props.onSegmentScroll === 'function') this.props.onSegmentScroll() - this.isVisible = false - } else { - this.isVisible = true + // Add a small debounce to ensure UI has settled before checking + if (this.visibilityChangeTimeout) { + clearTimeout(this.visibilityChangeTimeout) } + + this.visibilityChangeTimeout = setTimeout(() => { + if (entries[0].intersectionRatio < 0.99 && !isMaintainingFocus() && Date.now() - this.mountedTime > 2000) { + if (typeof this.props.onSegmentScroll === 'function') this.props.onSegmentScroll() + this.isVisible = false + } else { + this.isVisible = true + } + }, 1800) } startLive = () => {