diff --git a/src/grid/CallLayout.ts b/src/grid/CallLayout.ts index c4412677..8f8106de 100644 --- a/src/grid/CallLayout.ts +++ b/src/grid/CallLayout.ts @@ -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; /** - * The alignment of the floating tile, if any. + * The alignment of the floating spotlight tile, if present. */ - floatingAlignment: BehaviorSubject; + spotlightAlignment: BehaviorSubject; + /** + * The alignment of the small picture-in-picture tile, if present. + */ + pipAlignment: BehaviorSubject; } export interface GridTileModel { @@ -67,3 +81,68 @@ export interface CallLayoutOutputs { export type CallLayout = ( inputs: CallLayoutInputs, ) => CallLayoutOutputs; + +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 }; +} diff --git a/src/grid/GridLayout.module.css b/src/grid/GridLayout.module.css index 5e6aa9e1..33edc3be 100644 --- a/src/grid/GridLayout.module.css +++ b/src/grid/GridLayout.module.css @@ -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; diff --git a/src/grid/GridLayout.tsx b/src/grid/GridLayout.tsx index 3861457e..4d499eed 100644 --- a/src/grid/GridLayout.tsx +++ b/src/grid/GridLayout.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ ); return ( -
+
{tileModel && ( = ({ // 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( (prev) => (prev === undefined ? 0 : prev + 1), @@ -170,8 +119,8 @@ export const makeGridLayout: CallLayout = ({ { 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 } > diff --git a/src/grid/OneOnOneLayout.module.css b/src/grid/OneOnOneLayout.module.css new file mode 100644 index 00000000..0d2ad4ff --- /dev/null +++ b/src/grid/OneOnOneLayout.module.css @@ -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; +} diff --git a/src/grid/OneOnOneLayout.tsx b/src/grid/OneOnOneLayout.tsx new file mode 100644 index 00000000..2eac1b7e --- /dev/null +++ b/src/grid/OneOnOneLayout.tsx @@ -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 = ({ + minBounds, + spotlightAlignment, + pipAlignment, +}) => ({ + fixed: forwardRef(function OneOnOneLayoutFixed({ model, Slot }, ref) { + const { width, height } = useObservableEagerState(minBounds); + const spotlightAlignmentValue = useObservableEagerState(spotlightAlignment); + + const [generation] = useReactiveState( + (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 ( +
+ {spotlightTileModel && ( + + )} +
+ ); + }), + + 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( + (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 ( +
+ + + +
+ ); + }), +}); diff --git a/src/grid/SpotlightLayout.module.css b/src/grid/SpotlightLayout.module.css index af43216c..d58a95a1 100644 --- a/src/grid/SpotlightLayout.module.css +++ b/src/grid/SpotlightLayout.module.css @@ -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; diff --git a/src/grid/SpotlightLayout.tsx b/src/grid/SpotlightLayout.tsx index 3e07a0b2..9ddbce10 100644 --- a/src/grid/SpotlightLayout.tsx +++ b/src/grid/SpotlightLayout.tsx @@ -69,10 +69,8 @@ export const makeSpotlightLayout: CallLayout = ({ 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} >
@@ -102,7 +100,7 @@ export const makeSpotlightLayout: CallLayout = ({ 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} >
; @@ -321,8 +321,11 @@ export const InCallView: FC = ({ ); 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 = ({ makeLayout = makeGridLayout as CallLayout; else if (l.type === "spotlight") makeLayout = makeSpotlightLayout as CallLayout; + else if (l.type === "one-on-one") + makeLayout = makeOneOnOneLayout as CallLayout; else return null; // Not yet implemented return makeLayout({ minBounds: gridBoundsObservable, - floatingAlignment, + spotlightAlignment, + pipAlignment, }); }), ), @@ -491,7 +497,10 @@ export const InCallView: FC = ({ />