From 4767c445bec3879380025088ddf5b69d1e18dccf Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:10:25 +0100 Subject: [PATCH] feat: dynamic voice grid layout with ResizeObserver Replace hardcoded Tailwind grid-cols breakpoints with a container-aware layout algorithm that uses ResizeObserver to recompute optimal tile arrangement. Maximizes tile area while maintaining 16:9 aspect ratio, automatically adapting when VoiceChatPanel opens/closes, window resizes, or fullscreen is toggled. --- .../web/src/components/voice/StreamTile.tsx | 2 +- .../web/src/components/voice/VoiceGrid.tsx | 31 +++--- .../web/src/components/voice/VoiceUser.tsx | 2 +- packages/web/src/hooks/useGridLayout.ts | 98 +++++++++++++++++++ 4 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 packages/web/src/hooks/useGridLayout.ts diff --git a/packages/web/src/components/voice/StreamTile.tsx b/packages/web/src/components/voice/StreamTile.tsx index cb3e54df..b3afdd26 100644 --- a/packages/web/src/components/voice/StreamTile.tsx +++ b/packages/web/src/components/voice/StreamTile.tsx @@ -133,7 +133,7 @@ export function StreamTile({ tile, large }: StreamTileProps) { return (
diff --git a/packages/web/src/components/voice/VoiceGrid.tsx b/packages/web/src/components/voice/VoiceGrid.tsx index 6c294368..026ebd00 100644 --- a/packages/web/src/components/voice/VoiceGrid.tsx +++ b/packages/web/src/components/voice/VoiceGrid.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { VoiceUser } from './VoiceUser'; import { StreamTile } from './StreamTile'; import { useVoiceStore } from '../../stores/voiceStore'; import { deriveGridTiles } from '../../hooks/useLiveKit'; +import { useGridLayout } from '../../hooks/useGridLayout'; import type { ParticipantInfo, GridTile } from '../../hooks/useLiveKit'; interface VoiceGridProps { @@ -16,6 +17,9 @@ export function VoiceGrid({ participants }: VoiceGridProps) { const tiles = useMemo(() => deriveGridTiles(participants), [participants]); + const containerRef = useRef(null); + const { cols, tileWidth, tileHeight } = useGridLayout(containerRef, tiles.length); + // Reset strip visibility when focus target changes useEffect(() => { setStripHidden(false); @@ -150,23 +154,24 @@ export function VoiceGrid({ participants }: VoiceGridProps) { ); } - // Default grid mode - const gridClass = (() => { - if (tiles.length === 1) return 'grid-cols-1 max-w-2xl mx-auto'; - if (tiles.length === 2) return 'grid-cols-2 max-w-4xl mx-auto'; - if (tiles.length <= 4) return 'grid-cols-2'; - if (tiles.length <= 9) return 'grid-cols-3'; - return 'grid-cols-4'; - })(); - + // Default grid mode — container-aware layout via ResizeObserver return ( -
-
+
+
{tiles.map((t) => (
setFocusedParticipant(t.key)} - className="cursor-pointer hover:opacity-90 transition-opacity h-full" + className="cursor-pointer hover:opacity-90 transition-opacity" > {renderTile(t)}
diff --git a/packages/web/src/components/voice/VoiceUser.tsx b/packages/web/src/components/voice/VoiceUser.tsx index 91fccf11..497f7281 100644 --- a/packages/web/src/components/voice/VoiceUser.tsx +++ b/packages/web/src/components/voice/VoiceUser.tsx @@ -108,7 +108,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) { isSpeaking ? 'ring-[3px] ring-status-online shadow-[0_0_12px_rgba(134,239,172,0.25)]' : 'ring-1 ring-white/[0.06] hover:ring-white/10' - } ${large ? 'h-full w-full' : 'h-full aspect-video'}`} + } h-full w-full`} onContextMenu={handleContextMenu} > {hasVideo ? ( diff --git a/packages/web/src/hooks/useGridLayout.ts b/packages/web/src/hooks/useGridLayout.ts new file mode 100644 index 00000000..87b1dde8 --- /dev/null +++ b/packages/web/src/hooks/useGridLayout.ts @@ -0,0 +1,98 @@ +import { useState, useEffect, useRef, type RefObject } from 'react'; + +interface GridLayoutOptions { + gap?: number; + aspectRatio?: number; + padding?: number; +} + +interface GridLayout { + cols: number; + rows: number; + tileWidth: number; + tileHeight: number; +} + +export function useGridLayout( + containerRef: RefObject, + tileCount: number, + options: GridLayoutOptions = {}, +): GridLayout { + const { gap = 8, aspectRatio = 16 / 9, padding = 12 } = options; + + const [layout, setLayout] = useState({ + cols: 1, + rows: 1, + tileWidth: 320, + tileHeight: 180, + }); + + const prevRef = useRef(layout); + + useEffect(() => { + const el = containerRef.current; + if (!el || tileCount === 0) return; + + const compute = () => { + const containerWidth = el.clientWidth - padding * 2; + const containerHeight = el.clientHeight - padding * 2; + if (containerWidth <= 0 || containerHeight <= 0) return; + + let bestCols = 1; + let bestArea = 0; + let bestW = 0; + let bestH = 0; + + for (let cols = 1; cols <= tileCount; cols++) { + const rows = Math.ceil(tileCount / cols); + + const maxTileW = (containerWidth - gap * (cols - 1)) / cols; + const maxTileH = (containerHeight - gap * (rows - 1)) / rows; + + // Fit within both constraints while maintaining aspect ratio + let tileW = maxTileW; + let tileH = tileW / aspectRatio; + + if (tileH > maxTileH) { + tileH = maxTileH; + tileW = tileH * aspectRatio; + } + + const area = tileW * tileH; + if (area > bestArea) { + bestArea = area; + bestCols = cols; + bestW = Math.floor(tileW); + bestH = Math.floor(tileH); + } + } + + const bestRows = Math.ceil(tileCount / bestCols); + const next: GridLayout = { + cols: bestCols, + rows: bestRows, + tileWidth: bestW, + tileHeight: bestH, + }; + + const prev = prevRef.current; + if ( + prev.cols !== next.cols || + prev.rows !== next.rows || + prev.tileWidth !== next.tileWidth || + prev.tileHeight !== next.tileHeight + ) { + prevRef.current = next; + setLayout(next); + } + }; + + compute(); + + const observer = new ResizeObserver(compute); + observer.observe(el); + return () => observer.disconnect(); + }, [containerRef, tileCount, gap, aspectRatio, padding]); + + return layout; +}