diff --git a/src/app/call.ts b/src/app/call.ts index e81c442e..6b4a04f7 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -31,6 +31,45 @@ export const LIVEKIT_PARTICIPANTS = 39004 export {supportsAudioOutputSelection} +export type GridSize = {rows: number; cols: number} + +/** + * Given a tile count and available container dimensions, returns the optimal + * number of rows and columns to maximize tile size while maintaining ~16:9 + * cells. Handles 0 to 12+ tiles smoothly. + */ +export const computeGridSize = ( + tileCount: number, + containerWidth: number, + containerHeight: number, +): GridSize => { + if (tileCount <= 0) return {rows: 0, cols: 0} + if (tileCount === 1) return {rows: 1, cols: 1} + + const TILE_ASPECT = 16 / 9 + let best: GridSize = {rows: 1, cols: tileCount} + let bestArea = 0 + + // Guard against unmeasured container — fall back to a landscape-ish default + const w = containerWidth > 0 ? containerWidth : 800 + const h = containerHeight > 0 ? containerHeight : 600 + + for (let cols = 1; cols <= tileCount; cols++) { + const rows = Math.ceil(tileCount / cols) + // Tile width constrained by both axes while maintaining 16:9 + const tileWidth = Math.min(w / cols, (h / rows) * TILE_ASPECT) + const tileHeight = tileWidth / TILE_ASPECT + const area = tileWidth * tileHeight + + if (area > bestArea) { + bestArea = area + best = {rows, cols} + } + } + + return best +} + const LIVEKIT_DEFAULT_DEVICE_ID = "default" const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000] diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index c5db244b..e6966856 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -24,6 +24,8 @@ participantMediaState, pubkeyFromLiveKitIdentity, videoTrackRevision, + computeGridSize, + type GridSize, } from "@app/call" import {profiles} from "@app/core" @@ -173,10 +175,19 @@ return videoTiles.filter(t => tileKey(t) !== pk) }) + let gridWidth = $state(0) + let gridHeight = $state(0) + const useSpotlightLayout = $derived(primaryTile !== undefined) - const useMultiGrid = $derived(!useSpotlightLayout && videoTiles.length > 2) - const multiGridClass = $derived( - layout === VideoCallLayout.Split ? "grid-cols-1" : "grid-cols-1 sm:grid-cols-2", + const useMultiGrid = $derived(!useSpotlightLayout) + + const gridSize = $derived( + useMultiGrid ? computeGridSize(videoTiles.length, gridWidth, gridHeight) : {rows: 0, cols: 0}, + ) + const gridStyle = $derived( + gridSize.rows > 0 && gridSize.cols > 0 + ? `grid-template-columns: repeat(${gridSize.cols}, 1fr); grid-template-rows: repeat(${gridSize.rows}, 1fr)` + : "", ) $effect(() => { @@ -283,7 +294,11 @@ {/if} {:else if useMultiGrid} -
+
{#each videoTiles as tile (tileKey(tile))} {@render videoTile(tile, "default")} {/each}