feat: Discord-style video grid with fixed 16:9 aspect ratio

Replace CSS grid-template-columns with a JS layout calculator that
tries every column count and picks the arrangement maximising tile
area while preserving exact 16:9 ratio. ResizeObserver triggers
recalculation on container resize. Tests updated to exercise the
pure computeGridLayout function directly.
This commit is contained in:
jevb
2026-03-30 20:13:34 +02:00
parent 5d0af7c03e
commit 4b28f0e28d
3 changed files with 160 additions and 56 deletions
+101 -9
View File
@@ -35,17 +35,92 @@ function setButtonIcon(btn: HTMLButtonElement, icon: SVGSVGElement): void {
btn.appendChild(icon);
}
function computeGridColumns(count: number): string {
if (count <= 1) return "1fr";
if (count <= 4) return "1fr 1fr";
if (count <= 9) return "1fr 1fr 1fr";
return "1fr 1fr 1fr 1fr";
// ---------------------------------------------------------------------------
// Layout calculator — Discord-style tile sizing
// ---------------------------------------------------------------------------
export interface GridLayout {
readonly cols: number;
readonly rows: number;
readonly tileW: number;
readonly tileH: number;
}
const GRID_GAP = 4;
const GRID_PAD = 8;
const ASPECT = 16 / 9;
/**
* Compute optimal tile arrangement that maximises tile area while fitting
* all tiles inside the container. Tries every possible column count and
* picks the one whose tiles are largest.
*/
export function computeGridLayout(
containerW: number,
containerH: number,
tileCount: number,
): GridLayout {
if (tileCount <= 0) return { cols: 1, rows: 1, tileW: 0, tileH: 0 };
let best: GridLayout = { cols: 1, rows: tileCount, tileW: 0, tileH: 0 };
for (let cols = 1; cols <= tileCount; cols++) {
const rows = Math.ceil(tileCount / cols);
const availW = containerW - GRID_PAD * 2 - GRID_GAP * (cols - 1);
const availH = containerH - GRID_PAD * 2 - GRID_GAP * (rows - 1);
if (availW <= 0 || availH <= 0) continue;
let tileW = availW / cols;
let tileH = tileW / ASPECT;
// Shrink if total row height exceeds available height
if (tileH * rows > availH) {
tileH = availH / rows;
tileW = tileH * ASPECT;
}
// Floor width first, then derive height to preserve exact 16:9
const floorW = Math.floor(tileW);
const floorH = Math.floor(floorW / ASPECT);
if (floorW * floorH > best.tileW * best.tileH) {
best = { cols, rows, tileW: floorW, tileH: floorH };
}
}
return best;
}
export function createVideoGrid(): VideoGridComponent {
let root: HTMLDivElement | null = null;
const cells = new Map<number, { el: HTMLDivElement; config?: TileConfig }>();
let focusedTileId: number | null = null;
let resizeObserver: ResizeObserver | null = null;
let resizeRafId = 0;
/** Apply JS-calculated tile sizes to all grid-mode cells. */
function applyGridSizes(): void {
if (root === null || focusedTileId !== null || cells.size === 0) return;
const { width: cw, height: ch } = root.getBoundingClientRect();
if (cw === 0 || ch === 0) return;
const layout = computeGridLayout(cw, ch, cells.size);
for (const entry of cells.values()) {
entry.el.style.width = `${layout.tileW}px`;
entry.el.style.height = `${layout.tileH}px`;
}
}
/** Schedule a layout recalculation on the next animation frame. */
function scheduleResize(): void {
if (resizeRafId !== 0) cancelAnimationFrame(resizeRafId);
resizeRafId = requestAnimationFrame(() => {
resizeRafId = 0;
applyGridSizes();
});
}
function rebuildFocusLayout(): void {
if (root === null) return;
@@ -54,18 +129,23 @@ export function createVideoGrid(): VideoGridComponent {
while (root.firstChild) root.removeChild(root.firstChild);
if (focusedTileId === null || cells.size === 0) {
// No focus — use regular grid layout
// No focus — use regular flex-wrap layout
root.classList.remove("focus-mode");
root.style.gridTemplateColumns = computeGridColumns(cells.size);
for (const entry of cells.values()) {
entry.el.classList.remove("focused", "thumb");
root.appendChild(entry.el);
}
applyGridSizes();
return;
}
root.classList.add("focus-mode");
root.style.gridTemplateColumns = ""; // Clear grid columns, focus uses flex
// Clear inline sizes on cells (focus mode uses CSS flex sizing)
for (const entry of cells.values()) {
entry.el.style.width = "";
entry.el.style.height = "";
}
// Main area
const mainArea = createElement("div", { class: "video-focus-main" });
@@ -108,7 +188,7 @@ export function createVideoGrid(): VideoGridComponent {
rebuildFocusLayout();
return;
}
root.style.gridTemplateColumns = computeGridColumns(cells.size);
applyGridSizes();
}
function addStream(userId: number, username: string, stream: MediaStream, config?: TileConfig): void {
@@ -272,9 +352,21 @@ export function createVideoGrid(): VideoGridComponent {
"data-testid": "video-grid",
});
container.appendChild(root);
// Observe container size changes to recalculate tile layout
resizeObserver = new ResizeObserver(() => { scheduleResize(); });
resizeObserver.observe(root);
}
function destroy(): void {
if (resizeRafId !== 0) cancelAnimationFrame(resizeRafId);
resizeRafId = 0;
if (resizeObserver !== null) {
resizeObserver.disconnect();
resizeObserver = null;
}
for (const [, entry] of cells) {
const video = entry.el.querySelector("video");
if (video !== null) video.srcObject = null;
+5 -2
View File
@@ -2153,20 +2153,22 @@
}
.video-grid {
display: grid;
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px;
height: 100%;
align-content: center;
justify-content: center;
background: var(--bg-primary, #313338);
}
.video-cell {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: var(--radius-md);
background: var(--bg-tertiary);
/* Size set by JS layout calculator — no fixed aspect-ratio */
}
.video-cell video {
@@ -2252,6 +2254,7 @@
/* ── Video focus mode layout ── */
.video-grid.focus-mode {
display: flex;
flex-wrap: nowrap;
flex-direction: column;
height: 100%;
}
@@ -18,6 +18,7 @@ vi.mock("@lib/livekitSession", () => ({
import {
createVideoGrid,
computeGridLayout,
type VideoGridComponent,
type TileConfig,
} from "../../src/components/VideoGrid";
@@ -50,6 +51,13 @@ describe("VideoGrid", () => {
beforeEach(() => {
vi.clearAllMocks();
// ResizeObserver is not available in JSDOM
globalThis.ResizeObserver ??= class {
observe(): void { /* noop */ }
unobserve(): void { /* noop */ }
disconnect(): void { /* noop */ }
} as unknown as typeof ResizeObserver;
container = document.createElement("div");
grid = createVideoGrid();
grid.mount(container);
@@ -119,59 +127,60 @@ describe("VideoGrid", () => {
expect(grid.hasStreams()).toBe(true);
});
describe("grid layout updates correctly for different user counts", () => {
function getGridColumns(): string {
const root = container.querySelector(".video-grid") as HTMLElement;
return root.style.gridTemplateColumns;
}
it("1 user: 1fr", () => {
grid.addStream(1, "Alice", fakeStream());
expect(getGridColumns()).toBe("1fr");
describe("computeGridLayout — Discord-style tile sizing", () => {
it("returns zero-sized tiles for 0 tile count", () => {
const layout = computeGridLayout(800, 600, 0);
expect(layout.tileW).toBe(0);
expect(layout.tileH).toBe(0);
});
it("2 users: 1fr 1fr", () => {
grid.addStream(1, "Alice", fakeStream());
grid.addStream(2, "Bob", fakeStream());
expect(getGridColumns()).toBe("1fr 1fr");
it("1 tile fills the container (width-constrained)", () => {
// Wide container: tile should be width-limited
const layout = computeGridLayout(800, 600, 1);
expect(layout.cols).toBe(1);
expect(layout.rows).toBe(1);
expect(layout.tileW).toBeGreaterThan(0);
expect(layout.tileH).toBeGreaterThan(0);
// Verify 16:9 ratio (within 1px rounding)
expect(Math.abs(layout.tileW / layout.tileH - 16 / 9)).toBeLessThan(0.1);
});
it("4 users: 1fr 1fr", () => {
for (let i = 1; i <= 4; i++) {
grid.addStream(i, `User${i}`, fakeStream());
it("1 tile in a tall container is height-constrained", () => {
// Tall container: tile should be height-limited
const layout = computeGridLayout(400, 800, 1);
expect(layout.cols).toBe(1);
expect(layout.tileH).toBeLessThanOrEqual(800 - 16); // minus padding
});
it("2 tiles use 2 columns in a wide container", () => {
const layout = computeGridLayout(1200, 400, 2);
expect(layout.cols).toBe(2);
expect(layout.rows).toBe(1);
});
it("4 tiles use 2x2 grid", () => {
const layout = computeGridLayout(800, 600, 4);
expect(layout.cols).toBe(2);
expect(layout.rows).toBe(2);
});
it("all tiles fit within the container", () => {
for (const count of [1, 2, 3, 4, 5, 6, 9, 10, 16]) {
const layout = computeGridLayout(800, 600, count);
const totalW = layout.cols * layout.tileW + (layout.cols - 1) * 4 + 16;
const totalH = layout.rows * layout.tileH + (layout.rows - 1) * 4 + 16;
expect(totalW).toBeLessThanOrEqual(800);
expect(totalH).toBeLessThanOrEqual(600);
}
expect(getGridColumns()).toBe("1fr 1fr");
});
it("5 users: 1fr 1fr 1fr", () => {
for (let i = 1; i <= 5; i++) {
grid.addStream(i, `User${i}`, fakeStream());
it("tiles maintain approximately 16:9 aspect ratio", () => {
for (const count of [1, 2, 4, 9]) {
const layout = computeGridLayout(800, 600, count);
if (layout.tileW === 0) continue;
const ratio = layout.tileW / layout.tileH;
expect(Math.abs(ratio - 16 / 9)).toBeLessThan(0.15);
}
expect(getGridColumns()).toBe("1fr 1fr 1fr");
});
it("9 users: 1fr 1fr 1fr", () => {
for (let i = 1; i <= 9; i++) {
grid.addStream(i, `User${i}`, fakeStream());
}
expect(getGridColumns()).toBe("1fr 1fr 1fr");
});
it("10 users: 1fr 1fr 1fr 1fr", () => {
for (let i = 1; i <= 10; i++) {
grid.addStream(i, `User${i}`, fakeStream());
}
expect(getGridColumns()).toBe("1fr 1fr 1fr 1fr");
});
it("layout updates when streams are removed", () => {
for (let i = 1; i <= 5; i++) {
grid.addStream(i, `User${i}`, fakeStream());
}
expect(getGridColumns()).toBe("1fr 1fr 1fr");
grid.removeStream(5);
expect(getGridColumns()).toBe("1fr 1fr");
});
});