feat: uniform-size tile grid with flex-fill aspect (PR-C1 plan v2)
Replaces the Jitsi-style mixed-row layout (~190 lines: partitions, mixed-size rows, per-row aspect, penalty scoring) with a simple uniform grid (~25 lines): all tiles identical px size, aspect flexes within [3:4 .. 16:9], column count chosen to maximize area via key ordering (overflow < waste < aspect closeness < -area). Key changes: - computeUniformGrid / UniformTileGrid replaces computeTileLayout/TileLayout - TILE_ASPECT_MIN widened from 9/16 to 3/4 per @matt - CSS grid with explicit px column/row tracks replaces flex rows - mx-auto centers the grid block; orphan rows have same-size tiles (never full-width giants) - Outer measuring container always mounted (bind:clientWidth/Height); inner grid gated on tileGrid - Each tile wrapper: overflow-hidden rounded-2xl (grid cell sizes it) - 'default' tile variant keeps min-h-0 h-full w-full (fills grid cell) Per @matt's approved plan v2: flex-fill single rule everywhere, no portrait special-casing, no mixed-size rows, uniform tiles, centered block.
This commit is contained in:
parent
1b1e0169ff
commit
4b1e209262
2 changed files with 73 additions and 215 deletions
257
src/app/call.ts
257
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<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 = (
|
||||
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}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@
|
|||
participantMediaState,
|
||||
pubkeyFromLiveKitIdentity,
|
||||
videoTrackRevision,
|
||||
computeTileLayout,
|
||||
type TileLayout,
|
||||
computeUniformGrid,
|
||||
type UniformTileGrid,
|
||||
} from "@app/call"
|
||||
import {profiles} from "@app/core"
|
||||
|
||||
|
|
@ -181,8 +181,8 @@
|
|||
const useSpotlightLayout = $derived(primaryTile !== undefined)
|
||||
const useMultiGrid = $derived(!useSpotlightLayout)
|
||||
|
||||
const tileLayout = $derived<TileLayout | undefined>(
|
||||
useMultiGrid ? computeTileLayout(videoTiles.length, gridWidth, gridHeight) : undefined,
|
||||
const tileGrid = $derived<UniformTileGrid | undefined>(
|
||||
useMultiGrid ? computeUniformGrid(videoTiles.length, gridWidth, gridHeight) : undefined,
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
|
|
@ -292,18 +292,17 @@
|
|||
<div
|
||||
bind:clientWidth={gridWidth}
|
||||
bind:clientHeight={gridHeight}
|
||||
class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||
{#if tileLayout}
|
||||
{#each tileLayout.rows as row, i}
|
||||
{@const start = tileLayout.rows.slice(0, i).reduce((a, r) => a + r.count, 0)}
|
||||
<div class="flex min-h-0 flex-row gap-2 justify-center">
|
||||
{#each videoTiles.slice(start, start + row.count) as tile (tileKey(tile))}
|
||||
<div class="min-w-0 flex-1" style="aspect-ratio: {row.aspectRatio}">
|
||||
{@render videoTile(tile, "default")}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
class="min-h-0 flex-1 overflow-y-auto">
|
||||
{#if tileGrid}
|
||||
<div
|
||||
class="mx-auto grid content-start gap-2"
|
||||
style={`grid-template-columns: repeat(${tileGrid.cols}, ${tileGrid.tileWidth}px); grid-template-rows: repeat(${tileGrid.rows}, ${tileGrid.tileHeight}px)`}>
|
||||
{#each videoTiles as tile (tileKey(tile))}
|
||||
<div class="overflow-hidden rounded-2xl">
|
||||
{@render videoTile(tile, "default")}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Reference in a new issue