Implement the new one-on-one layout

This commit is contained in:
Robin 2024-06-07 16:59:56 -04:00
parent e0b10d89b5
commit 7979493371
8 changed files with 312 additions and 93 deletions

View File

@ -19,22 +19,36 @@ import { ComponentType } from "react";
import { MediaViewModel } from "../state/MediaViewModel";
import { LayoutProps } from "./Grid";
import { Alignment } from "../room/InCallView";
export interface Bounds {
width: number;
height: number;
}
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" };
export interface CallLayoutInputs {
/**
* The minimum bounds of the layout area.
*/
minBounds: Observable<Bounds>;
/**
* The alignment of the floating tile, if any.
* The alignment of the floating spotlight tile, if present.
*/
floatingAlignment: BehaviorSubject<Alignment>;
spotlightAlignment: BehaviorSubject<Alignment>;
/**
* The alignment of the small picture-in-picture tile, if present.
*/
pipAlignment: BehaviorSubject<Alignment>;
}
export interface GridTileModel {
@ -67,3 +81,68 @@ export interface CallLayoutOutputs<Model> {
export type CallLayout<Model> = (
inputs: CallLayoutInputs,
) => CallLayoutOutputs<Model>;
export interface GridArrangement {
tileWidth: number;
tileHeight: number;
gap: number;
columns: number;
}
const tileMinHeight = 130;
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 tileMinWidth = width < 500 ? 150 : 180;
let columns = Math.min(
// Don't create more columns than we have items for
tileCount,
// 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.ceil(Math.sqrt((width / minHeight / tileMaxAspectRatio) * tileCount)),
);
let rows = Math.ceil(tileCount / columns);
let tileWidth = (width - (columns - 1) * gap) / columns;
let tileHeight = (minHeight - (rows - 1) * gap) / rows;
// Impose a minimum width and height on the tiles
if (tileWidth < tileMinWidth) {
// In this case we want the tile width to determine the number of columns,
// not the other way around. If we take the above equation for the tile
// width (w = (W - (c - 1) * g) / c) and solve for c, we get
// c = (W + g) / (w + g).
columns = Math.floor((width + gap) / (tileMinWidth + gap));
rows = Math.ceil(tileCount / columns);
tileWidth = (width - (columns - 1) * gap) / columns;
tileHeight = (minHeight - (rows - 1) * gap) / rows;
}
if (tileHeight < tileMinHeight) tileHeight = tileMinHeight;
// 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;
// TODO: We might now be hitting the minimum height or width limit again
return { tileWidth, tileHeight, gap, columns };
}

View File

@ -17,11 +17,10 @@ limitations under the License.
.fixed,
.scrolling {
margin-inline: var(--inline-content-inset);
block-size: 100%;
}
.scrolling {
box-sizing: border-box;
block-size: 100%;
display: flex;
flex-wrap: wrap;
justify-content: center;

View File

@ -22,7 +22,12 @@ import { GridLayout as GridLayoutModel } from "../state/CallViewModel";
import styles from "./GridLayout.module.css";
import { useReactiveState } from "../useReactiveState";
import { useInitial } from "../useInitial";
import { CallLayout, GridTileModel, TileModel } from "./CallLayout";
import {
CallLayout,
GridTileModel,
TileModel,
arrangeTiles,
} from "./CallLayout";
import { DragCallback } from "./Grid";
interface GridCSSProperties extends CSSProperties {
@ -31,13 +36,9 @@ interface GridCSSProperties extends CSSProperties {
"--height": string;
}
const slotMinHeight = 130;
const slotMaxAspectRatio = 17 / 9;
const slotMinAspectRatio = 4 / 3;
export const makeGridLayout: CallLayout<GridLayoutModel> = ({
minBounds,
floatingAlignment,
spotlightAlignment,
}) => ({
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
// lives
@ -45,7 +46,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
const { width, height } = useObservableEagerState(minBounds);
const alignment = useObservableEagerState(
useInitial(() =>
floatingAlignment.pipe(
spotlightAlignment.pipe(
distinctUntilChanged(
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
),
@ -68,7 +69,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) =>
floatingAlignment.next({
spotlightAlignment.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
@ -76,12 +77,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
);
return (
<div
ref={ref}
className={styles.fixed}
data-generation={generation}
style={{ height }}
>
<div ref={ref} className={styles.fixed} data-generation={generation}>
{tileModel && (
<Slot
className={styles.slot}
@ -99,57 +95,10 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
// The scrolling part of the layout is where all the grid tiles live
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
const { width, height: minHeight } = useObservableEagerState(minBounds);
// 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, slotWidth, slotHeight] = useMemo(() => {
const gap = width < 800 ? 16 : 20;
const slotMinWidth = width < 500 ? 150 : 180;
let columns = Math.min(
// Don't create more columns than we have items for
model.grid.length,
// 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 slots rather than squares, so we
// divide the width-to-height ratio by the target aspect ratio.
Math.ceil(
Math.sqrt(
(width / minHeight / slotMaxAspectRatio) * model.grid.length,
),
),
);
let rows = Math.ceil(model.grid.length / columns);
let slotWidth = (width - (columns - 1) * gap) / columns;
let slotHeight = (minHeight - (rows - 1) * gap) / rows;
// Impose a minimum width and height on the slots
if (slotWidth < slotMinWidth) {
// In this case we want the slot width to determine the number of columns,
// not the other way around. If we take the above equation for the slot
// width (w = (W - (c - 1) * g) / c) and solve for c, we get
// c = (W + g) / (w + g).
columns = Math.floor((width + gap) / (slotMinWidth + gap));
rows = Math.ceil(model.grid.length / columns);
slotWidth = (width - (columns - 1) * gap) / columns;
slotHeight = (minHeight - (rows - 1) * gap) / rows;
}
if (slotHeight < slotMinHeight) slotHeight = slotMinHeight;
// Impose a minimum and maximum aspect ratio on the slots
const slotAspectRatio = slotWidth / slotHeight;
if (slotAspectRatio > slotMaxAspectRatio)
slotWidth = slotHeight * slotMaxAspectRatio;
else if (slotAspectRatio < slotMinAspectRatio)
slotHeight = slotWidth / slotMinAspectRatio;
// TODO: We might now be hitting the minimum height or width limit again
return [gap, slotWidth, slotHeight];
}, [width, minHeight, model.grid.length]);
const { gap, tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, minHeight, model.grid.length),
[width, minHeight, model.grid.length],
);
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
@ -170,8 +119,8 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
{
width,
"--gap": `${gap}px`,
"--width": `${Math.floor(slotWidth)}px`,
"--height": `${Math.floor(slotHeight)}px`,
"--width": `${Math.floor(tileWidth)}px`,
"--height": `${Math.floor(tileHeight)}px`,
} as GridCSSProperties
}
>

View File

@ -0,0 +1,56 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.layer {
margin-inline: var(--inline-content-inset);
block-size: 100%;
display: grid;
place-items: center;
}
.container {
position: relative;
}
.local {
position: absolute;
inline-size: 180px;
block-size: 135px;
inset: var(--cpd-space-4x);
}
.spotlight {
position: absolute;
inline-size: 404px;
block-size: 233px;
inset: -12px;
}
.slot[data-block-alignment="start"] {
inset-block-end: unset;
}
.slot[data-block-alignment="end"] {
inset-block-start: unset;
}
.slot[data-inline-alignment="start"] {
inset-inline-end: unset;
}
.slot[data-inline-alignment="end"] {
inset-inline-start: unset;
}

132
src/grid/OneOnOneLayout.tsx Normal file
View File

@ -0,0 +1,132 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { forwardRef, useCallback, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
import { OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel";
import {
CallLayout,
GridTileModel,
SpotlightTileModel,
arrangeTiles,
} from "./CallLayout";
import { useReactiveState } from "../useReactiveState";
import styles from "./OneOnOneLayout.module.css";
import { DragCallback } from "./Grid";
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
minBounds,
spotlightAlignment,
pipAlignment,
}) => ({
fixed: forwardRef(function OneOnOneLayoutFixed({ model, Slot }, ref) {
const { width, height } = useObservableEagerState(minBounds);
const spotlightAlignmentValue = useObservableEagerState(spotlightAlignment);
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[width, height, model.spotlight === undefined, spotlightAlignmentValue],
);
const spotlightTileModel: SpotlightTileModel | undefined = useMemo(
() =>
model.spotlight && {
type: "spotlight",
vms: model.spotlight,
maximised: false,
},
[model.spotlight],
);
const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) =>
spotlightAlignment.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[],
);
return (
<div ref={ref} data-generation={generation} className={styles.layer}>
{spotlightTileModel && (
<Slot
className={classNames(styles.slot, styles.spotlight)}
id="spotlight"
model={spotlightTileModel}
onDrag={onDragSpotlight}
data-block-alignment={spotlightAlignmentValue.block}
data-inline-alignment={spotlightAlignmentValue.inline}
/>
)}
</div>
);
}),
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {
const { width, height } = useObservableEagerState(minBounds);
const pipAlignmentValue = useObservableEagerState(pipAlignment);
const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1),
[width, height],
);
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[width, height, pipAlignmentValue],
);
const remoteTileModel: GridTileModel = useMemo(
() => ({ type: "grid", vm: model.remote }),
[model.remote],
);
const localTileModel: GridTileModel = useMemo(
() => ({ type: "grid", vm: model.local }),
[model.local],
);
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
pipAlignment.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[],
);
return (
<div ref={ref} data-generation={generation} className={styles.layer}>
<Slot
id={remoteTileModel.vm.id}
model={remoteTileModel}
className={styles.container}
style={{ width: tileWidth, height: tileHeight }}
>
<Slot
className={classNames(styles.slot, styles.local)}
id={localTileModel.vm.id}
model={localTileModel}
onDrag={onDragLocalTile}
data-block-alignment={pipAlignmentValue.block}
data-inline-alignment={pipAlignmentValue.inline}
/>
</Slot>
</div>
);
}),
});

