PR-C1: adaptive video grid sizing (foundation) #2
2 changed files with 49 additions and 121 deletions
168
src/app/call.ts
168
src/app/call.ts
|
|
@ -32,11 +32,8 @@ export const LIVEKIT_PARTICIPANTS = 39004
|
|||
export {supportsAudioOutputSelection}
|
||||
|
||||
/**
|
||||
* Aspect ratio constraints — tiles can flex between 3:4 (portrait)
|
||||
* and 16:9 (landscape). Tiles are ALLOWED to be wider than 16:9
|
||||
* when the container is very wide, to prevent overflow (review #2
|
||||
* bug: n=2 second tile half off screen). The lower bound (3:4)
|
||||
* prevents tiles from being too tall.
|
||||
* Aspect ratio constraints for tiles. The lower bound is dynamic
|
||||
* (1:1 on landscape, 3:4 on portrait); the upper bound is 16:9.
|
||||
*/
|
||||
const TILE_ASPECT_PORTRAIT = 3 / 4
|
||||
const TILE_ASPECT_LANDSCAPE = 16 / 9
|
||||
|
|
@ -51,13 +48,14 @@ const TILE_GAP = 8
|
|||
const MIN_TILE_HEIGHT = 120
|
||||
|
||||
/**
|
||||
* A single row in an adaptive tile grid: tiles in this row share
|
||||
* the same dimensions but may differ from other rows.
|
||||
* A single row in an adaptive tile grid.
|
||||
*/
|
||||
export type TileRow = {
|
||||
columnCount: number
|
||||
tileWidth: number
|
||||
tileHeight: number
|
||||
/** Total width of this row including gaps — for the template's max-width */
|
||||
rowWidth: number
|
||||
aspectRatio: number
|
||||
}
|
||||
|
||||
|
|
@ -73,8 +71,6 @@ export type AdaptiveTileGrid = {
|
|||
rows: TileRow[]
|
||||
totalWidth: number
|
||||
totalHeight: number
|
||||
/** True when the grid overflows — tiles are below MIN_TILE_HEIGHT */
|
||||
overflows: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -128,30 +124,11 @@ const fitTile = (availWidth: number, availHeight: number, minAspect: number) =>
|
|||
}
|
||||
|
||||
/**
|
||||
* Build a single-row TileRow for the uniform case where all rows
|
||||
* have the same number of tiles. Tiles are centered within the row
|
||||
* and capped at 16:9 to avoid overflow on ultrawide containers.
|
||||
* Build a candidate grid for a given column count. All tiles share the
|
||||
* same dimensions; the partial last row (if any) is centered with the
|
||||
* same tile size as full rows.
|
||||
*/
|
||||
const buildUniformRow = (
|
||||
columnCount: number,
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
rowCount: number,
|
||||
minAspect: number,
|
||||
): TileRow => {
|
||||
const gap = TILE_GAP
|
||||
const availHeight = (containerHeight - (rowCount - 1) * gap) / rowCount
|
||||
const availWidth = (containerWidth - (columnCount - 1) * gap) / columnCount
|
||||
const {tileWidth, tileHeight, aspectRatio} = fitTile(availWidth, availHeight, minAspect)
|
||||
return {columnCount, tileWidth, tileHeight, aspectRatio}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a mixed-size layout candidate for a given column count.
|
||||
* All tiles share the same dimensions — full rows fill the width,
|
||||
* and the partial last row is centered with the same tile size.
|
||||
*/
|
||||
const buildMixedCandidate = (
|
||||
const buildCandidate = (
|
||||
tileCount: number,
|
||||
columnCount: number,
|
||||
containerWidth: number,
|
||||
|
|
@ -161,40 +138,48 @@ const buildMixedCandidate = (
|
|||
const gap = TILE_GAP
|
||||
const fullRowCount = Math.floor(tileCount / columnCount)
|
||||
const remainder = tileCount % columnCount
|
||||
const totalRowCount = fullRowCount + 1
|
||||
const totalRowCount = fullRowCount + (remainder > 0 ? 1 : 0)
|
||||
|
||||
const availHeight = (containerHeight - (totalRowCount - 1) * gap) / totalRowCount
|
||||
const availWidth = (containerWidth - (columnCount - 1) * gap) / columnCount
|
||||
if (availWidth <= 0 || availHeight <= 0) return undefined
|
||||
|
||||
const {tileWidth, tileHeight, aspectRatio} = fitTile(availWidth, availHeight, minAspect)
|
||||
|
||||
const rows: TileRow[] = []
|
||||
for (let r = 0; r < fullRowCount; r++) {
|
||||
rows.push({columnCount, tileWidth, tileHeight, aspectRatio})
|
||||
rows.push({
|
||||
columnCount,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
rowWidth: columnCount * tileWidth + (columnCount - 1) * gap,
|
||||
aspectRatio,
|
||||
})
|
||||
}
|
||||
if (remainder > 0) {
|
||||
rows.push({
|
||||
columnCount: remainder,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
rowWidth: remainder * tileWidth + (remainder - 1) * gap,
|
||||
aspectRatio,
|
||||
})
|
||||
}
|
||||
rows.push({columnCount: remainder, tileWidth, tileHeight, aspectRatio})
|
||||
|
||||
const totalHeight = totalRowCount * tileHeight + (totalRowCount - 1) * gap
|
||||
const totalWidth = columnCount * tileWidth + (columnCount - 1) * gap
|
||||
|
||||
return {
|
||||
rows,
|
||||
totalWidth,
|
||||
totalHeight,
|
||||
overflows: totalHeight > containerHeight,
|
||||
}
|
||||
return {rows, totalWidth, totalHeight}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute an adaptive tile grid using Jitsi-style mixed row sizes.
|
||||
*
|
||||
* - Tiles flex between 3:4 (portrait) and wider than 16:9 if needed
|
||||
* to avoid overflow. No upper bound on aspect ratio — tiles can
|
||||
* Tiles flex between a minimum aspect (1:1 on landscape, 3:4 on portrait)
|
||||
* and 16:9, capped so they never overflow the container. Partial rows
|
||||
* use the same tile size as full rows, centered.
|
||||
* Compute an adaptive tile grid. All tiles share the same dimensions;
|
||||
* partial rows are centered. Tiles flex between a minimum aspect
|
||||
* (1:1 on landscape, 3:4 on portrait) and 16:9, capped so they never
|
||||
* overflow the container.
|
||||
*
|
||||
* Only allows overflow (scroll) when tiles would be below
|
||||
* MIN_TILE_HEIGHT (review #2: ~6-8 tiles on portrait phone).
|
||||
* MIN_TILE_HEIGHT (~6-8 tiles on portrait phone).
|
||||
*
|
||||
* Prioritises:
|
||||
* 1. No overflow (tiles shrink to fit within aspect bounds)
|
||||
|
|
@ -209,91 +194,34 @@ export const computeAdaptiveGrid = (
|
|||
): AdaptiveTileGrid | undefined => {
|
||||
if (tileCount <= 0 || containerWidth <= 0 || containerHeight <= 0) return undefined
|
||||
|
||||
// When the container is at least 4:3 landscape, bound tiles at 1:1 (square)
|
||||
// so they don't become tall and narrow. On narrower/portrait viewports,
|
||||
// allow 3:4 so more tiles fit on a phone screen.
|
||||
const minAspect = containerWidth / containerHeight >= 4 / 3 ? 1 : TILE_ASPECT_PORTRAIT
|
||||
|
||||
let best: AdaptiveTileGrid | undefined
|
||||
let bestScore: LayoutScore | undefined
|
||||
|
||||
for (let columnCount = 1; columnCount <= tileCount; columnCount++) {
|
||||
const fullRowCount = Math.floor(tileCount / columnCount)
|
||||
const remainder = tileCount % columnCount
|
||||
const totalRowCount = fullRowCount + (remainder > 0 ? 1 : 0)
|
||||
|
||||
let candidate: AdaptiveTileGrid | undefined
|
||||
|
||||
if (remainder === 0) {
|
||||
// Uniform layout — all rows identical
|
||||
const row = buildUniformRow(
|
||||
columnCount,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
totalRowCount,
|
||||
minAspect,
|
||||
)
|
||||
const totalHeight = totalRowCount * row.tileHeight + (totalRowCount - 1) * TILE_GAP
|
||||
const totalWidth = columnCount * row.tileWidth + (columnCount - 1) * TILE_GAP
|
||||
const rows: TileRow[] = []
|
||||
for (let r = 0; r < totalRowCount; r++) {
|
||||
rows.push({...row})
|
||||
}
|
||||
candidate = {
|
||||
rows,
|
||||
totalWidth,
|
||||
totalHeight,
|
||||
overflows: totalHeight > containerHeight,
|
||||
}
|
||||
} else {
|
||||
// Mixed layout — last row has fewer tiles, make them wider
|
||||
candidate = buildMixedCandidate(
|
||||
tileCount,
|
||||
columnCount,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
minAspect,
|
||||
)
|
||||
}
|
||||
|
||||
const candidate = buildCandidate(
|
||||
tileCount,
|
||||
columnCount,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
minAspect,
|
||||
)
|
||||
if (!candidate) continue
|
||||
|
||||
// Compute score
|
||||
const {tileWidth, tileHeight, aspectRatio} = candidate.rows[0]
|
||||
const verticalOverflow = Math.max(0, candidate.totalHeight - containerHeight)
|
||||
const horizontalOverflow = Math.max(0, candidate.totalWidth - containerWidth)
|
||||
const whitespace =
|
||||
Math.max(0, containerWidth - candidate.totalWidth) +
|
||||
Math.max(0, containerHeight - candidate.totalHeight)
|
||||
const aspectDeviation =
|
||||
candidate.rows.reduce(
|
||||
(sum, row) => sum + Math.abs(row.aspectRatio - TILE_ASPECT_LANDSCAPE),
|
||||
0,
|
||||
) / candidate.rows.length
|
||||
const totalArea = candidate.rows.reduce(
|
||||
(sum, row) => sum + row.tileWidth * row.tileHeight * row.columnCount,
|
||||
0,
|
||||
)
|
||||
const totalArea = tileWidth * tileHeight * tileCount
|
||||
|
||||
// For overflow: if tiles are above MIN_TILE_HEIGHT, overflow is
|
||||
// unacceptable — heavily penalize. If tiles are below MIN_TILE_HEIGHT,
|
||||
// overflow is acceptable (tiles are already too small to shrink further).
|
||||
const minTileHeightInCandidate = Math.min(...candidate.rows.map(r => r.tileHeight))
|
||||
const verticalOverflowPenalty =
|
||||
verticalOverflow > 0 && minTileHeightInCandidate >= MIN_TILE_HEIGHT
|
||||
? verticalOverflow * 1_000_000
|
||||
: verticalOverflow
|
||||
|
||||
const horizontalOverflowPenalty =
|
||||
horizontalOverflow > 0 && minTileHeightInCandidate >= MIN_TILE_HEIGHT
|
||||
? horizontalOverflow * 1_000_000
|
||||
: horizontalOverflow
|
||||
|
||||
// Min-tile-height penalty: even when fitting, discourage layouts
|
||||
// where tiles are below the minimum acceptable size.
|
||||
const overflowPenaltyMultiplier = tileHeight >= MIN_TILE_HEIGHT ? 1_000_000 : 1
|
||||
const verticalOverflowPenalty = verticalOverflow * overflowPenaltyMultiplier
|
||||
const horizontalOverflowPenalty = horizontalOverflow * overflowPenaltyMultiplier
|
||||
const minTileHeightPenalty =
|
||||
minTileHeightInCandidate < MIN_TILE_HEIGHT
|
||||
? (MIN_TILE_HEIGHT - minTileHeightInCandidate) * 1000
|
||||
: 0
|
||||
tileHeight < MIN_TILE_HEIGHT ? (MIN_TILE_HEIGHT - tileHeight) * 1000 : 0
|
||||
|
||||
const score: LayoutScore = {
|
||||
verticalOverflowPenalty,
|
||||
|
|
@ -301,7 +229,7 @@ export const computeAdaptiveGrid = (
|
|||
minTileHeightPenalty,
|
||||
negativeArea: -totalArea,
|
||||
whitespace,
|
||||
aspectDeviation,
|
||||
aspectDeviation: Math.abs(aspectRatio - TILE_ASPECT_LANDSCAPE),
|
||||
}
|
||||
|
||||
if (!bestScore || compareScores(score, bestScore) < 0) {
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@
|
|||
.reduce((sum, r) => sum + r.columnCount, 0)}
|
||||
<div
|
||||
class="flex flex-nowrap justify-center gap-2"
|
||||
style={`max-width: ${row.columnCount * row.tileWidth + (row.columnCount - 1) * 8}px`}>
|
||||
style={`max-width: ${row.rowWidth}px`}>
|
||||
{#each videoTiles.slice(offset, offset + row.columnCount) as tile (tileKey(tile))}
|
||||
<div
|
||||
class="overflow-hidden rounded-2xl shrink-0"
|
||||
|
|
|
|||
Loading…
Reference in a new issue