diff --git a/src/app/call.ts b/src/app/call.ts index 6b4a04f7..4776946e 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -31,39 +31,222 @@ export const LIVEKIT_PARTICIPANTS = 39004 export {supportsAudioOutputSelection} -export type GridSize = {rows: number; cols: number} +/** Aspect ratio constraints — tiles can flex between 9:16 (portrait) and 16:9 (landscape). */ +const TILE_ASPECT_MIN = 9 / 16 +const TILE_ASPECT_MAX = 16 / 9 +const TILE_GAP = 8 /** - * 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. + * A single row in the tile layout. */ -export const computeGridSize = ( +export type TileRowLayout = { + /** Number of tiles in this row */ + count: number + /** Aspect ratio (width/height) for tiles in this row */ + aspectRatio: number +} + +/** + * Describes a Jitsi-style mixed-size tile layout: tiles arranged in rows, + * each row may have a different tile count and aspect ratio. Tiles fill + * available width via flex, centered in the last row when it has fewer tiles. + */ +export type TileLayout = { + rows: TileRowLayout[] +} + +/** + * Generate all reasonable row-partitions of `n` tiles. + * Each partition is an array of tile counts per row, e.g. for n=3: + * [3], [2,1], [1,2], [1,1,1] + */ +const generatePartitions = (n: number): number[][] => { + const result: number[][] = [] + + // Single row + result.push([n]) + + // Balanced grids: partition by column count + for (let cols = 2; cols <= n; cols++) { + const rows = Math.ceil(n / cols) + const row: number[] = [] + let remaining = n + for (let r = 0; r < rows; r++) { + const tiles = r < rows - 1 ? cols : remaining + row.push(tiles) + remaining -= tiles + } + result.push(row) + } + + // All-single row (N rows of 1 tile each) + if (n >= 2) result.push(Array.from({length: n}).fill(1)) + + // Deduplicate + const seen = new Set() + return result.filter(r => { + const k = r.join(",") + if (seen.has(k)) return false + seen.add(k) + return true + }) +} + +/** + * Given a row partition, per-row aspect ratios, and available width, + * compute the total height of the layout (including gaps). + */ +const layoutHeight = ( + partition: number[], + aspectRatios: number[], + cw: number, + gap: number, +): number => { + let total = 0 + for (let i = 0; i < partition.length; i++) { + const n = partition[i] + const tileW = (cw - (n - 1) * gap) / n + total += tileW / aspectRatios[i] + } + return total + (partition.length - 1) * gap +} + +/** + * Total area of all tiles in the layout. + */ +const tileArea = (partition: number[], aspectRatios: number[], cw: number, gap: number): number => { + let area = 0 + for (let i = 0; i < partition.length; i++) { + const n = partition[i] + const tileW = (cw - (n - 1) * gap) / n + const tileH = tileW / aspectRatios[i] + area += n * tileW * tileH + } + return area +} + +/** + * Score a candidate layout. Rewards large tile area, penalizes overflow + * heavily, and penalizes extreme row-count imbalance. + */ +const layoutScore = (partition: number[], h: number, area: number, ch: number): number => { + const OVERFLOW_PENALTY = 100000 + const WHITESPACE_PENALTY = 20 + const BALANCE_PENALTY = 15000 + + // Penalize imbalance: max-min > 1 gets penalized + let imbalance = 0 + if (partition.length > 1) { + const max = Math.max(...partition) + const min = Math.min(...partition) + imbalance = Math.max(0, max - min - 1) * BALANCE_PENALTY + } + + if (h > ch) { + // Overflow: heavily penalized + return area - (h - ch) * OVERFLOW_PENALTY - imbalance + } + + // Fits: reward area, penalize whitespace and imbalance + return area - (ch - h) * WHITESPACE_PENALTY - imbalance +} + +/** + * Jitsi-style tile layout: tiles flex between 9:16 and 16:9 aspect ratio, + * arranged in rows where each row may have a different number of tiles. + * The algorithm picks the partition and aspect ratio(s) that maximize tile + * area while fitting within the container. The last row with fewer tiles + * gets a wider (more landscape) aspect ratio. + * + * For 3 participants on a portrait phone this produces e.g. stacked + * single tiles (1,1,1). For a squarish container it produces e.g. [2,1] + * with roughly square tiles on top and a wider tile on the bottom. + * For 6 participants on a landscape desktop it produces e.g. [3,3]. + */ +export const computeTileLayout = ( tileCount: number, containerWidth: number, containerHeight: number, -): GridSize => { - if (tileCount <= 0) return {rows: 0, cols: 0} - if (tileCount === 1) return {rows: 1, cols: 1} +): TileLayout | undefined => { + if (tileCount <= 0 || containerWidth <= 0 || containerHeight <= 0) return undefined + if (tileCount === 1) { + return {rows: [{count: 1, aspectRatio: TILE_ASPECT_MAX}]} + } - const TILE_ASPECT = 16 / 9 - let best: GridSize = {rows: 1, cols: tileCount} - let bestArea = 0 + const cw = containerWidth + const ch = containerHeight + const gap = TILE_GAP - // Guard against unmeasured container — fall back to a landscape-ish default - const w = containerWidth > 0 ? containerWidth : 800 - const h = containerHeight > 0 ? containerHeight : 600 + const partitions = generatePartitions(tileCount) - 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 + let best: TileLayout | undefined = undefined + let bestScore = -Infinity - if (area > bestArea) { - bestArea = area - best = {rows, cols} + for (const partition of partitions) { + const nRows = partition.length + + // --- Uniform pass --- + // Solve for aspect ratio that fills the container: + // totalHeight = sum(tileW_i) / aspect + (nRows-1)*gap = ch + // aspect = sum(tileW_i) / (ch - (nRows-1)*gap) + const sumW = partition.reduce((s, n) => s + (cw - (n - 1) * gap) / n, 0) + const availH = ch - (nRows - 1) * gap + const aspectUniform = + availH > 0 + ? Math.max(TILE_ASPECT_MIN, Math.min(TILE_ASPECT_MAX, sumW / availH)) + : TILE_ASPECT_MAX + + const uniformRatios = partition.map(() => aspectUniform) + const hUniform = layoutHeight(partition, uniformRatios, cw, gap) + const areaUniform = tileArea(partition, uniformRatios, cw, gap) + const scoreUniform = layoutScore(partition, hUniform, areaUniform, ch) + + if (scoreUniform > bestScore) { + bestScore = scoreUniform + best = { + rows: partition.map(count => ({count, aspectRatio: aspectUniform})), + } + } + + // --- Mixed pass: only for partitions where the last row has fewer tiles --- + // Makes earlier rows more square-ish, last row wider (more landscape). + if (nRows > 1 && partition[nRows - 1] < partition[0]) { + const lastN = partition[nRows - 1] + const lastW = (cw - (lastN - 1) * gap) / lastN + const earlySlice = partition.slice(0, -1) + + // Try several early-aspect ratios relative to the uniform aspect + // to find a better fit without creating extreme per-row differences. + for (const factor of [0.9, 0.95, 1.0, 1.05, 1.1]) { + const aEarly = Math.max(TILE_ASPECT_MIN, Math.min(TILE_ASPECT_MAX, aspectUniform * factor)) + + const earlyH = layoutHeight( + earlySlice, + earlySlice.map(() => aEarly), + cw, + gap, + ) + + const lastAvailH = ch - earlyH - gap + if (lastAvailH <= 0) continue // doesn't fit + + const aLast = Math.max(TILE_ASPECT_MIN, Math.min(TILE_ASPECT_MAX, lastW / lastAvailH)) + + const mixedRatios = [...earlySlice.map(() => aEarly), aLast] + const hMixed = layoutHeight(partition, mixedRatios, cw, gap) + const areaMixed = tileArea(partition, mixedRatios, cw, gap) + const scoreMixed = layoutScore(partition, hMixed, areaMixed, ch) + + if (scoreMixed > bestScore) { + bestScore = scoreMixed + best = { + rows: partition.map((count, i) => ({ + count, + aspectRatio: i < nRows - 1 ? aEarly : aLast, + })), + } + } + } } } diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index e6966856..e0789f28 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -24,8 +24,8 @@ participantMediaState, pubkeyFromLiveKitIdentity, videoTrackRevision, - computeGridSize, - type GridSize, + computeTileLayout, + type TileLayout, } from "@app/call" import {profiles} from "@app/core" @@ -45,7 +45,7 @@ source: Track.Source.Camera | Track.Source.ScreenShare } - type TileLayout = "spotlight" | "default" | "strip" + type TileLayoutVariant = "spotlight" | "default" | "strip" const {layout, mobile = false, url, h, class: className = ""}: Props = $props() @@ -181,13 +181,8 @@ const useSpotlightLayout = $derived(primaryTile !== undefined) 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)` - : "", + const tileLayout = $derived( + useMultiGrid ? computeTileLayout(videoTiles.length, gridWidth, gridHeight) : undefined, ) $effect(() => { @@ -228,13 +223,13 @@ ) -{#snippet videoTile(tile: VideoTileData, layout: TileLayout)} +{#snippet videoTile(tile: VideoTileData, layout: TileLayoutVariant)} {@const media = $mediaStateByIdentity(tile.liveKitIdentity)}
@@ -293,20 +288,20 @@
{/if} - {:else if useMultiGrid} + {:else if useMultiGrid && tileLayout}
- {#each videoTiles as tile (tileKey(tile))} - {@render videoTile(tile, "default")} - {/each} -
- {:else} -
- {#each videoTiles as tile (tileKey(tile))} - {@render videoTile(tile, "default")} + class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto"> + {#each tileLayout.rows as row, i} + {@const start = tileLayout.rows.slice(0, i).reduce((a, r) => a + r.count, 0)} +
+ {#each videoTiles.slice(start, start + row.count) as tile (tileKey(tile))} +
+ {@render videoTile(tile, "default")} +
+ {/each} +
{/each}
{/if}