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 (
|
||||
<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 ${
|
||||
large ? 'h-full w-full' : 'h-full aspect-video'
|
||||
'h-full w-full'
|
||||
}`}
|
||||
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 { 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<HTMLDivElement>(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 (
|
||||
<div className="flex-1 p-3 overflow-auto flex items-center min-h-0">
|
||||
<div className={`grid ${gridClass} gap-2 w-full max-h-full`}>
|
||||
<div ref={containerRef} className="flex-1 overflow-hidden min-h-0">
|
||||
<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) => (
|
||||
<div
|
||||
key={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)}
|
||||
</div>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user