feat: adaptive video grid sizing (PR-C1)

Add computeGridSize helper in call.ts that computes optimal row/column
counts for a video tile grid given tile count and container dimensions,
maximizing tile size while maintaining ~16:9 cells.

Apply via computed grid-template-columns/rows on the multi-grid path
(no-spotlight) in VideoCallContent.svelte, replacing the fixed
grid-cols-1 / grid-cols-1 sm:grid-cols-2 heuristic. The multi-grid now
handles all tile counts from 1 to 12+ smoothly.

- computeGridSize(tileCount, containerWidth, containerHeight) -> GridSize
- Container dimensions bound via clientWidth/clientHeight
- useMultiGrid: dropped the > 2 check, now !useSpotlightLayout
- gridStyle: computed grid-template-columns/rows string from gridSize
- Scope: multi-grid only, no spotlight/strip/breakpoint changes
This commit is contained in:
Agent 2026-08-05 14:39:01 -04:00
parent 91a8995a98
commit 664a57d4eb
2 changed files with 58 additions and 4 deletions

View file

@ -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]

View file

@ -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<GridSize>(
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}
</div>
{:else if useMultiGrid}
<div class={cx("grid min-h-0 flex-1 content-start gap-2 overflow-y-auto", multiGridClass)}>
<div
bind:clientWidth={gridWidth}
bind:clientHeight={gridHeight}
style={gridStyle}
class="grid min-h-0 flex-1 content-start gap-2 overflow-y-auto">
{#each videoTiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}