diff --git a/src/app/call.ts b/src/app/call.ts index 6eb362c1..78fede0c 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -31,222 +31,81 @@ export const LIVEKIT_PARTICIPANTS = 39004 export {supportsAudioOutputSelection} -/** Aspect ratio constraints — tiles can flex between 9:16 (portrait) and 16:9 (landscape). */ -const TILE_ASPECT_MIN = 9 / 16 +/** Aspect ratio constraints — tiles can flex between 3:4 (portrait) and 16:9 (landscape). */ +const TILE_ASPECT_MIN = 3 / 4 const TILE_ASPECT_MAX = 16 / 9 const TILE_GAP = 8 /** - * A single row in the tile layout. + * Uniform-size tile grid: all tiles are identical px dimensions, arranged + * in a CSS grid. Column count is chosen to maximize tile area while + * respecting aspect ratio bounds [3:4 .. 16:9]. + * Orphan rows (fewer tiles than a full row) center at the same tile size. */ -export type TileRowLayout = { - /** Number of tiles in this row */ - count: number - /** Aspect ratio (width/height) for tiles in this row */ - aspectRatio: number +export type UniformTileGrid = { + cols: number + rows: number + tileWidth: number + tileHeight: number + aspect: 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. + * Pick the column count that best fills the viewport. Prioritises: + * 1. No vertical overflow (avoids unnecessary scroll) + * 2. Minimal leftover whitespace (h-pad + v-pad) + * 3. Aspect ratio closest to 16:9 + * 4. Larger 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 = ( +export const computeUniformGrid = ( tileCount: number, - containerWidth: number, - containerHeight: number, -): TileLayout | undefined => { - if (tileCount <= 0 || containerWidth <= 0 || containerHeight <= 0) return undefined - if (tileCount === 1) { - return {rows: [{count: 1, aspectRatio: TILE_ASPECT_MAX}]} - } + cw: number, + ch: number, +): UniformTileGrid | undefined => { + if (tileCount <= 0 || cw <= 0 || ch <= 0) return undefined - const cw = containerWidth - const ch = containerHeight - const gap = TILE_GAP + let best: UniformTileGrid | undefined + let bestKey: number[] | undefined - const partitions = generatePartitions(tileCount) + for (let cols = 1; cols <= tileCount; cols++) { + const rows = Math.ceil(tileCount / cols) + const availH = ch - (rows - 1) * TILE_GAP + const widthLimit = (cw - (cols - 1) * TILE_GAP) / cols + if (availH <= 0 || widthLimit <= 0) continue - let best: TileLayout | undefined = undefined - let bestScore = -Infinity + const rowH = availH / rows + const fillAspect = widthLimit / rowH + const aspect = Math.max(TILE_ASPECT_MIN, Math.min(TILE_ASPECT_MAX, fillAspect)) - 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})), - } + let tileW: number, tileH: number + if (fillAspect > TILE_ASPECT_MAX) { + // Too wide: cap at 16:9, tiles taller than row -> vertical overflow + tileW = widthLimit + tileH = widthLimit / TILE_ASPECT_MAX + } else if (fillAspect < TILE_ASPECT_MIN) { + // Too tall: cap at 3:4, tiles narrower than full width -> h-pad + tileH = rowH + tileW = rowH * TILE_ASPECT_MIN + } else { + // In range: fills both dims exactly + tileW = widthLimit + tileH = rowH } - // --- 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) + const bboxW = cols * tileW + (cols - 1) * TILE_GAP + const bboxH = rows * tileH + (rows - 1) * TILE_GAP + const vOverflow = Math.max(0, bboxH - ch) + const waste = Math.max(0, cw - bboxW) + Math.max(0, ch - bboxH) + const key = [ + Math.round(vOverflow), + Math.round(waste), + Number(Math.abs(aspect - TILE_ASPECT_MAX).toFixed(3)), + -Math.round(tileW * tileH), + ] - // 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, - })), - } - } - } + if (!bestKey || key < bestKey) { + bestKey = key + best = {cols, rows, tileWidth: tileW, tileHeight: tileH, aspect} } } diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index 49a5b7c6..7acc614c 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -19,8 +19,8 @@ participantMediaState, pubkeyFromLiveKitIdentity, videoTrackRevision, - computeTileLayout, - type TileLayout, + computeUniformGrid, + type UniformTileGrid, } from "@app/call" import {profiles} from "@app/core" @@ -157,8 +157,8 @@ const useSpotlightLayout = $derived(primaryTile !== undefined) const useMultiGrid = $derived(!useSpotlightLayout) - const tileLayout = $derived( - useMultiGrid ? computeTileLayout(videoTiles.length, gridWidth, gridHeight) : undefined, + const tileGrid = $derived( + useMultiGrid ? computeUniformGrid(videoTiles.length, gridWidth, gridHeight) : undefined, ) $effect(() => { @@ -270,18 +270,19 @@
- {#if tileLayout} - {#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} + class="min-h-0 flex-1 overflow-y-auto"> + {#if tileGrid} +
+ {#each videoTiles as tile (tileKey(tile))} +
+ {@render videoTile(tile, "default")} +
+ {/each} +
{/if}
{/if}