feat: Jitsi-style mixed-size tile grid sizing (PR-C1 rework)
Replaces the uniform grid-sizing helper (computeGridSize -> rows/cols)
with a Jitsi-inspired mixed-size layout algorithm (computeTileLayout).
Key changes:
- Tiles flex between 9:16 and 16:9 aspect ratio per row, matched to
container dimensions
- Rows can have different tile counts (e.g. [2,1] for 3 participants,
[3,2] for 5, [4,3] for 7)
- Last row with fewer tiles gets a wider (more landscape) aspect ratio
while earlier rows stay more square-ish
- CSS grid replaced with flex rows; each tile's aspect-ratio set via
inline style (no more hardcoded aspect-video)
- Container dimensions bound via bind:clientWidth/clientHeight
- Dead {:else} branch (always-true useMultiGrid) removed
- Overflow penalty, whitespace penalty, and balance penalty ensure
reasonable layouts at all tile counts
Per @matt's PR review feedback: read jitsi-meet source
(react/features/video-layout/functions.web.ts,
react/features/filmstrip/functions.web.ts) for the tile sizing
approach.
This commit is contained in:
parent
664a57d4eb
commit
d8dea9c4dd
2 changed files with 224 additions and 46 deletions
229
src/app/call.ts
229
src/app/call.ts
|
|
@ -31,39 +31,222 @@ export const LIVEKIT_PARTICIPANTS = 39004
|
||||||
|
|
||||||
export {supportsAudioOutputSelection}
|
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
|
* A single row in the tile layout.
|
||||||
* number of rows and columns to maximize tile size while maintaining ~16:9
|
|
||||||
* cells. Handles 0 to 12+ tiles smoothly.
|
|
||||||
*/
|
*/
|
||||||
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<number>({length: n}).fill(1))
|
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
const seen = new Set<string>()
|
||||||
|
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,
|
tileCount: number,
|
||||||
containerWidth: number,
|
containerWidth: number,
|
||||||
containerHeight: number,
|
containerHeight: number,
|
||||||
): GridSize => {
|
): TileLayout | undefined => {
|
||||||
if (tileCount <= 0) return {rows: 0, cols: 0}
|
if (tileCount <= 0 || containerWidth <= 0 || containerHeight <= 0) return undefined
|
||||||
if (tileCount === 1) return {rows: 1, cols: 1}
|
if (tileCount === 1) {
|
||||||
|
return {rows: [{count: 1, aspectRatio: TILE_ASPECT_MAX}]}
|
||||||
|
}
|
||||||
|
|
||||||
const TILE_ASPECT = 16 / 9
|
const cw = containerWidth
|
||||||
let best: GridSize = {rows: 1, cols: tileCount}
|
const ch = containerHeight
|
||||||
let bestArea = 0
|
const gap = TILE_GAP
|
||||||
|
|
||||||
// Guard against unmeasured container — fall back to a landscape-ish default
|
const partitions = generatePartitions(tileCount)
|
||||||
const w = containerWidth > 0 ? containerWidth : 800
|
|
||||||
const h = containerHeight > 0 ? containerHeight : 600
|
|
||||||
|
|
||||||
for (let cols = 1; cols <= tileCount; cols++) {
|
let best: TileLayout | undefined = undefined
|
||||||
const rows = Math.ceil(tileCount / cols)
|
let bestScore = -Infinity
|
||||||
// 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) {
|
for (const partition of partitions) {
|
||||||
bestArea = area
|
const nRows = partition.length
|
||||||
best = {rows, cols}
|
|
||||||
|
// --- 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,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@
|
||||||
participantMediaState,
|
participantMediaState,
|
||||||
pubkeyFromLiveKitIdentity,
|
pubkeyFromLiveKitIdentity,
|
||||||
videoTrackRevision,
|
videoTrackRevision,
|
||||||
computeGridSize,
|
computeTileLayout,
|
||||||
type GridSize,
|
type TileLayout,
|
||||||
} from "@app/call"
|
} from "@app/call"
|
||||||
import {profiles} from "@app/core"
|
import {profiles} from "@app/core"
|
||||||
|
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
source: Track.Source.Camera | Track.Source.ScreenShare
|
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()
|
const {layout, mobile = false, url, h, class: className = ""}: Props = $props()
|
||||||
|
|
||||||
|
|
@ -181,13 +181,8 @@
|
||||||
const useSpotlightLayout = $derived(primaryTile !== undefined)
|
const useSpotlightLayout = $derived(primaryTile !== undefined)
|
||||||
const useMultiGrid = $derived(!useSpotlightLayout)
|
const useMultiGrid = $derived(!useSpotlightLayout)
|
||||||
|
|
||||||
const gridSize = $derived<GridSize>(
|
const tileLayout = $derived<TileLayout | undefined>(
|
||||||
useMultiGrid ? computeGridSize(videoTiles.length, gridWidth, gridHeight) : {rows: 0, cols: 0},
|
useMultiGrid ? computeTileLayout(videoTiles.length, gridWidth, gridHeight) : undefined,
|
||||||
)
|
|
||||||
const gridStyle = $derived(
|
|
||||||
gridSize.rows > 0 && gridSize.cols > 0
|
|
||||||
? `grid-template-columns: repeat(${gridSize.cols}, 1fr); grid-template-rows: repeat(${gridSize.rows}, 1fr)`
|
|
||||||
: "",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -228,13 +223,13 @@
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
|
{#snippet videoTile(tile: VideoTileData, layout: TileLayoutVariant)}
|
||||||
{@const media = $mediaStateByIdentity(tile.liveKitIdentity)}
|
{@const media = $mediaStateByIdentity(tile.liveKitIdentity)}
|
||||||
<div
|
<div
|
||||||
class={cx(
|
class={cx(
|
||||||
"relative isolate overflow-hidden rounded-2xl shadow-sm",
|
"relative isolate overflow-hidden rounded-2xl shadow-sm",
|
||||||
layout === "spotlight" && "min-h-0 flex-1",
|
layout === "spotlight" && "min-h-0 flex-1",
|
||||||
layout === "default" && "aspect-video w-full min-h-0",
|
layout === "default" && "min-h-0 w-full",
|
||||||
layout === "strip" && "aspect-video w-44 shrink-0",
|
layout === "strip" && "aspect-video w-44 shrink-0",
|
||||||
tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-surface",
|
tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-surface",
|
||||||
)}>
|
)}>
|
||||||
|
|
@ -293,20 +288,20 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if useMultiGrid}
|
{:else if useMultiGrid && tileLayout}
|
||||||
<div
|
<div
|
||||||
bind:clientWidth={gridWidth}
|
bind:clientWidth={gridWidth}
|
||||||
bind:clientHeight={gridHeight}
|
bind:clientHeight={gridHeight}
|
||||||
style={gridStyle}
|
class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||||
class="grid min-h-0 flex-1 content-start gap-2 overflow-y-auto">
|
{#each tileLayout.rows as row, i}
|
||||||
{#each videoTiles as tile (tileKey(tile))}
|
{@const start = tileLayout.rows.slice(0, i).reduce((a, r) => a + r.count, 0)}
|
||||||
{@render videoTile(tile, "default")}
|
<div class="flex min-h-0 flex-row gap-2 justify-center">
|
||||||
{/each}
|
{#each videoTiles.slice(start, start + row.count) as tile (tileKey(tile))}
|
||||||
</div>
|
<div class="min-w-0 flex-1" style="aspect-ratio: {row.aspectRatio}">
|
||||||
{:else}
|
{@render videoTile(tile, "default")}
|
||||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
</div>
|
||||||
{#each videoTiles as tile (tileKey(tile))}
|
{/each}
|
||||||
{@render videoTile(tile, "default")}
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue