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.
This commit is contained in:
@@ -133,7 +133,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative bg-surface-base rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ring-1 ring-white/[0.06] hover:ring-white/10 ${
|
className={`relative bg-surface-base rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 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}
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { VoiceUser } from './VoiceUser';
|
import { VoiceUser } from './VoiceUser';
|
||||||
import { StreamTile } from './StreamTile';
|
import { StreamTile } from './StreamTile';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { deriveGridTiles } from '../../hooks/useLiveKit';
|
import { deriveGridTiles } from '../../hooks/useLiveKit';
|
||||||
|
import { useGridLayout } from '../../hooks/useGridLayout';
|
||||||
import type { ParticipantInfo, GridTile } from '../../hooks/useLiveKit';
|
import type { ParticipantInfo, GridTile } from '../../hooks/useLiveKit';
|
||||||
|
|
||||||
interface VoiceGridProps {
|
interface VoiceGridProps {
|
||||||
@@ -16,6 +17,9 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
|
|||||||
|
|
||||||
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
|
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { cols, tileWidth, tileHeight } = useGridLayout(containerRef, tiles.length);
|
||||||
|
|
||||||
// Reset strip visibility when focus target changes
|
// Reset strip visibility when focus target changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setStripHidden(false);
|
setStripHidden(false);
|
||||||
@@ -150,23 +154,24 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default grid mode
|
// Default grid mode — container-aware layout via ResizeObserver
|
||||||
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';
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 p-3 overflow-auto flex items-center min-h-0">
|
<div ref={containerRef} className="flex-1 overflow-hidden min-h-0">
|
||||||
<div className={`grid ${gridClass} gap-2 w-full max-h-full`}>
|
<div
|
||||||
|
className="grid h-full w-full"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${cols}, ${tileWidth}px)`,
|
||||||
|
gridAutoRows: `${tileHeight}px`,
|
||||||
|
gap: '8px',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{tiles.map((t) => (
|
{tiles.map((t) => (
|
||||||
<div
|
<div
|
||||||
key={t.key}
|
key={t.key}
|
||||||
onClick={() => setFocusedParticipant(t.key)}
|
onClick={() => setFocusedParticipant(t.key)}
|
||||||
className="cursor-pointer hover:opacity-90 transition-opacity h-full"
|
className="cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
>
|
>
|
||||||
{renderTile(t)}
|
{renderTile(t)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
|||||||
isSpeaking
|
isSpeaking
|
||||||
? 'ring-[3px] ring-status-online shadow-[0_0_12px_rgba(134,239,172,0.25)]'
|
? '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'
|
: '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}
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
{hasVideo ? (
|
{hasVideo ? (
|
||||||
|
|||||||
@@ -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<HTMLElement | null>,
|
||||||
|
tileCount: number,
|
||||||
|
options: GridLayoutOptions = {},
|
||||||
|
): GridLayout {
|
||||||
|
const { gap = 8, aspectRatio = 16 / 9, padding = 12 } = options;
|
||||||
|
|
||||||
|
const [layout, setLayout] = useState<GridLayout>({
|
||||||
|
cols: 1,
|
||||||
|
rows: 1,
|
||||||
|
tileWidth: 320,
|
||||||
|
tileHeight: 180,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prevRef = useRef<GridLayout>(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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user