diff --git a/src/app/call.ts b/src/app/call.ts index caaff464..c6552dc3 100644 --- a/src/app/call.ts +++ b/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) { diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index 42f57a40..724aee01 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -279,7 +279,7 @@ .reduce((sum, r) => sum + r.columnCount, 0)}