From 73a9fb3a72bbccc26f64c675c2ace89611c97878 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Thu, 4 Sep 2025 21:31:28 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=A6=EF=B8=8F(frontend)=20vendor=20Grid?= =?UTF-8?q?Layout=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendor LiveKit layout components to internationalize them --- .../components/controls/PaginationControl.tsx | 55 ++++++++++++++ .../controls/PaginationIndicator.tsx | 30 ++++++++ .../livekit/components/layout/GridLayout.tsx | 71 +++++++++++++++++++ .../rooms/livekit/hooks/useGridLayout.ts | 53 ++++++++++++++ .../rooms/livekit/prefabs/VideoConference.tsx | 2 +- 5 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 src/frontend/src/features/rooms/livekit/components/controls/PaginationControl.tsx create mode 100644 src/frontend/src/features/rooms/livekit/components/controls/PaginationIndicator.tsx create mode 100644 src/frontend/src/features/rooms/livekit/components/layout/GridLayout.tsx create mode 100644 src/frontend/src/features/rooms/livekit/hooks/useGridLayout.ts diff --git a/src/frontend/src/features/rooms/livekit/components/controls/PaginationControl.tsx b/src/frontend/src/features/rooms/livekit/components/controls/PaginationControl.tsx new file mode 100644 index 00000000..679d8428 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/PaginationControl.tsx @@ -0,0 +1,55 @@ +import * as React from 'react' +import { createInteractingObservable } from '@livekit/components-core' +import { usePagination } from '@livekit/components-react' +import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react' + +export interface PaginationControlProps + extends Pick< + ReturnType, + 'totalPageCount' | 'nextPage' | 'prevPage' | 'currentPage' + > { + /** Reference to an HTML element that holds the pages, while interacting (`mouseover`) + * with it, the pagination controls will appear for a while. */ + pagesContainer?: React.RefObject +} + +export function PaginationControl({ + totalPageCount, + nextPage, + prevPage, + currentPage, + pagesContainer: connectedElement, +}: PaginationControlProps) { + const [interactive, setInteractive] = React.useState(false) + React.useEffect(() => { + let subscription: + | ReturnType['subscribe']> + | undefined + if (connectedElement) { + subscription = createInteractingObservable( + connectedElement.current, + 2000 + ).subscribe(setInteractive) + } + return () => { + if (subscription) { + subscription.unsubscribe() + } + } + }, [connectedElement]) + + return ( +
+ + {`${currentPage} of ${totalPageCount}`} + +
+ ) +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/PaginationIndicator.tsx b/src/frontend/src/features/rooms/livekit/components/controls/PaginationIndicator.tsx new file mode 100644 index 00000000..4a99a09d --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/PaginationIndicator.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' + +export interface PaginationIndicatorProps { + totalPageCount: number + currentPage: number +} + +export const PaginationIndicator: ( + props: PaginationIndicatorProps & React.RefAttributes +) => React.ReactNode = /* @__PURE__ */ React.forwardRef< + HTMLDivElement, + PaginationIndicatorProps +>(function PaginationIndicator( + { totalPageCount, currentPage }: PaginationIndicatorProps, + ref +) { + const bubbles = new Array(totalPageCount).fill('').map((_, index) => { + if (index + 1 === currentPage) { + return + } else { + return + } + }) + + return ( +
+ {bubbles} +
+ ) +}) diff --git a/src/frontend/src/features/rooms/livekit/components/layout/GridLayout.tsx b/src/frontend/src/features/rooms/livekit/components/layout/GridLayout.tsx new file mode 100644 index 00000000..6553e27d --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/layout/GridLayout.tsx @@ -0,0 +1,71 @@ +import * as React from 'react' +import type { TrackReferenceOrPlaceholder } from '@livekit/components-core' +import { + TrackLoop, + usePagination, + UseParticipantsOptions, + useSwipe, +} from '@livekit/components-react' +import { mergeProps } from '@/utils/mergeProps' +import { PaginationIndicator } from '../controls/PaginationIndicator' +import { useGridLayout } from '../../hooks/useGridLayout' +import { PaginationControl } from '../controls/PaginationControl' + +/** @public */ +export interface GridLayoutProps + extends React.HTMLAttributes, + Pick { + children: React.ReactNode + tracks: TrackReferenceOrPlaceholder[] +} + +/** + * The `GridLayout` component displays the nested participants in a grid where every participants has the same size. + * It also supports pagination if there are more participants than the grid can display. + * @remarks + * To ensure visual stability when tiles are reordered due to track updates, + * the component uses the `useVisualStableUpdate` hook. + * @example + * ```tsx + * + * + * + * + * + * ``` + * @public + */ +export function GridLayout({ tracks, ...props }: GridLayoutProps) { + const gridEl = React.createRef() + + const elementProps = React.useMemo( + () => mergeProps(props, { className: 'lk-grid-layout' }), + [props] + ) + const { layout } = useGridLayout(gridEl, tracks.length) + const pagination = usePagination(layout.maxTiles, tracks) + + useSwipe(gridEl, { + onLeftSwipe: pagination.nextPage, + onRightSwipe: pagination.prevPage, + }) + + return ( +
1} + {...elementProps} + > + {props.children} + {tracks.length > layout.maxTiles && ( + <> + + + + )} +
+ ) +} diff --git a/src/frontend/src/features/rooms/livekit/hooks/useGridLayout.ts b/src/frontend/src/features/rooms/livekit/hooks/useGridLayout.ts new file mode 100644 index 00000000..17ad5701 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/useGridLayout.ts @@ -0,0 +1,53 @@ +import { GRID_LAYOUTS, selectGridLayout } from '@livekit/components-core' +import type { + GridLayoutDefinition, + GridLayoutInfo, +} from '@livekit/components-core' +import * as React from 'react' +import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver' + +/** + * The `useGridLayout` hook tries to select the best layout to fit all tiles. + * If the available screen space is not enough, it will reduce the number of maximum visible + * tiles and select a layout that still works visually within the given limitations. + * As the order of tiles changes over time, the hook tries to keep visual updates to a minimum + * while trying to display important tiles such as speaking participants or screen shares. + * + * @example + * ```tsx + * const { layout } = useGridLayout(gridElement, trackCount); + * ``` + * @public + */ +export function useGridLayout( + /** HTML element that contains the grid. */ + gridElement: React.RefObject, + /** Count of tracks that should get layed out */ + trackCount: number, + options: { + gridLayouts?: GridLayoutDefinition[] + } = {} +): { layout: GridLayoutInfo; containerWidth: number; containerHeight: number } { + const gridLayouts = options.gridLayouts ?? GRID_LAYOUTS + const { width, height } = useSize(gridElement) + const layout = selectGridLayout(gridLayouts, trackCount, width, height) + + React.useEffect(() => { + if (gridElement.current && layout) { + gridElement.current.style.setProperty( + '--lk-col-count', + layout?.columns.toString() + ) + gridElement.current.style.setProperty( + '--lk-row-count', + layout?.rows.toString() + ) + } + }, [gridElement, layout]) + + return { + layout, + containerWidth: width, + containerHeight: height, + } +} diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index d00d8fe3..0c3efb54 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -11,7 +11,6 @@ import { useState } from 'react' import { ConnectionStateToast, FocusLayoutContainer, - GridLayout, LayoutContextProvider, RoomAudioRenderer, usePinnedTracks, @@ -36,6 +35,7 @@ import { SettingsDialogProvider } from '@/features/settings/components/SettingsD import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles' import { Subtitles } from '@/features/subtitle/component/Subtitles' import { CarouselLayout } from '../components/layout/CarouselLayout' +import { GridLayout } from '../components/layout/GridLayout' const LayoutWrapper = styled( 'div',