diff --git a/src/frontend/src/features/layout/components/CarouselLayout.tsx b/src/frontend/src/features/layout/components/CarouselLayout.tsx index f24ac751..e5403aa9 100644 --- a/src/frontend/src/features/layout/components/CarouselLayout.tsx +++ b/src/frontend/src/features/layout/components/CarouselLayout.tsx @@ -3,7 +3,6 @@ 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 @@ -11,72 +10,6 @@ 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[] @@ -107,42 +40,52 @@ 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 [layout, setLayout] = React.useState({ - orientation: orientation ?? 'vertical', - maxVisibleTiles: MIN_VISIBLE_TILES, - }) + const tileSpan = + carouselOrientation === 'vertical' + ? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT) + : Math.max(height * ASPECT_RATIO, MIN_WIDTH) + const scrollBarWidth = getScrollBarWidth() - // 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 - ) - }, []) + const tilesThatFit = + carouselOrientation === 'vertical' + ? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES) + : Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES) - const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles) + 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]) return ( - <> - - {/* `key` intentionally remounts the container when orientation flips, */} - {/* which resets scroll position and re-runs the observer measurement. */} - - + ) }