From 4b28f0e28de520df12a41cf7861af08f7c582d0d Mon Sep 17 00:00:00 2001 From: jevb Date: Mon, 30 Mar 2026 20:13:34 +0200 Subject: [PATCH] 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. --- .../tauri-client/src/components/VideoGrid.ts | 110 ++++++++++++++++-- Client/tauri-client/src/styles/app.css | 7 +- .../tests/unit/video-grid.test.ts | 99 +++++++++------- 3 files changed, 160 insertions(+), 56 deletions(-) diff --git a/Client/tauri-client/src/components/VideoGrid.ts b/Client/tauri-client/src/components/VideoGrid.ts index 58571c34..49b6c9ee 100644 --- a/Client/tauri-client/src/components/VideoGrid.ts +++ b/Client/tauri-client/src/components/VideoGrid.ts @@ -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(); 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; diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css index a101355e..cc5f1341 100644 --- a/Client/tauri-client/src/styles/app.css +++ b/Client/tauri-client/src/styles/app.css @@ -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%; } diff --git a/Client/tauri-client/tests/unit/video-grid.test.ts b/Client/tauri-client/tests/unit/video-grid.test.ts index 1abf3212..63097c19 100644 --- a/Client/tauri-client/tests/unit/video-grid.test.ts +++ b/Client/tauri-client/tests/unit/video-grid.test.ts @@ -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"); }); });