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}
|
export {supportsAudioOutputSelection}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aspect ratio constraints — tiles can flex between 3:4 (portrait)
|
* Aspect ratio constraints for tiles. The lower bound is dynamic
|
||||||
* and 16:9 (landscape). Tiles are ALLOWED to be wider than 16:9
|
* (1:1 on landscape, 3:4 on portrait); the upper bound is 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.
|
|
||||||
*/
|
*/
|
||||||
const TILE_ASPECT_PORTRAIT = 3 / 4
|
const TILE_ASPECT_PORTRAIT = 3 / 4
|
||||||
const TILE_ASPECT_LANDSCAPE = 16 / 9
|
const TILE_ASPECT_LANDSCAPE = 16 / 9
|
||||||
|
|
@ -51,13 +48,14 @@ const TILE_GAP = 8
|
||||||
const MIN_TILE_HEIGHT = 120
|
const MIN_TILE_HEIGHT = 120
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single row in an adaptive tile grid: tiles in this row share
|
* A single row in an adaptive tile grid.
|
||||||
* the same dimensions but may differ from other rows.
|
|
||||||
*/
|
*/
|
||||||
export type TileRow = {
|
export type TileRow = {
|
||||||
columnCount: number
|
columnCount: number
|
||||||
tileWidth: number
|
tileWidth: number
|
||||||
tileHeight: number
|
tileHeight: number
|
||||||
|
/** Total width of this row including gaps — for the template's max-width */
|
||||||
|
rowWidth: number
|
||||||
aspectRatio: number
|
aspectRatio: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,8 +71,6 @@ export type AdaptiveTileGrid = {
|
||||||
rows: TileRow[]
|
rows: TileRow[]
|
||||||
totalWidth: number
|
totalWidth: number
|
||||||
totalHeight: 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
|
* Build a candidate grid for a given column count. All tiles share the
|
||||||
* have the same number of tiles. Tiles are centered within the row
|
* same dimensions; the partial last row (if any) is centered with the
|
||||||
* and capped at 16:9 to avoid overflow on ultrawide containers.
|
* same tile size as full rows.
|
||||||
*/
|
*/
|
||||||
const buildUniformRow = (
|
const buildCandidate = (
|
||||||
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 = (
|
|
||||||
tileCount: number,
|
tileCount: number,
|
||||||
columnCount: number,
|
columnCount: number,
|
||||||
containerWidth: number,
|
containerWidth: number,
|
||||||
|
|
@ -161,40 +138,48 @@ const buildMixedCandidate = (
|
||||||
const gap = TILE_GAP
|
const gap = TILE_GAP
|
||||||
const fullRowCount = Math.floor(tileCount / columnCount)
|
const fullRowCount = Math.floor(tileCount / columnCount)
|
||||||
const remainder = 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 availHeight = (containerHeight - (totalRowCount - 1) * gap) / totalRowCount
|
||||||
const availWidth = (containerWidth - (columnCount - 1) * gap) / columnCount
|
const availWidth = (containerWidth - (columnCount - 1) * gap) / columnCount
|
||||||
|
if (availWidth <= 0 || availHeight <= 0) return undefined
|
||||||
|
|
||||||
const {tileWidth, tileHeight, aspectRatio} = fitTile(availWidth, availHeight, minAspect)
|
const {tileWidth, tileHeight, aspectRatio} = fitTile(availWidth, availHeight, minAspect)
|
||||||
|
|
||||||
const rows: TileRow[] = []
|
const rows: TileRow[] = []
|
||||||
for (let r = 0; r < fullRowCount; r++) {
|
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 totalHeight = totalRowCount * tileHeight + (totalRowCount - 1) * gap
|
||||||
const totalWidth = columnCount * tileWidth + (columnCount - 1) * gap
|
const totalWidth = columnCount * tileWidth + (columnCount - 1) * gap
|
||||||
|
|
||||||
return {
|
return {rows, totalWidth, totalHeight}
|
||||||
rows,
|
|
||||||
totalWidth,
|
|
||||||
totalHeight,
|
|
||||||
overflows: totalHeight > containerHeight,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute an adaptive tile grid using Jitsi-style mixed row sizes.
|
* Compute an adaptive tile grid. All tiles share the same dimensions;
|
||||||
*
|
* partial rows are centered. Tiles flex between a minimum aspect
|
||||||
* - Tiles flex between 3:4 (portrait) and wider than 16:9 if needed
|
* (1:1 on landscape, 3:4 on portrait) and 16:9, capped so they never
|
||||||
* to avoid overflow. No upper bound on aspect ratio — tiles can
|
* overflow the container.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Only allows overflow (scroll) when tiles would be below
|
* 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:
|
* Prioritises:
|
||||||
* 1. No overflow (tiles shrink to fit within aspect bounds)
|
* 1. No overflow (tiles shrink to fit within aspect bounds)
|
||||||
|
|
@ -209,91 +194,34 @@ export const computeAdaptiveGrid = (
|
||||||
): AdaptiveTileGrid | undefined => {
|
): AdaptiveTileGrid | undefined => {
|
||||||
if (tileCount <= 0 || containerWidth <= 0 || containerHeight <= 0) return 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
|
const minAspect = containerWidth / containerHeight >= 4 / 3 ? 1 : TILE_ASPECT_PORTRAIT
|
||||||
|
|
||||||
let best: AdaptiveTileGrid | undefined
|
let best: AdaptiveTileGrid | undefined
|
||||||
let bestScore: LayoutScore | undefined
|
let bestScore: LayoutScore | undefined
|
||||||
|
|
||||||
for (let columnCount = 1; columnCount <= tileCount; columnCount++) {
|
for (let columnCount = 1; columnCount <= tileCount; columnCount++) {
|
||||||
const fullRowCount = Math.floor(tileCount / columnCount)
|
const candidate = buildCandidate(
|
||||||
const remainder = tileCount % columnCount
|
tileCount,
|
||||||
const totalRowCount = fullRowCount + (remainder > 0 ? 1 : 0)
|
columnCount,
|
||||||
|
containerWidth,
|
||||||
let candidate: AdaptiveTileGrid | undefined
|
containerHeight,
|
||||||
|
minAspect,
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!candidate) continue
|
if (!candidate) continue
|
||||||
|
|
||||||
// Compute score
|
const {tileWidth, tileHeight, aspectRatio} = candidate.rows[0]
|
||||||
const verticalOverflow = Math.max(0, candidate.totalHeight - containerHeight)
|
const verticalOverflow = Math.max(0, candidate.totalHeight - containerHeight)
|
||||||
const horizontalOverflow = Math.max(0, candidate.totalWidth - containerWidth)
|
const horizontalOverflow = Math.max(0, candidate.totalWidth - containerWidth)
|
||||||
const whitespace =
|
const whitespace =
|
||||||
Math.max(0, containerWidth - candidate.totalWidth) +
|
Math.max(0, containerWidth - candidate.totalWidth) +
|
||||||
Math.max(0, containerHeight - candidate.totalHeight)
|
Math.max(0, containerHeight - candidate.totalHeight)
|
||||||
const aspectDeviation =
|
const totalArea = tileWidth * tileHeight * tileCount
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
// For overflow: if tiles are above MIN_TILE_HEIGHT, overflow is
|
const overflowPenaltyMultiplier = tileHeight >= MIN_TILE_HEIGHT ? 1_000_000 : 1
|
||||||
// unacceptable — heavily penalize. If tiles are below MIN_TILE_HEIGHT,
|
const verticalOverflowPenalty = verticalOverflow * overflowPenaltyMultiplier
|
||||||
// overflow is acceptable (tiles are already too small to shrink further).
|
const horizontalOverflowPenalty = horizontalOverflow * overflowPenaltyMultiplier
|
||||||
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 minTileHeightPenalty =
|
const minTileHeightPenalty =
|
||||||
minTileHeightInCandidate < MIN_TILE_HEIGHT
|
tileHeight < MIN_TILE_HEIGHT ? (MIN_TILE_HEIGHT - tileHeight) * 1000 : 0
|
||||||
? (MIN_TILE_HEIGHT - minTileHeightInCandidate) * 1000
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const score: LayoutScore = {
|
const score: LayoutScore = {
|
||||||
verticalOverflowPenalty,
|
verticalOverflowPenalty,
|
||||||
|
|
@ -301,7 +229,7 @@ export const computeAdaptiveGrid = (
|
||||||
minTileHeightPenalty,
|
minTileHeightPenalty,
|
||||||
negativeArea: -totalArea,
|
negativeArea: -totalArea,
|
||||||
whitespace,
|
whitespace,
|
||||||
aspectDeviation,
|
aspectDeviation: Math.abs(aspectRatio - TILE_ASPECT_LANDSCAPE),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bestScore || compareScores(score, bestScore) < 0) {
|
if (!bestScore || compareScores(score, bestScore) < 0) {
|
||||||
|
|
|
||||||
|
|
@ -279,7 +279,7 @@
|
||||||
.reduce((sum, r) => sum + r.columnCount, 0)}
|
.reduce((sum, r) => sum + r.columnCount, 0)}
|
||||||
<div
|
<div
|
||||||
class="flex flex-nowrap justify-center gap-2"
|
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))}
|
{#each videoTiles.slice(offset, offset + row.columnCount) as tile (tileKey(tile))}
|
||||||
<div
|
<div
|
||||||
class="overflow-hidden rounded-2xl shrink-0"
|
class="overflow-hidden rounded-2xl shrink-0"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue