Files
element-call/src/grid/CallLayout.ts

129 lines
3.8 KiB
TypeScript
Raw Normal View History

2024-05-17 16:38:00 -04:00
/*
Copyright 2024 New Vector Ltd.
2024-05-17 16:38:00 -04:00
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
2024-05-17 16:38:00 -04:00
*/
import { type BehaviorSubject, type Observable } from "rxjs";
import { type ComponentType } from "react";
2024-05-17 16:38:00 -04:00
import { type LayoutProps } from "./Grid";
import { type TileViewModel } from "../state/TileViewModel";
2024-05-17 16:38:00 -04:00
export interface Bounds {
width: number;
height: number;
}
2024-06-07 16:59:56 -04:00
export interface Alignment {
inline: "start" | "end";
block: "start" | "end";
}
export const defaultSpotlightAlignment: Alignment = {
inline: "end",
block: "end",
};
export const defaultPipAlignment: Alignment = { inline: "end", block: "start" };
2024-05-17 16:38:00 -04:00
export interface CallLayoutInputs {
/**
* The minimum bounds of the layout area.
*/
minBounds: Observable<Bounds>;
/**
2024-06-07 16:59:56 -04:00
* The alignment of the floating spotlight tile, if present.
*/
spotlightAlignment: BehaviorSubject<Alignment>;
/**
* The alignment of the small picture-in-picture tile, if present.
2024-05-17 16:38:00 -04:00
*/
2024-06-07 16:59:56 -04:00
pipAlignment: BehaviorSubject<Alignment>;
2024-05-17 16:38:00 -04:00
}
export interface CallLayoutOutputs<Model> {
/**
* Whether the scrolling layer of the layout should appear on top.
*/
scrollingOnTop: boolean;
2024-05-17 16:38:00 -04:00
/**
* The visually fixed (non-scrolling) layer of the layout.
*/
Keep tiles in a stable order (#2670) * Keep tiles in a stable order This introduces a new layer of abstraction on top of MediaViewModel: TileViewModel, which gives us a place to store data relating to tiles rather than their media, and also generally makes it easier to reason about tiles as they move about the call layout. I have created a class called TileStore to keep track of these tiles. This allows us to swap out the media shown on a tile as the spotlight speaker changes, and avoid moving tiles around unless they really need to jump between the visible/invisible regions of the layout. * Don't throttle spotlight updates Since we now assume that the spotlight and grid will be in sync (i.e. an active speaker in one will behave as an active speaker in the other), we don't want the spotlight to ever lag behind due to throttling. If this causes usability issues we should maybe look into making LiveKit's 'speaking' indicators less erratic first. * Make layout shifts due to a change in speaker less surprising Although we try now to avoid layout shifts due to the spotlight speaker changing wherever possible, a spotlight speaker coming from off screen can still trigger one. Let's shift the layout a bit more gracefully in this case. * Improve the tile ordering tests * Maximize the spotlight tile in portrait layout * Tell tiles whether they're actually visible in a more timely manner * Fix test * Fix speaking indicators logic * Improve readability of marbles * Fix test case --------- Co-authored-by: Hugh Nimmo-Smith <hughns@element.io>
2024-11-06 04:36:48 -05:00
fixed: ComponentType<LayoutProps<Model, TileViewModel, HTMLDivElement>>;
2024-05-17 16:38:00 -04:00
/**
* The layer of the layout that can overflow and be scrolled.
*/
Keep tiles in a stable order (#2670) * Keep tiles in a stable order This introduces a new layer of abstraction on top of MediaViewModel: TileViewModel, which gives us a place to store data relating to tiles rather than their media, and also generally makes it easier to reason about tiles as they move about the call layout. I have created a class called TileStore to keep track of these tiles. This allows us to swap out the media shown on a tile as the spotlight speaker changes, and avoid moving tiles around unless they really need to jump between the visible/invisible regions of the layout. * Don't throttle spotlight updates Since we now assume that the spotlight and grid will be in sync (i.e. an active speaker in one will behave as an active speaker in the other), we don't want the spotlight to ever lag behind due to throttling. If this causes usability issues we should maybe look into making LiveKit's 'speaking' indicators less erratic first. * Make layout shifts due to a change in speaker less surprising Although we try now to avoid layout shifts due to the spotlight speaker changing wherever possible, a spotlight speaker coming from off screen can still trigger one. Let's shift the layout a bit more gracefully in this case. * Improve the tile ordering tests * Maximize the spotlight tile in portrait layout * Tell tiles whether they're actually visible in a more timely manner * Fix test * Fix speaking indicators logic * Improve readability of marbles * Fix test case --------- Co-authored-by: Hugh Nimmo-Smith <hughns@element.io>
2024-11-06 04:36:48 -05:00
scrolling: ComponentType<LayoutProps<Model, TileViewModel, HTMLDivElement>>;
2024-05-17 16:38:00 -04:00
}
/**
* A layout system for media tiles.
*/
export type CallLayout<Model> = (
inputs: CallLayoutInputs,
) => CallLayoutOutputs<Model>;
2024-06-07 16:59:56 -04:00
export interface GridArrangement {
tileWidth: number;
tileHeight: number;
gap: number;
columns: number;
}
const tileMaxAspectRatio = 17 / 9;
const tileMinAspectRatio = 4 / 3;
/**
* Determine the ideal arrangement of tiles into a grid of a particular size.
*/
export function arrangeTiles(
width: number,
minHeight: number,
tileCount: number,
): GridArrangement {
// The goal here is to determine the grid size and padding that maximizes
// use of screen space for n tiles without making those tiles too small or
// too cropped (having an extreme aspect ratio)
const gap = width < 800 ? 16 : 20;
const area = width * minHeight;
// Magic numbers that make tiles scale up nicely as the window gets larger
const tileArea = Math.pow(Math.sqrt(area) / 8 + 125, 2);
const tilesPerPage = Math.min(tileCount, area / tileArea);
2024-06-07 16:59:56 -04:00
let columns = Math.min(
2024-06-07 16:59:56 -04:00
// Don't create more columns than we have items for
tilesPerPage,
2024-06-07 16:59:56 -04:00
// The ideal number of columns is given by a packing of equally-sized
// squares into a grid.
// width / column = height / row.
// columns * rows = number of squares.
// ∴ columns = sqrt(width / height * number of squares).
// Except we actually want 16:9-ish tiles rather than squares, so we
// divide the width-to-height ratio by the target aspect ratio.
Math.round(
Math.sqrt((width / minHeight / tileMinAspectRatio) * tilesPerPage),
),
2024-06-07 16:59:56 -04:00
);
let rows = tilesPerPage / columns;
// If all the tiles could fit on one page, we want to ensure that they do by
// not leaving fractional rows hanging off the bottom
if (tilesPerPage === tileCount) {
rows = Math.ceil(rows);
// We may now be able to fit the tiles into fewer columns
columns = Math.ceil(tileCount / rows);
}
2024-06-07 16:59:56 -04:00
let tileWidth = (width - (columns + 1) * gap) / columns;
2024-06-07 16:59:56 -04:00
let tileHeight = (minHeight - (rows - 1) * gap) / rows;
// Impose a minimum and maximum aspect ratio on the tiles
const tileAspectRatio = tileWidth / tileHeight;
if (tileAspectRatio > tileMaxAspectRatio)
tileWidth = tileHeight * tileMaxAspectRatio;
else if (tileAspectRatio < tileMinAspectRatio)
tileHeight = tileWidth / tileMinAspectRatio;
2024-06-07 16:59:56 -04:00
return { tileWidth, tileHeight, gap, columns };
}