diff --git a/src/frontend/src/features/layout/components/CarouselLayout.tsx b/src/frontend/src/features/layout/components/CarouselLayout.tsx index e5403aa9..f24ac751 100644 --- a/src/frontend/src/features/layout/components/CarouselLayout.tsx +++ b/src/frontend/src/features/layout/components/CarouselLayout.tsx @@ -3,6 +3,7 @@ import { getScrollBarWidth } from '@livekit/components-core' import * as React from 'react' import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react' import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver' +import { useCallback, useEffect, useLayoutEffect } from 'react' const MIN_HEIGHT = 130 const MIN_WIDTH = 140 @@ -10,6 +11,72 @@ const MIN_VISIBLE_TILES = 1 const ASPECT_RATIO = 16 / 10 const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1 +type CarouselOrientation = 'vertical' | 'horizontal' + +interface CarouselLayoutState { + orientation: CarouselOrientation + maxVisibleTiles: number +} + +interface CarouselLayoutObserverProps { + asideEl: React.RefObject + orientation?: CarouselOrientation + onLayoutChange: (layout: CarouselLayoutState) => void +} + +const CarouselLayoutObserver = ({ + orientation, + asideEl, + onLayoutChange, +}: CarouselLayoutObserverProps) => { + const { width, height } = useSize(asideEl) + + // Hysteresis memory: avoids flapping between N and N+1 tiles when the + // container size hovers around a breakpoint. A ref (not state) because + // updating it must not trigger a re-render. + const prevTilesRef = React.useRef(0) + + const carouselOrientation: CarouselOrientation = + orientation ?? (height >= width ? 'vertical' : 'horizontal') + + const tileSpan = + carouselOrientation === 'vertical' + ? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT) + : Math.max(height * ASPECT_RATIO, MIN_WIDTH) + + const scrollBarWidth = getScrollBarWidth() + + const availableSpan = + (carouselOrientation === 'vertical' ? height : width) - scrollBarWidth + + const tilesThatFit = Math.max(availableSpan / tileSpan, MIN_VISIBLE_TILES) + + let maxVisibleTiles: number + if (Math.abs(tilesThatFit - prevTilesRef.current) < 0.5) { + // Within the dead zone: keep the previous count. + maxVisibleTiles = Math.round(prevTilesRef.current) + } else { + maxVisibleTiles = Math.round(tilesThatFit) + prevTilesRef.current = tilesThatFit + } + + // Apply cosmetic layout output straight to the DOM. + useLayoutEffect(() => { + const el = asideEl.current + if (!el) return + el.dataset.lkOrientation = carouselOrientation + el.style.setProperty('--lk-max-visible-tiles', maxVisibleTiles.toString()) + }, [asideEl, carouselOrientation, maxVisibleTiles]) + + // Report upward only what the parent actually needs for + // `useVisualStableUpdate` (and only when it changes — see parent handler). + useEffect(() => { + onLayoutChange({ orientation: carouselOrientation, maxVisibleTiles }) + }, [carouselOrientation, maxVisibleTiles, onLayoutChange]) + + return null +} + /** @public */ export interface CarouselLayoutProps extends React.HTMLAttributes { tracks: TrackReferenceOrPlaceholder[] @@ -40,52 +107,42 @@ export function CarouselLayout({ ...props }: CarouselLayoutProps) { const asideEl = React.useRef(null) - const [prevTiles, setPrevTiles] = React.useState(0) - const { width, height } = useSize(asideEl) - const carouselOrientation = orientation - ? orientation - : height >= width - ? 'vertical' - : 'horizontal' - const tileSpan = - carouselOrientation === 'vertical' - ? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT) - : Math.max(height * ASPECT_RATIO, MIN_WIDTH) - const scrollBarWidth = getScrollBarWidth() + const [layout, setLayout] = React.useState({ + orientation: orientation ?? 'vertical', + maxVisibleTiles: MIN_VISIBLE_TILES, + }) - const tilesThatFit = - carouselOrientation === 'vertical' - ? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES) - : Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES) + // Stable callback + identity check: the parent only re-renders when the + // derived layout genuinely changed, not on every resize tick. + const handleLayoutChange = useCallback((next: CarouselLayoutState) => { + setLayout((prev) => + prev.orientation === next.orientation && + prev.maxVisibleTiles === next.maxVisibleTiles + ? prev + : next + ) + }, []) - let maxVisibleTiles = Math.round(tilesThatFit) - if (Math.abs(tilesThatFit - prevTiles) < 0.5) { - maxVisibleTiles = Math.round(prevTiles) - } else if (prevTiles !== tilesThatFit) { - setPrevTiles(tilesThatFit) - } - - const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles) - - React.useLayoutEffect(() => { - if (asideEl.current) { - asideEl.current.dataset.lkOrientation = carouselOrientation - asideEl.current.style.setProperty( - '--lk-max-visible-tiles', - maxVisibleTiles.toString() - ) - } - }, [maxVisibleTiles, carouselOrientation]) + const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles) return ( - + <> + + {/* `key` intentionally remounts the container when orientation flips, */} + {/* which resets scroll position and re-runs the observer measurement. */} + + ) }