mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-02 19:43:10 +03:00
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:
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user