View File

@ -16,6 +16,7 @@ limitations under the License.
.layer {
margin-inline: var(--inline-content-inset);
block-size: 100%;
display: grid;
--grid-gap: 20px;
gap: 30px;
@ -30,10 +31,6 @@ limitations under the License.
grid-template-rows: minmax(1fr, auto);
}
.scrolling {
block-size: 100%;
}
.spotlight {
container: spotlight / size;
display: grid;

View File

@ -69,10 +69,8 @@ export const makeSpotlightLayout: CallLayout<SpotlightLayoutModel> = ({
ref={ref}
data-generation={generation}
data-orientation={layout.orientation}
className={classNames(styles.layer, styles.fixed)}
style={
{ "--grid-columns": layout.gridColumns, height } as GridCSSProperties
}
className={styles.layer}
style={{ "--grid-columns": layout.gridColumns } as GridCSSProperties}
>
<div className={styles.spotlight}>
<Slot className={styles.slot} id="spotlight" model={tileModel} />
@ -102,7 +100,7 @@ export const makeSpotlightLayout: CallLayout<SpotlightLayoutModel> = ({
ref={ref}
data-generation={generation}
data-orientation={layout.orientation}
className={classNames(styles.layer, styles.scrolling)}
className={styles.layer}
style={{ "--grid-columns": layout.gridColumns } as GridCSSProperties}
>
<div

View File

@ -87,17 +87,17 @@ import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType";
import { makeGridLayout } from "../grid/GridLayout";
import { makeSpotlightLayout } from "../grid/SpotlightLayout";
import { CallLayout, GridTileModel, TileModel } from "../grid/CallLayout";
import {
CallLayout,
GridTileModel,
TileModel,
defaultPipAlignment,
defaultSpotlightAlignment,
} from "../grid/CallLayout";
import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
export interface Alignment {
inline: "start" | "end";
block: "start" | "end";
}
const defaultAlignment: Alignment = { inline: "end", block: "end" };
const dummySpotlightItem = {
id: "spotlight",
} as TileDescriptor<MediaViewModel>;
@ -321,8 +321,11 @@ export const InCallView: FC<InCallViewProps> = ({
);
const gridBoundsObservable = useObservable(gridBounds);
const floatingAlignment = useInitial(
() => new BehaviorSubject(defaultAlignment),
const spotlightAlignment = useInitial(
() => new BehaviorSubject(defaultSpotlightAlignment),
);
const pipAlignment = useInitial(
() => new BehaviorSubject(defaultPipAlignment),
);
const layoutSystem = useObservableEagerState(
@ -334,11 +337,14 @@ export const InCallView: FC<InCallViewProps> = ({
makeLayout = makeGridLayout as CallLayout<Layout>;
else if (l.type === "spotlight")
makeLayout = makeSpotlightLayout as CallLayout<Layout>;
else if (l.type === "one-on-one")
makeLayout = makeOneOnOneLayout as CallLayout<Layout>;
else return null; // Not yet implemented
return makeLayout({
minBounds: gridBoundsObservable,
floatingAlignment,
spotlightAlignment,
pipAlignment,
});
}),
),
@ -491,7 +497,10 @@ export const InCallView: FC<InCallViewProps> = ({
/>
<Grid
className={styles.fixedGrid}
style={{ insetBlockStart: headerBounds.bottom }}
style={{
insetBlockStart: headerBounds.bottom,
height: gridBounds.height,
}}
model={layout}
Layout={layoutSystem.fixed}
Tile={Tile}