diff --git a/package.json b/package.json index 848892a9..7dd2d1dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@whereby.com/browser-sdk", - "version": "2.0.0", + "version": "2.1.0-beta1", "description": "Modules for integration Whereby video in web apps", "author": "Whereby AS", "license": "MIT", diff --git a/src/lib/react/Grid/helpers/__tests__/centerGridLayout.unit.ts b/src/lib/react/Grid/helpers/__tests__/centerGridLayout.unit.ts new file mode 100644 index 00000000..0eb60607 --- /dev/null +++ b/src/lib/react/Grid/helpers/__tests__/centerGridLayout.unit.ts @@ -0,0 +1,48 @@ +import * as grid from "../centerGridLayout"; + +const sanityLayout = { + cellWidth: 600, + cellHeight: 450, + rows: 1, + cols: 1, + gridGap: 0, + extraHorizontalPadding: 0, + extraVerticalPadding: 25, + cellCount: 1, + paddings: { top: 0, left: 0, bottom: 0, right: 0 }, +}; + +const sanityLayout2 = { + cellWidth: 400, + cellHeight: 300, + rows: 2, + cols: 1, + gridGap: 0, + extraHorizontalPadding: 50, + extraVerticalPadding: 0, + cellCount: 2, + paddings: { top: 0, left: 0, bottom: 0, right: 0 }, +}; + +const NORMAL_AR = 4 / 3; + +describe("calculateLayout", () => { + it.each` + cellCount | width | height | gridGap | cellAspectRatios | expectedResult + ${1} | ${600} | ${500} | ${0} | ${[NORMAL_AR]} | ${sanityLayout} + ${2} | ${500} | ${600} | ${0} | ${[NORMAL_AR]} | ${sanityLayout2} + `( + "returns $expectedResult layout for $cellCount cell(s) in a (w: $width, h: $height) container", + ({ cellCount, width, height, gridGap, cellAspectRatios, expectedResult }) => { + expect( + grid.calculateLayout({ + cellCount, + width, + height, + gridGap, + cellAspectRatios, + }) + ).toEqual(expectedResult); + } + ); +}); diff --git a/src/lib/react/Grid/helpers/__tests__/gridUtils.unit.ts b/src/lib/react/Grid/helpers/__tests__/gridUtils.unit.ts new file mode 100644 index 00000000..9368e6ad --- /dev/null +++ b/src/lib/react/Grid/helpers/__tests__/gridUtils.unit.ts @@ -0,0 +1,36 @@ +import { getGridSizeForCount, fitToBounds } from "../gridUtils"; + +describe("getGridSizeForCount", () => { + it.each` + count | width | height | expectedResult + ${1} | ${500} | ${500} | ${{ rows: 1, cols: 1 }} + ${2} | ${500} | ${500} | ${{ rows: 2, cols: 1 }} + ${2} | ${1000} | ${500} | ${{ rows: 1, cols: 2 }} + ${3} | ${500} | ${500} | ${{ rows: 2, cols: 2 }} + ${3} | ${1000} | ${500} | ${{ rows: 1, cols: 3 }} + ${6} | ${500} | ${1600} | ${{ rows: 6, cols: 1 }} + ${6} | ${1600} | ${500} | ${{ rows: 2, cols: 3 }} + ${12} | ${500} | ${1000} | ${{ rows: 6, cols: 2 }} + ${12} | ${1600} | ${500} | ${{ rows: 2, cols: 6 }} + ${12} | ${500} | ${500} | ${{ rows: 4, cols: 3 }} + `( + "returns $expectedResult grid size for container bounds $width $height", + ({ count, width, height, expectedResult }) => { + expect(getGridSizeForCount({ count, width, height, aspectRatio: 4 / 3 })).toEqual(expectedResult); + } + ); +}); + +describe("fitToBounds", () => { + it.each` + aspectRatio | width | height | expectedResult + ${0.5} | ${100} | ${100} | ${{ width: 50, height: 100 }} + ${1} | ${100} | ${100} | ${{ width: 100, height: 100 }} + ${2} | ${100} | ${100} | ${{ width: 100, height: 50 }} + `( + "returns $expectedResult grid size for container bounds $width $height", + ({ aspectRatio, width, height, expectedResult }) => { + expect(fitToBounds(aspectRatio, { width, height })).toEqual(expectedResult); + } + ); +}); diff --git a/src/lib/react/Grid/helpers/__tests__/stageLayout.unit.ts b/src/lib/react/Grid/helpers/__tests__/stageLayout.unit.ts new file mode 100644 index 00000000..6d4ccf3b --- /dev/null +++ b/src/lib/react/Grid/helpers/__tests__/stageLayout.unit.ts @@ -0,0 +1,40 @@ +import { calculateStageLayout } from "../stageLayout"; + +const GRID_GAP_PX = 10; + +const wideScreenNoSubgrid = { + hasOverflow: false, + isPortrait: false, + videosContainer: { + bounds: { + width: 600, + height: 400, + }, + origin: { + top: 0, + left: 0, + }, + }, +}; + +describe("calculateStageLayout", () => { + it.each` + width | height | isConstrained | isPortrait | expectedResult + ${600} | ${400} | ${true} | ${false} | ${wideScreenNoSubgrid} + `( + "returns expected stage layout in a (w: $width, h: $height) container isPortrait:$isPortrait", + ({ width, height, isPortrait, expectedResult }) => { + expect( + calculateStageLayout({ + containerBounds: { width, height }, + containerOrigin: { top: 0, left: 0 }, + isPortrait, + gridGap: GRID_GAP_PX, + hasConstrainedOverflow: false, + hasPresentationContent: true, + hasVideoContent: true, + }) + ).toEqual(expectedResult); + } + ); +}); diff --git a/src/lib/react/Grid/helpers/cellView.ts b/src/lib/react/Grid/helpers/cellView.ts new file mode 100644 index 00000000..aa5dcdbf --- /dev/null +++ b/src/lib/react/Grid/helpers/cellView.ts @@ -0,0 +1,29 @@ +export function makeVideoCellView({ + aspectRatio, + avatarSize, + cellPaddings, + client = undefined, + isDraggable = true, + isPlaceholder = false, + isSubgrid = false, +}: { + aspectRatio?: number; + avatarSize?: number; + cellPaddings?: number; + client?: { id: string }; + isDraggable?: boolean; + isPlaceholder?: boolean; + isSubgrid?: boolean; +}) { + return { + aspectRatio: aspectRatio || 16 / 9, + avatarSize, + cellPaddings, + client, + clientId: client?.id || "", + isDraggable, + isPlaceholder, + isSubgrid, + type: "video", + }; +} diff --git a/src/lib/react/Grid/helpers/centerGridLayout.ts b/src/lib/react/Grid/helpers/centerGridLayout.ts new file mode 100644 index 00000000..703c0597 --- /dev/null +++ b/src/lib/react/Grid/helpers/centerGridLayout.ts @@ -0,0 +1,221 @@ +import { getGridSizeForCount } from "./gridUtils"; + +import { Box, makeBox } from "./layout"; + +const WIDE_AR = 16 / 9; +const NORMAL_AR = 4 / 3; + +const clamp = ({ value, min, max }: { value: number; min: number; max: number }) => Math.min(Math.max(value, min), max); + +function hasDuplicates(...array: T[]) { + return new Set(array).size !== array.length; +} + +function findMostCommon(arr: T[]) { + return arr.sort((a, b) => arr.filter((v) => v === a).length - arr.filter((v) => v === b).length).pop(); +} + +// Grid cells are all the same aspect ratio (not to be confused with the video cells) +// Pick the best ratio given a list of the video cell ratios: +export function pickCellAspectRatio({ choices = [] }: { choices: number[] }) { + // If all cells are the same aspect ratio use that: + const minAr = Math.min(...choices); + const maxAr = Math.max(...choices); + let chosenAr = null; + if (minAr === maxAr) { + chosenAr = minAr; + } else { + // Otherwise we're in a mixed grid. + // We ideally want to make the majority ratio look nice. Pick the most common + // ratio but limit it to wide cells. If we don't have a majority choice + // just go with the widest: + const dominantAr = hasDuplicates(choices) ? findMostCommon(choices) : maxAr; + chosenAr = clamp({ value: dominantAr || maxAr, min: NORMAL_AR, max: WIDE_AR }); + } + return { + minAr, + maxAr, + chosenAr, + }; +} + +// Calculate how much we need to move the last row horizontally so it +// becomes centered: +function getCenterPadding({ + rows, + cols, + cellWidth, + index, + cellCount, + gridGap, +}: { + rows: number; + cols: number; + cellWidth: number; + index: number; + cellCount: number; + gridGap: number; +}) { + const max = rows * cols; + + const leftOver = max - cellCount; + + if (!leftOver) { + return 0; + } + + const lastIndex = max - leftOver - 1; + const firstIndex = lastIndex - (cols - leftOver) + 1; + + const lastRowPadding = (leftOver * cellWidth) / 2 + gridGap; + + return index >= firstIndex && index <= lastIndex ? lastRowPadding : 0; +} + +function getCellBounds({ + width, + height, + rows, + cols, + gridGap, + aspectRatio, +}: { + width: number; + height: number; + rows: number; + cols: number; + gridGap: number; + aspectRatio: number; +}) { + // Naively calculate the cell size based on grid and container size: + const cellWidth = (width - (cols - 1) * gridGap) / cols; + const cellHeight = (height - (rows - 1) * gridGap) / rows; + const ar = cellWidth / cellHeight; + + // Knowing the target cell aspect ratio, pull any extra space + // into the grid padding: + let horizontalCorrection = 0; + let verticalCorrection = 0; + + if (aspectRatio < ar) { + horizontalCorrection = cellWidth - cellHeight * aspectRatio; + } else if (aspectRatio > ar) { + verticalCorrection = cellHeight - cellWidth / aspectRatio; + } + + const totalHorizontalCorrection = horizontalCorrection * cols; + const totalVerticalCorrection = verticalCorrection * rows; + + return { + cellWidth: cellWidth - horizontalCorrection, + cellHeight: cellHeight - verticalCorrection, + extraHorizontalPadding: totalHorizontalCorrection / 2, + extraVerticalPadding: totalVerticalCorrection / 2, + }; +} + +export function calculateLayout({ + width, + height, + cellCount, + gridGap, + cellAspectRatios = [NORMAL_AR], + paddings = makeBox(), +}: { + width: number; + height: number; + cellCount: number; + gridGap: number; + cellAspectRatios?: number[]; + paddings?: Box; +}) { + // Handle empty grid: + if (!cellCount) { + return { + cellCount, + cellHeight: 0, + cellWidth: 0, + cols: 0, + rows: 0, + extraHorizontalPadding: 0, + extraVerticalPadding: 0, + gridGap, + paddings, + }; + } + + const contentWidth = width - (paddings.left + paddings.right); + const contentHeight = height - (paddings.top + paddings.bottom); + + const cellAspectRatioTuple = pickCellAspectRatio({ + choices: cellAspectRatios, + }); + let cellAspectRatio = cellAspectRatioTuple.chosenAr; + + const { rows, cols } = getGridSizeForCount({ + count: cellCount, + width: contentWidth, + height: contentHeight, + aspectRatio: cellAspectRatio, + }); + + // Special case 1 col / row: + // Divvy up available all space (within reason) + if (rows === 1) { + cellAspectRatio = clamp({ + value: contentWidth / cols / contentHeight, + min: Math.min(cellAspectRatioTuple.chosenAr, cellAspectRatioTuple.maxAr), + max: Math.max(cellAspectRatioTuple.chosenAr, cellAspectRatioTuple.maxAr), + }); + } else if (cols === 1) { + cellAspectRatio = clamp({ + value: contentWidth / (contentHeight / rows), + min: Math.min(cellAspectRatioTuple.chosenAr, cellAspectRatioTuple.maxAr), + max: Math.max(cellAspectRatioTuple.chosenAr, cellAspectRatioTuple.maxAr), + }); + } + + const { cellWidth, cellHeight, extraHorizontalPadding, extraVerticalPadding } = getCellBounds({ + width: contentWidth, + height: contentHeight, + rows, + cols, + gridGap, + aspectRatio: cellAspectRatio, + }); + + return { + cellCount, + cellHeight, + cellWidth, + cols, + rows, + extraHorizontalPadding, + extraVerticalPadding, + // pass through + gridGap, + paddings, + }; +} + +export function getCellPropsAtIndexForLayout({ + index, + layout, +}: { + index: number; + layout: ReturnType; +}) { + const { cellWidth, cellHeight, rows, cols, cellCount, gridGap } = layout; + + const top = Math.floor(index / cols); + const left = Math.floor(index % cols); + + const leftPadding = getCenterPadding({ rows, cols, cellWidth, index, cellCount, gridGap }); + + return { + top: top * cellHeight + top * gridGap, + left: left * cellWidth + left * gridGap + leftPadding, + width: cellWidth, + height: cellHeight, + }; +} diff --git a/src/lib/react/Grid/helpers/gridUtils.ts b/src/lib/react/Grid/helpers/gridUtils.ts new file mode 100644 index 00000000..f3f88343 --- /dev/null +++ b/src/lib/react/Grid/helpers/gridUtils.ts @@ -0,0 +1,135 @@ +import { Bounds, Frame, makeFrame } from "./layout"; + +export function fitToBounds(aspectRatio: number, containerSize: Bounds) { + const { width, height } = containerSize; + const contentHeight = height; + const contentWidth = contentHeight * aspectRatio; + const scale = Math.min(width / contentWidth, height / contentHeight); + const adjustedWidth = contentWidth * scale; + const adjustedHeight = contentHeight * scale; + return { width: adjustedWidth, height: adjustedHeight }; +} + +export function fitToFrame({ + contentAspectRatio, + containerFrame, +}: { + contentAspectRatio: number; + containerFrame: Frame; +}) { + const contentBounds = fitToBounds(contentAspectRatio, { + width: containerFrame.bounds.width, + height: containerFrame.bounds.height, + }); + + const topOffset = (containerFrame.bounds.height - contentBounds.height) / 2; + const leftOffset = (containerFrame.bounds.width - contentBounds.width) / 2; + + return makeFrame({ + ...contentBounds, + top: containerFrame.origin.top + topOffset, + left: containerFrame.origin.left + leftOffset, + }); +} + +const cellContentArea = ({ + width, + height, + rows, + cols, + aspectRatio, +}: { + width: number; + height: number; + rows: number; + cols: number; + aspectRatio: number; +}) => { + const bounds = fitToBounds(aspectRatio, { width: width / cols, height: height / rows }); + return Math.round(bounds.width * bounds.height); +}; + +const getWeightedSplitCount = ({ + vertical, + width, + height, + count, + aspectRatio, +}: { + vertical: boolean; + width: number; + height: number; + count: number; + aspectRatio: number; +}) => { + // Calculate cell content areas for 1, 2 and 3 (columns|rows) layouts + // and pick the largest one: + const choices = [1, 2, 3].map((rowCols) => + cellContentArea({ + width, + height, + rows: vertical ? Math.ceil(count / rowCols) : rowCols, + cols: vertical ? rowCols : Math.ceil(count / rowCols), + aspectRatio, + }) + ); + const closest = Math.max(...choices); + const splits = choices.indexOf(closest) + 1; + + return { splits, weight: closest }; +}; + +const getGridSplits = ({ + width, + height, + count, + aspectRatio, +}: { + width: number; + height: number; + count: number; + aspectRatio: number; +}) => { + // Try both vertical and horizontal layout and pick the one that yields the + // biggest video cells: + const verticalPick = getWeightedSplitCount({ vertical: true, width, height, count, aspectRatio }); + const horizontalPick = getWeightedSplitCount({ vertical: false, width, height, count, aspectRatio }); + + if (verticalPick.weight > horizontalPick.weight) { + return { splits: verticalPick.splits, vertical: true }; + } + return { splits: horizontalPick.splits, vertical: false }; +}; + +export function getGridSizeForCount({ + count, + width, + height, + aspectRatio, +}: { + count: number; + width: number; + height: number; + aspectRatio: number; +}) { + if (count <= 1) { + return { + rows: 1, + cols: 1, + }; + } + + const { splits, vertical } = getGridSplits({ width, height, count, aspectRatio }); + + if (vertical) { + return { + rows: Math.ceil(count / splits), + cols: splits, + }; + } + + return { + rows: splits, + cols: Math.ceil(count / splits), + }; +} diff --git a/src/lib/react/Grid/helpers/layout.ts b/src/lib/react/Grid/helpers/layout.ts new file mode 100644 index 00000000..d8c61295 --- /dev/null +++ b/src/lib/react/Grid/helpers/layout.ts @@ -0,0 +1,97 @@ +import layoutConstants from "./layoutConstants"; + +const { DESKTOP_BREAKPOINT, TABLET_BREAKPOINT, PHONE_BREAKPOINT } = layoutConstants; + +// Types +export type Box = { + top: number; + left: number; + bottom: number; + right: number; +}; + +export type Origin = { + top: number; + left: number; +}; + +export type Bounds = { + width: number; + height: number; +}; + +export type Frame = { + origin: Origin; + bounds: Bounds; +}; + +export function makeOrigin({ top = 0, left = 0 } = {}): Origin { + return { + top, + left, + }; +} + +export function makeBounds({ width = 0, height = 0 } = {}): Bounds { + return { + width: Math.max(width, 0), + height: Math.max(height, 0), + }; +} + +export function makeFrame({ top = 0, left = 0, width = 0, height = 0 } = {}): Frame { + return { + bounds: makeBounds({ width, height }), + origin: makeOrigin({ top, left }), + }; +} + +export function makeBox({ top = 0, left = 0, bottom = 0, right = 0 } = {}): Box { + return { + top, + left, + bottom, + right, + }; +} + +export function hasBounds(bounds: { width: number; height: number }): boolean { + if (!bounds) { + return false; + } + return !(bounds.width <= 0 || bounds.height <= 0); +} + +export function insetBounds({ bounds, fromBounds }: { bounds: Bounds; fromBounds: Bounds }): Bounds { + return { + width: Math.max(fromBounds.width - bounds.width, 0), + height: Math.max(fromBounds.height - bounds.height, 0), + }; +} +// Responsive + +export function isBoundsPhone(bounds: Bounds, isLandscape: boolean) { + const isVerticalPhone = !isLandscape && bounds.width <= PHONE_BREAKPOINT; + const isHorizontalPhone = isLandscape && bounds.height <= PHONE_BREAKPOINT && bounds.width <= TABLET_BREAKPOINT; + return isVerticalPhone || isHorizontalPhone; +} + +export function isBoundsTablet(bounds: Bounds, isLandscape: boolean) { + const isVerticalTablet = !isLandscape && bounds.width <= TABLET_BREAKPOINT; + const isHorizontalTablet = isLandscape && bounds.height <= TABLET_BREAKPOINT && bounds.width <= DESKTOP_BREAKPOINT; + return isVerticalTablet || isHorizontalTablet; +} + +export function calculateResponsiveLayout(bounds: Bounds) { + const isLandscape = bounds.width > bounds.height; + const isPhone = isBoundsPhone(bounds, isLandscape); + const isTablet = !isPhone && isBoundsTablet(bounds, isLandscape); + const isDesktop = !isPhone && !isTablet; + return { + isPhone, + isTablet, + isDesktop, + isLandscape, + isPortrait: !isLandscape, + }; +} diff --git a/src/lib/react/Grid/helpers/layoutConstants.ts b/src/lib/react/Grid/helpers/layoutConstants.ts new file mode 100644 index 00000000..2fa566cb --- /dev/null +++ b/src/lib/react/Grid/helpers/layoutConstants.ts @@ -0,0 +1,38 @@ +// Source of truth layout related constants + +const VIDEO_CONTROLS_MIN_WIDTH = 7 * 60; +export default { + // Minimum window size before we start floating the toolbars + MIN_WINDOW_HEIGHT: 320, + MIN_WINDOW_WIDTH: 320, + // Breakpoints + DESKTOP_BREAKPOINT: 1025, + TABLET_BREAKPOINT: 750, + PHONE_BREAKPOINT: 500, + // Room layout + TOP_TOOLBAR_HEIGHT: 40 + 8 * 2, + BOTTOM_TOOLBAR_HEIGHT: 70 + 4 * 3, + SIDEBAR_WIDTH: 375, + VIDEO_CONTROLS_MIN_WIDTH, + ROOM_FOOTER_MIN_WIDTH: 60 * 3 + VIDEO_CONTROLS_MIN_WIDTH, + FLOATING_VIDEO_CONTROLS_BOTTOM_MARGIN: 20, + WATERMARK_BAR_HEIGHT: 32, + // Breakout stage (no active group) + BREAKOUT_STAGE_BACKDROP_HEADER_HEIGHT: 20 + 8, + BREAKOUT_STAGE_BACKDROP_FOOTER_HEIGHT: 8 + 40 + 8, + // Subgrid + SUBGRID_EMPTY_STAGE_MAX_WIDTH: 800, + // Groups grid + GROUPS_CELL_MARGIN: 8, + GROUPS_CELL_PADDING: 12, + GROUPS_CELL_NAV_HEIGHT: 48 + 8, + GROUPS_CELL_AVATAR_WRAPPER_BOTTOM_MARGIN: 8, + GROUPS_CELL_AVATAR_GRID_GAP: 8, + GROUPS_CELL_MIN_WIDTH: 360, + GROUPS_CELL_MAX_WIDTH: 600, + // Groups table + GROUPS_ROW_HEIGHT: 72, + GROUPS_ROW_GAP: 1, + // Foldable screen + FOLDABLE_SCREEN_STAGE_PADDING: 8, +}; diff --git a/src/lib/react/Grid/helpers/stageLayout.ts b/src/lib/react/Grid/helpers/stageLayout.ts new file mode 100644 index 00000000..8d9036e2 --- /dev/null +++ b/src/lib/react/Grid/helpers/stageLayout.ts @@ -0,0 +1,651 @@ +import { fitToBounds } from "./gridUtils"; + +import * as centerGrid from "./centerGridLayout"; + +import { makeOrigin, makeBounds, makeFrame, makeBox, Frame, Bounds, Origin } from "./layout"; +import { type Box } from "./layout"; +import layoutConstants from "./layoutConstants"; + +const { BOTTOM_TOOLBAR_HEIGHT, VIDEO_CONTROLS_MIN_WIDTH, TABLET_BREAKPOINT } = layoutConstants; + +const MIN_GRID_HEIGHT = 200; +const MIN_GRID_WIDTH = 300; +const FLOATING_VIDEO_SIZE = 200; +const CONSTRAINED_OVERFLOW_TRIGGER = 12; + +function getMinGridBounds({ cellCount }: { cellCount: number }): Bounds { + // Reduce min grid dimensions if we have 6 videos or less + const isSmallGrid = cellCount <= 6; + const minGridHeight = isSmallGrid ? MIN_GRID_HEIGHT - 50 : MIN_GRID_HEIGHT; + const minGridWidth = isSmallGrid ? MIN_GRID_WIDTH - 50 : MIN_GRID_WIDTH; + return makeBounds({ width: minGridWidth, height: minGridHeight }); +} + +export function fitSupersizedContent({ + bounds, + aspectRatio, + minGridContainerBounds, + hasPresentationGrid, +}: { + bounds: Bounds; + aspectRatio: number; + minGridContainerBounds: Bounds; + hasPresentationGrid: boolean; +}) { + const { width, height } = bounds; + + // If we don't have any grids take up whole stage + const hasVideoGrid = minGridContainerBounds.width > 0; + if (!hasVideoGrid) { + return { + isPortrait: width <= height, + supersizedContentBounds: bounds, + }; + } + + // Calculate minimum supersized content bounds - take up at least half the + // available area: + const minHorizontalSupersizedContentWidth = Math.round(width / 2); + const minVerticalSupersizedContentHeight = Math.round(height / 2); + // Calculate maximum supersized content bounds + const maxHorizontalSupersizedContentWidth = Math.max(width - minGridContainerBounds.width, 0); + const maxVerticalSupersizedContentHeight = Math.max(height - minGridContainerBounds.height, 0); + let isPortrait = maxHorizontalSupersizedContentWidth <= maxVerticalSupersizedContentHeight; + + let horizontalCorrection = 0; + let verticalCorrection = 0; + + // Do we have an aspect ratio? If not give up all available space (ex some integrations) + if (aspectRatio) { + // Calculate fit bounds for both portrait and landscape layouts: + + // 1. grid to the left of content + const horizontalContentBounds = fitToBounds(aspectRatio, { + width: maxHorizontalSupersizedContentWidth, + height, + }); + + // 2. grid below content + const verticalContentBounds = fitToBounds(aspectRatio, { + width, + height: maxVerticalSupersizedContentHeight, + }); + + // Pick direction that gives content most space: + const isPortraitContent = aspectRatio <= 1.0; + isPortrait = isPortraitContent + ? verticalContentBounds.height > horizontalContentBounds.height + : verticalContentBounds.width > horizontalContentBounds.width; + + // Give wasted space back to the video grid: + if (isPortrait) { + const wastedSpace = + maxVerticalSupersizedContentHeight - + Math.max(verticalContentBounds.height, minVerticalSupersizedContentHeight); + verticalCorrection = Math.max(wastedSpace, 0); + } else { + const wastedSpace = + maxHorizontalSupersizedContentWidth - + Math.max(horizontalContentBounds.width, minHorizontalSupersizedContentWidth); + horizontalCorrection = Math.max(wastedSpace, 0); + } + } else if (hasPresentationGrid) { + // If we have more than one presentation grid cell we naively favor portrait orientation + // unless it gets too squished: + isPortrait = maxHorizontalSupersizedContentWidth / maxVerticalSupersizedContentHeight >= 5; + } + + const supersizedContentBounds = { + width: isPortrait ? width : maxHorizontalSupersizedContentWidth - horizontalCorrection, + height: isPortrait ? maxVerticalSupersizedContentHeight - verticalCorrection : height, + }; + + return { + isPortrait, + supersizedContentBounds, + }; +} + +// The stage layout is the base room layout +// It divides the stage area between a videos container (made up of video grid + +// presentation grid) + +export function calculateStageLayout({ + containerBounds, + containerOrigin, + hasConstrainedOverflow, + hasPresentationContent, + hasVideoContent, + isPortrait, +}: { + containerBounds: Bounds; + containerOrigin: Origin; + gridGap: number; + hasConstrainedOverflow: boolean; + hasPresentationContent: boolean; + hasVideoContent: boolean; + isPortrait: boolean; +}) { + const hasVideos = hasPresentationContent || hasVideoContent; + + // Sanity checks + + // Do we have anything to calculate? + if (!hasVideos) { + return { + isPortrait, + videosContainer: makeFrame(), + hasOverflow: false, + }; + } + + return { + isPortrait, + videosContainer: makeFrame({ ...containerBounds, ...containerOrigin }), + hasOverflow: hasConstrainedOverflow, + }; +} + +export function calculateVideosContainerLayout({ + containerBounds, + containerOrigin, + gridGap, + supersizedContentAspectRatio, + hasPresentationContent, + hasPresentationGrid, + hasVideoContent, + minGridBounds, +}: { + containerBounds: Bounds; + containerOrigin: Origin; + gridGap: number; + supersizedContentAspectRatio: number; + hasPresentationContent: boolean; + hasPresentationGrid: boolean; + hasVideoContent: boolean; + minGridBounds: Bounds; +}) { + const { width, height } = containerBounds; + let isPortrait = width <= height; + + let presentationGridBounds = makeBounds(); + let presentationGridOrigin = makeOrigin(); + let videoGridBounds = hasVideoContent ? { ...containerBounds } : makeBounds(); + let videoGridOrigin = hasVideoContent ? { ...containerOrigin } : makeOrigin(); + + if (hasPresentationContent) { + // Fit supersized content + const minGridContainerBounds = makeBounds({ + width: hasVideoContent ? minGridBounds.width + gridGap : 0, + height: hasVideoContent ? minGridBounds.height + gridGap : 0, + }); + const supersizedContentLayout = fitSupersizedContent({ + bounds: containerBounds, + aspectRatio: supersizedContentAspectRatio, + minGridContainerBounds, + hasPresentationGrid, + }); + isPortrait = supersizedContentLayout.isPortrait; + + presentationGridBounds = supersizedContentLayout.supersizedContentBounds; + presentationGridOrigin = { ...containerOrigin }; + + if (hasVideoContent) { + videoGridBounds = makeBounds({ + width: isPortrait + ? containerBounds.width + : containerBounds.width - presentationGridBounds.width - gridGap, + height: isPortrait + ? containerBounds.height - presentationGridBounds.height - gridGap + : containerBounds.height, + }); + + videoGridOrigin = makeOrigin({ + top: isPortrait ? containerOrigin.top + presentationGridBounds.height + gridGap : containerOrigin.top, + left: isPortrait ? containerOrigin.left : containerOrigin.left + presentationGridBounds.width + gridGap, + }); + } + } + + return { + isPortrait, + presentationGrid: { + ...makeFrame({ + ...presentationGridBounds, + ...presentationGridOrigin, + }), + }, + videoGrid: makeFrame({ + ...videoGridBounds, + ...videoGridOrigin, + }), + }; +} + +function calculateGridLayout({ + containerBounds, + paddings = makeBox(), + videos, + isConstrained, + maxGridWidth, + gridGap, +}: { + containerBounds: Bounds; + paddings?: Box; + videos: { clientId: string; isDraggable: boolean; aspectRatio: number }[]; + isConstrained: boolean; + maxGridWidth: number; + gridGap: number; +}) { + const { width, height } = containerBounds; + const cappedWidth = maxGridWidth ? Math.min(width, maxGridWidth) : width; + const cellCount = videos.length; + + let videoCells = null; + + const cellAspectRatios = videos.map((video) => video.aspectRatio); + const minGridBounds = getMinGridBounds({ cellCount }); + // Cap grid to a sane width (on very wide monitors) + const gridLayout = centerGrid.calculateLayout({ + width: cappedWidth, + height, + cellCount, + gridGap, + cellAspectRatios, + paddings, + }); + + videoCells = videos.map((video, index) => { + const cellProps = centerGrid.getCellPropsAtIndexForLayout({ index, layout: gridLayout }); + const isSmallCell = gridLayout.cellWidth < minGridBounds.width; + const shouldZoom = isConstrained || isSmallCell; + const aspectRatio = shouldZoom ? gridLayout.cellWidth / gridLayout.cellHeight : video.aspectRatio; + + return { + clientId: video.clientId, + isDraggable: video.isDraggable, + origin: makeOrigin({ + top: cellProps.top, + left: cellProps.left, + }), + bounds: makeBounds({ + width: cellProps.width, + height: cellProps.height, + }), + aspectRatio, + isSmallCell, + }; + }); + + return { + videoCells, + extraHorizontalPadding: + // If we hit the max width, pass up as extra space + width !== cappedWidth + ? gridLayout.extraHorizontalPadding + (width - cappedWidth) / 2 + : gridLayout.extraHorizontalPadding, + extraVerticalPadding: gridLayout.extraVerticalPadding, + paddings: gridLayout.paddings, + gridGap, + }; +} + +function calculateFloatingLayout({ + roomBounds, + containerFrame, + floatingVideo, + videoControlsHeight, + margin = 8, +}: { + roomBounds: Bounds; + containerFrame: Frame; + floatingVideo: { clientId: string; isDraggable: boolean; aspectRatio: number } | null; + videoControlsHeight: number; + margin?: number; +}) { + if (!floatingVideo) { + return null; + } + const bounds = fitToBounds(floatingVideo.aspectRatio, { + width: FLOATING_VIDEO_SIZE, + height: FLOATING_VIDEO_SIZE, + }); + // Determine if we should position above the video controls or not + const isFloating = !(roomBounds.height - containerFrame.bounds.height - containerFrame.origin.top); + const isConstrained = containerFrame.bounds.width - (bounds.width + margin) * 2 < VIDEO_CONTROLS_MIN_WIDTH; + let verticalOffset = 0; + if (isFloating && isConstrained) { + // Pull up above floating video controls + verticalOffset = videoControlsHeight * -1; + } else if (!isFloating && !isConstrained) { + // Push down over the bottom toolbar + verticalOffset = videoControlsHeight; + } + const origin = makeOrigin({ + top: containerFrame.origin.top + (containerFrame.bounds.height - bounds.height - margin) + verticalOffset, + left: containerFrame.origin.left + (containerFrame.bounds.width - bounds.width - margin), + }); + const videoCell = { + clientId: floatingVideo.clientId, + isDraggable: floatingVideo.isDraggable, + origin, + bounds, + aspectRatio: floatingVideo.aspectRatio, + isSmallCell: true, + }; + return videoCell; +} + +function rebalanceLayoutPaddedAreas({ + a, + b, + gridGap, + isPortrait, +}: { + a: { horizontal: number; vertical: number }; + b: { horizontal: number; vertical: number }; + gridGap: number; + isPortrait: boolean; +}) { + const aPad = isPortrait ? a.vertical : a.horizontal; + const bPad = isPortrait ? b.vertical : b.horizontal; + if (aPad === bPad) { + return { a: 0, b: 0 }; + } + const sArea = aPad < bPad ? a : b; + const sAreaPad = isPortrait ? sArea.vertical : sArea.horizontal; + const spaceBetween = gridGap + (aPad + bPad); + const offset = (spaceBetween + sAreaPad) / 2 - sAreaPad; + return { + a: sArea === a ? offset : 0, + b: sArea === b ? offset : 0, + }; +} + +type VideoContainerLayout = { + isPortrait: boolean; + presentationGrid: Frame; + videoGrid: Frame; +}; +type GridLayout = { + videoCells: { clientId: string; isDraggable: boolean; aspectRatio: number }[]; + extraHorizontalPadding: number; + extraVerticalPadding: number; + paddings: Box; + gridGap: number; +}; + +function rebalanceLayoutInPlace({ + videosContainerLayout, + gridLayout, + presentationGridLayout, + gridGap, +}: { + videosContainerLayout: VideoContainerLayout; + gridLayout: GridLayout; + presentationGridLayout: GridLayout; + gridGap: number; +}) { + const hasPresentationGrid = videosContainerLayout.presentationGrid.bounds.width > 0; + const hasVideoGrid = videosContainerLayout.videoGrid.bounds.width > 0; + + const videoGridRebalanceOffset = { vertical: 0, horizontal: 0 }; + + // Rebalance video containers if we have both presentationGrid and videoGrid bounds, + // unless we have a breakout no group stage: + if (hasPresentationGrid && hasVideoGrid) { + const correction = rebalanceLayoutPaddedAreas({ + a: { + horizontal: presentationGridLayout.extraHorizontalPadding, + vertical: presentationGridLayout.extraVerticalPadding, + }, + b: { + horizontal: gridLayout.extraHorizontalPadding, + vertical: gridLayout.extraVerticalPadding, + }, + gridGap, + isPortrait: videosContainerLayout.isPortrait, + }); + + // Update in place: + if (videosContainerLayout.isPortrait) { + videosContainerLayout.presentationGrid.origin.top += correction.a; + videosContainerLayout.videoGrid.origin.top -= correction.b; + // Save off how much we moved the grid over to be used in the next phase: + videoGridRebalanceOffset.vertical = correction.b; + } else { + videosContainerLayout.presentationGrid.origin.left += correction.a; + videosContainerLayout.videoGrid.origin.left -= correction.b; + // Save off how much we moved the grid over to be used in the next phase: + videoGridRebalanceOffset.horizontal = correction.b; + } + } +} + +interface CalculateGridLayoutsOptions { + gridGap: number; + isConstrained: boolean; + presentationVideos: { clientId: string; isDraggable: boolean; aspectRatio: number }[]; + videos: { clientId: string; isDraggable: boolean; aspectRatio: number }[]; + videosContainerLayout: VideoContainerLayout; + gridLayoutPaddings?: Box; + presentationGridLayoutPaddings?: Box; + maxGridWidth: number; +} + +function calculateGridLayouts({ + gridGap, + isConstrained, + presentationVideos, + videos, + videosContainerLayout, + gridLayoutPaddings = makeBox(), + presentationGridLayoutPaddings = makeBox(), + maxGridWidth, +}: CalculateGridLayoutsOptions) { + // Lay out video cells in provided video containers: + const gridLayout = calculateGridLayout({ + containerBounds: videosContainerLayout.videoGrid.bounds, + gridGap, + isConstrained, + maxGridWidth, + paddings: gridLayoutPaddings, + videos, + }); + const presentationGridLayout = calculateGridLayout({ + containerBounds: videosContainerLayout.presentationGrid.bounds, + gridGap, + isConstrained, + maxGridWidth, + paddings: presentationGridLayoutPaddings, + videos: presentationVideos, + }); + + return { gridLayout, presentationGridLayout }; +} + +// autofill arguments from calculateLayout function +interface CalculateLayoutOptions { + breakoutActive?: boolean; + breakoutGroupedClients?: []; + breakoutStagePaddings?: Box; + floatingVideo?: { clientId: string; isDraggable: boolean; aspectRatio: number } | null; + frame: Frame; + gridGap: number; + isConstrained: boolean; + isMaximizeMode?: boolean; + isXLMeetingSize?: boolean; + paddings?: Box; + presentationVideos?: { clientId: string; isDraggable: boolean; aspectRatio: number }[]; + rebalanceLayout?: boolean; + roomBounds: Bounds; + roomLayoutHasOverlow?: boolean; + videoControlsHeight?: number; + videos?: { clientId: string; isDraggable: boolean; aspectRatio: number }[]; + videoGridGap?: number; +} + +export function calculateLayout({ + floatingVideo = null, + frame, + gridGap = 0, + isConstrained = false, + isMaximizeMode = false, + paddings = makeBox(), + presentationVideos = [], + rebalanceLayout = false, + roomBounds, + roomLayoutHasOverlow = false, + videoControlsHeight = 0, + videos = [], + videoGridGap = 0, +}: CalculateLayoutOptions) { + const hasPresentationContent = !!presentationVideos.length; + const hasPresentationGrid = presentationVideos.length > 1; + const supersizedContentAspectRatio = + hasPresentationContent && !hasPresentationGrid ? presentationVideos[0].aspectRatio : 1; + const hasVideoContent = !!videos.length; + const width = frame.bounds.width - paddings.left - paddings.right; + let height = frame.bounds.height - paddings.top - paddings.bottom; + const maxGridWidth = Math.max(25 * 88, (80 / 100) * width); // go up to 80vw after a sane max width + + // On mobile, we set a hard limit on 12 videos, and overflows after that. + const hasConstrainedOverflow = (isConstrained && videos.length > CONSTRAINED_OVERFLOW_TRIGGER) || false; + const lineHeight = height / 4; + const extraLines = Math.ceil((videos.length - CONSTRAINED_OVERFLOW_TRIGGER) / 3); + + height = hasConstrainedOverflow ? height + lineHeight * extraLines : height; + + const stageBounds = makeBounds({ width, height }); + const stageOrigin = makeOrigin({ top: paddings.top, left: paddings.left }); + const _minBounds = getMinGridBounds({ cellCount: videos.length }); + const minGridBounds = _minBounds; + + const isSmallScreen = roomBounds.width < TABLET_BREAKPOINT || roomBounds.height < TABLET_BREAKPOINT; + + const forceStageLayoutPortrait = isMaximizeMode; + const stageLayoutIsPortrait = + forceStageLayoutPortrait || + !(hasPresentationContent || hasVideoContent) || + stageBounds.width <= stageBounds.height; + + const stableStageLayoutProps = { + cellPaddings: { top: 4, left: 4, bottom: 4, right: 4 }, + containerBounds: stageBounds, + containerOrigin: stageOrigin, + gridGap, + hasPresentationContent, + hasVideoContent, + isConstrained, + isMaximizeMode, + isSmallScreen, + maxGridWidth, + }; + + let stageLayout = calculateStageLayout({ + ...stableStageLayoutProps, + isPortrait: stageLayoutIsPortrait, + hasConstrainedOverflow, + }); + + // Prevent yo-yo-ing between overflow and non overflow states: + // - if we're not in a forced overflow state and main room layout has overflow already (prev we could not fit) and now we can fit, + // - double check by re-running the stage layout with the non overflow bounds: + let forceRerunAsOverflow = false; + if (roomLayoutHasOverlow && !stageLayout.hasOverflow) { + const _stageLayout = calculateStageLayout({ + ...stableStageLayoutProps, + containerBounds: makeBounds({ + width: stageBounds.width, + height: stageBounds.height - BOTTOM_TOOLBAR_HEIGHT, // override "stable" prop + }), + isPortrait: stageLayoutIsPortrait, + hasConstrainedOverflow, + }); + // If it turns out we can't fit, force re-layout as overflow: + if (_stageLayout.hasOverflow) { + forceRerunAsOverflow = true; + } + } + + // If subgrid cannot fit, re-run the stage layout in overflow: + if (forceRerunAsOverflow || stageLayout.hasOverflow) { + stageLayout = calculateStageLayout({ + ...stableStageLayoutProps, + isPortrait: true, + hasConstrainedOverflow, + }); + } + + const videosContainerLayout = calculateVideosContainerLayout({ + containerBounds: stageLayout.videosContainer.bounds, + containerOrigin: stageLayout.videosContainer.origin, + gridGap, + supersizedContentAspectRatio, + hasPresentationContent, + hasPresentationGrid, + hasVideoContent, + minGridBounds, + }); + + const { gridLayout, presentationGridLayout } = calculateGridLayouts({ + gridGap: videoGridGap, + isConstrained, + presentationVideos, + videos, + videosContainerLayout, + maxGridWidth, + }); + + const floatingLayout = calculateFloatingLayout({ + roomBounds, + containerFrame: frame, + floatingVideo, + videoControlsHeight, + }); + + // Nudge containers closer to each other to get pleasing layouts with less extreme + // negative space. It's opt in because debugging is a lot easier with this behavior off: + if (rebalanceLayout) { + rebalanceLayoutInPlace({ + videosContainerLayout, + gridLayout, + presentationGridLayout, + gridGap, + }); + } + + return { + isPortrait: stageLayout.isPortrait, + hasOverflow: stageLayout.hasOverflow, + bounds: makeBounds({ + height: frame.bounds.height, + width: frame.bounds.width, + }), + gridGap, + presentationGrid: { + ...videosContainerLayout.presentationGrid, + cells: presentationGridLayout.videoCells, + paddings: makeBox({ + top: presentationGridLayout.paddings.top + presentationGridLayout.extraVerticalPadding, + bottom: presentationGridLayout.paddings.bottom + presentationGridLayout.extraVerticalPadding, + left: presentationGridLayout.paddings.left + presentationGridLayout.extraHorizontalPadding, + right: presentationGridLayout.paddings.right + presentationGridLayout.extraHorizontalPadding, + }), + }, + videoGrid: { + ...videosContainerLayout.videoGrid, + cells: gridLayout.videoCells, + paddings: makeBox({ + top: gridLayout.paddings.top + gridLayout.extraVerticalPadding, + bottom: gridLayout.paddings.bottom + gridLayout.extraVerticalPadding, + left: gridLayout.paddings.left + gridLayout.extraHorizontalPadding, + right: gridLayout.paddings.right + gridLayout.extraHorizontalPadding, + }), + }, + floatingContent: { + ...floatingLayout, + ...floatingVideo, + }, + }; +} diff --git a/src/lib/react/Grid/index.tsx b/src/lib/react/Grid/index.tsx new file mode 100644 index 00000000..f5fbeb5f --- /dev/null +++ b/src/lib/react/Grid/index.tsx @@ -0,0 +1,186 @@ +import * as React from "react"; +import { LocalParticipant, RemoteParticipant, VideoView } from ".."; +import { calculateLayout } from "./helpers/stageLayout"; +import { Bounds, Frame, Origin, makeFrame } from "./helpers/layout"; +import { makeVideoCellView } from "./helpers/cellView"; +import debounce from "../../../lib/utils/debounce"; +import { RoomConnectionRef } from "../useRoomConnection/types"; +import { doRtcReportStreamResolution } from "../../../lib/core/redux/slices/rtcConnection"; + +function GridVideoCellView({ + cell, + participant, + render, + onSetAspectRatio, + onResize, +}: { + cell: { aspectRatio: number; clientId: string; bounds: Bounds; origin: Origin }; + participant: RemoteParticipant | LocalParticipant; + render?: () => React.ReactNode; + onSetAspectRatio: ({ aspectRatio }: { aspectRatio: number }) => void; + onResize?: ({ width, height, stream }: { width: number; height: number; stream: MediaStream }) => void; +}) { + const handleAspectRatioChange = React.useCallback( + ({ ar }: { ar: number }) => { + if (ar !== cell.aspectRatio) { + onSetAspectRatio({ aspectRatio: ar }); + } + }, + [cell.aspectRatio, onSetAspectRatio] + ); + + return ( +
+ {render ? ( + render() + ) : participant.stream ? ( + handleAspectRatioChange({ ar: aspectRatio })} + onResize={onResize as any} + /> + ) : null} +
+ ); +} + +interface GridProps { + roomConnection: RoomConnectionRef; + renderParticipant?: ({ + cell, + participant, + }: { + cell: { clientId: string; bounds: Bounds; origin: Origin }; + participant: RemoteParticipant | LocalParticipant; + }) => React.ReactNode; + videoGridGap?: number; +} + +function Grid({ roomConnection, renderParticipant, videoGridGap = 0 }: GridProps) { + const { remoteParticipants, localParticipant } = roomConnection.state; + const gridRef = React.useRef(null); + const [containerFrame, setContainerFrame] = React.useState(null); + const [aspectRatios, setAspectRatios] = React.useState<{ clientId: string; aspectRatio: number }[]>([]); + + // Calculate container frame on resize + React.useEffect(() => { + if (!gridRef.current) { + return; + } + + const resizeObserver = new ResizeObserver( + debounce( + () => { + setContainerFrame( + makeFrame({ + width: gridRef.current?.clientWidth, + height: gridRef.current?.clientHeight, + }) + ); + }, + { delay: 60 } + ) + ); + resizeObserver.observe(gridRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + // Merge local and remote participants + const participants = React.useMemo(() => { + return [...(localParticipant ? [localParticipant] : []), ...remoteParticipants]; + }, [remoteParticipants, localParticipant]); + + // Make video cells + const videoCells = React.useMemo(() => { + return participants.map((participant) => { + const aspectRatio = aspectRatios.find((item) => item.clientId === participant?.id)?.aspectRatio; + + return makeVideoCellView({ + aspectRatio: aspectRatio ?? 16 / 9, + avatarSize: 0, + cellPaddings: 10, + client: participant, + }); + }); + }, [participants, aspectRatios]); + + // Calculate stage layout + const stageLayout = React.useMemo(() => { + if (!containerFrame) return null; + + return calculateLayout({ + frame: containerFrame, + gridGap: 0, + isConstrained: false, + roomBounds: containerFrame.bounds, + videos: videoCells, + videoGridGap, + }); + }, [containerFrame, videoCells, videoGridGap]); + + // Handle resize + const handleResize = React.useCallback( + ({ width, height, stream }: { width: number; height: number; stream: MediaStream }) => { + if (!roomConnection._ref) return; + + roomConnection._ref.dispatch(doRtcReportStreamResolution({ streamId: stream.id, width, height })); + }, + [localParticipant, roomConnection._ref] + ); + + return ( +
+ {participants.map((participant, i) => { + const cell = stageLayout?.videoGrid.cells[i]; + + if (!cell || !participant || !participant.stream || !cell.clientId) return null; + + return ( + renderParticipant({ cell, participant }) : undefined} + onResize={handleResize} + onSetAspectRatio={({ aspectRatio }) => { + setAspectRatios((prev) => { + const index = prev.findIndex((item) => item.clientId === cell.clientId); + + if (index === -1) { + return [...prev, { clientId: cell.clientId, aspectRatio }]; + } + + return [ + ...prev.slice(0, index), + { clientId: cell.clientId, aspectRatio }, + ...prev.slice(index + 1), + ]; + }); + }} + /> + ); + })} +
+ ); +} + +export { Grid }; diff --git a/src/lib/react/VideoView.tsx b/src/lib/react/VideoView.tsx index b994898f..24713a44 100644 --- a/src/lib/react/VideoView.tsx +++ b/src/lib/react/VideoView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from "react"; +import * as React from "react"; import debounce from "../utils/debounce"; interface VideoViewSelfProps { @@ -7,16 +7,17 @@ interface VideoViewSelfProps { mirror?: boolean; style?: React.CSSProperties; onResize?: ({ width, height, stream }: { width: number; height: number; stream: MediaStream }) => void; + onSetAspectRatio?: ({ aspectRatio }: { aspectRatio: number }) => void; } type VideoViewProps = VideoViewSelfProps & React.DetailedHTMLProps, HTMLVideoElement>; -export default ({ muted, mirror = false, stream, onResize, ...rest }: VideoViewProps) => { - const videoEl = useRef(null); +export default ({ muted, mirror = false, stream, onResize, onSetAspectRatio, ...rest }: VideoViewProps) => { + const videoEl = React.useRef(null); - useEffect(() => { - if (!videoEl.current || !onResize) { + React.useEffect(() => { + if (!videoEl.current) { return; } @@ -24,11 +25,19 @@ export default ({ muted, mirror = false, stream, onResize, ...rest }: VideoViewP debounce( () => { if (videoEl.current && stream?.id) { - onResize({ - width: videoEl.current.clientWidth, - height: videoEl.current.clientHeight, - stream, - }); + if (onResize) { + onResize({ + width: videoEl.current.clientWidth, + height: videoEl.current.clientHeight, + stream, + }); + } + const h = videoEl.current.videoHeight; + const w = videoEl.current.videoWidth; + + if (w && h && w + h > 20 && onSetAspectRatio) { + onSetAspectRatio({ aspectRatio: w / h }); + } } }, { delay: 1000, edges: true } @@ -42,7 +51,7 @@ export default ({ muted, mirror = false, stream, onResize, ...rest }: VideoViewP }; }, [stream]); - useEffect(() => { + React.useEffect(() => { if (!videoEl.current) { return; } diff --git a/src/lib/react/useRoomConnection/index.ts b/src/lib/react/useRoomConnection/index.ts index 14f70383..cd03af98 100644 --- a/src/lib/react/useRoomConnection/index.ts +++ b/src/lib/react/useRoomConnection/index.ts @@ -1,5 +1,5 @@ import * as React from "react"; -import { RoomConnectionState, RoomConnectionActions, UseRoomConnectionOptions } from "./types"; +import { RoomConnectionState, UseRoomConnectionOptions, RoomConnectionRef, VideoViewComponentProps } from "./types"; import { Store, createStore, observeStore } from "../../core/redux/store"; import VideoView from "../VideoView"; import { createServices } from "../../services"; @@ -23,19 +23,6 @@ const initialState: RoomConnectionState = { waitingParticipants: [], }; -type VideoViewComponentProps = Omit, "onResize">; - -interface RoomConnectionComponents { - VideoView: (props: VideoViewComponentProps) => ReturnType; -} - -export type RoomConnectionRef = { - state: RoomConnectionState; - actions: RoomConnectionActions; - components: RoomConnectionComponents; - _ref: Store; -}; - const defaultRoomConnectionOptions: UseRoomConnectionOptions = { localMediaOptions: { audio: true, diff --git a/src/lib/react/useRoomConnection/types.ts b/src/lib/react/useRoomConnection/types.ts index 62659f76..70d5d2bf 100644 --- a/src/lib/react/useRoomConnection/types.ts +++ b/src/lib/react/useRoomConnection/types.ts @@ -3,6 +3,8 @@ import { LocalParticipant, RemoteParticipant, Screenshare } from "../../RoomPart import { ChatMessage as SignalChatMessage } from "@whereby/jslib-media/src/utils/ServerSocket"; import { LocalMediaOptions } from "../../core/redux/slices/localMedia"; import { UseLocalMediaResult } from "../useLocalMedia/types"; +import { Store } from "../../../lib/core/redux/store"; +import VideoView from "../VideoView"; export type RemoteParticipantState = Omit; export type LocalParticipantState = LocalParticipant; @@ -79,3 +81,16 @@ export interface RoomConnectionActions { stopCloudRecording(): void; stopScreenshare(): void; } + +export type VideoViewComponentProps = Omit, "onResize">; + +interface RoomConnectionComponents { + VideoView: (props: VideoViewComponentProps) => ReturnType; +} + +export type RoomConnectionRef = { + state: RoomConnectionState; + actions: RoomConnectionActions; + components: RoomConnectionComponents; + _ref: Store; +}; diff --git a/src/stories/components/Grid.tsx b/src/stories/components/Grid.tsx index 62aebcbc..4e40e375 100644 --- a/src/stories/components/Grid.tsx +++ b/src/stories/components/Grid.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import { VideoView } from "~/lib/react"; -import { RoomConnectionRef } from "~/lib/react/useRoomConnection"; +import { RoomConnectionRef } from "~/lib/react/useRoomConnection/types"; export default function Grid({ roomConnection }: { roomConnection: RoomConnectionRef }) { const { state, components } = roomConnection; diff --git a/src/stories/custom-ui.stories.tsx b/src/stories/custom-ui.stories.tsx index b3b13c29..73d7619e 100644 --- a/src/stories/custom-ui.stories.tsx +++ b/src/stories/custom-ui.stories.tsx @@ -6,6 +6,7 @@ import fakeWebcamFrame from "../lib/utils/fakeWebcamFrame"; import fakeAudioStream from "../lib/utils/fakeAudioStream"; import "./styles.css"; import Grid from "./components/Grid"; +import { Grid as VideoGrid } from "../lib/react/Grid"; export default { title: "Examples/Custom UI", @@ -172,3 +173,63 @@ RoomConnectionStrictMode.parameters = { }, }, }; + +export const GridStory = ({ roomUrl }: { roomUrl: string; displayName?: string }) => { + if (!roomUrl || !roomUrl.match(roomRegEx)) { + return

Set room url on the Controls panel

; + } + + const roomConnection = useRoomConnection(roomUrl, { localMediaOptions: { audio: false, video: true } }); + + return ( +
+ +
+ ); +}; + +export const GridWithCustomVideosStory = ({ roomUrl }: { roomUrl: string; displayName?: string }) => { + if (!roomUrl || !roomUrl.match(roomRegEx)) { + return

Set room url on the Controls panel

; + } + + const roomConnection = useRoomConnection(roomUrl, { localMediaOptions: { audio: false, video: true } }); + + return ( +
+ { + if (!participant.stream) { + return null; + } + + return ( +
+ +

{participant.displayName}

+
+ ); + }} + /> +
+ ); +};