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;
+}