diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 048ec6a7..acefd3dc 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -464,6 +464,7 @@ function useParticipantTiles( !sfuParticipant.isLocal, hasVideo: sfuParticipant.isCameraEnabled, local: sfuParticipant.isLocal, + largeBaseSize: false, data: { member, sfuParticipant, @@ -478,6 +479,8 @@ function useParticipantTiles( ...userMediaTile, id: `${id}:screen-share`, focused: true, + largeBaseSize: true, + placeNear: id, data: { ...userMediaTile.data, content: TileContent.ScreenShare, diff --git a/src/video-grid/NewVideoGrid.tsx b/src/video-grid/NewVideoGrid.tsx index 00c06507..5012fb46 100644 --- a/src/video-grid/NewVideoGrid.tsx +++ b/src/video-grid/NewVideoGrid.tsx @@ -46,7 +46,7 @@ import { fillGaps, forEachCellInArea, cycleTileSize, - appendItems, + addItems, tryMoveTile, resize, } from "./model"; @@ -97,7 +97,7 @@ const useGridState = ( grid2.cells.filter((c) => c !== undefined).map((c) => c!.item.id) ); const newItems = items.filter((i) => !existingItemIds.has(i.id)); - const grid3 = appendItems(newItems, grid2); + const grid3 = addItems(newItems, grid2); return { ...grid3, generation: prevGrid.generation + 1 }; }, diff --git a/src/video-grid/VideoGrid.tsx b/src/video-grid/VideoGrid.tsx index bc572dbe..78c26156 100644 --- a/src/video-grid/VideoGrid.tsx +++ b/src/video-grid/VideoGrid.tsx @@ -812,6 +812,8 @@ export interface TileDescriptor { isSpeaker: boolean; hasVideo: boolean; local: boolean; + largeBaseSize: boolean; + placeNear?: string; data: T; } diff --git a/src/video-grid/model.ts b/src/video-grid/model.ts index 88aa38ea..0101d8d8 100644 --- a/src/video-grid/model.ts +++ b/src/video-grid/model.ts @@ -266,7 +266,7 @@ export function tryMoveTile(g: Grid, from: number, to: number): Grid { const tile = g.cells[from]!; if ( - to > 0 && + to >= 0 && to < g.cells.length && column(to, g) <= g.columns - tile.columns ) { @@ -403,19 +403,94 @@ export function fillGaps(g: Grid): Grid { return result; } -export function appendItems(items: TileDescriptor[], g: Grid): Grid { - return { - ...g, - cells: [ - ...g.cells, - ...items.map((i) => ({ - item: i, - origin: true, - columns: 1, - rows: 1, - })), - ], +function createRows(g: Grid, count: number, atRow: number): Grid { + const result = { + columns: g.columns, + cells: new Array(g.cells.length + g.columns * count), }; + const offsetAfterNewRows = g.columns * count; + + // Copy tiles from the original grid to the new one, with the new rows + // inserted at the target location + g.cells.forEach((c, from) => { + if (c?.origin) { + const offset = row(from, g) >= atRow ? offsetAfterNewRows : 0; + forEachCellInArea( + from, + areaEnd(from, c.columns, c.rows, g), + g, + (c, i) => { + result.cells[i + offset] = c; + } + ); + } + }); + + return result; +} + +/** + * Adds a set of new items into the grid. + */ +export function addItems(items: TileDescriptor[], g: Grid): Grid { + let result = cloneGrid(g); + + for (const item of items) { + const cell = { + item, + origin: true, + columns: 1, + rows: 1, + }; + + let placeAt: number; + let hasGaps: boolean; + + if (item.placeNear === undefined) { + // This item has no special placement requests, so let's put it + // uneventfully at the end of the grid + placeAt = result.cells.length; + hasGaps = false; + } else { + // This item wants to be placed near another; let's put it on a row + // directly below the related tile + const placeNear = result.cells.findIndex( + (c) => c?.item.id === item.placeNear + ); + if (placeNear === -1) { + // Can't find the related tile, so let's give up and place it at the end + placeAt = result.cells.length; + hasGaps = false; + } else { + const placeNearCell = result.cells[placeNear]!; + const placeNearEnd = areaEnd( + placeNear, + placeNearCell.columns, + placeNearCell.rows, + result + ); + + result = createRows(result, 1, row(placeNearEnd, result) + 1); + placeAt = + placeNear + + Math.floor(placeNearCell.columns / 2) + + result.columns * placeNearCell.rows; + hasGaps = true; + } + } + + result.cells[placeAt] = cell; + + if (item.largeBaseSize) { + // Cycle the tile size once to set up the tile with its larger base size + // This also fills any gaps in the grid, hence no extra call to fillGaps + result = cycleTileSize(item.id, result); + } else if (hasGaps) { + result = fillGaps(result); + } + } + + return result; } const largeTileDimensions = (g: Grid): [number, number] => [ @@ -423,6 +498,9 @@ const largeTileDimensions = (g: Grid): [number, number] => [ 2, ]; +const extraLargeTileDimensions = (g: Grid): [number, number] => + g.columns > 3 ? [4, 3] : [g.columns, 2]; + /** * Changes the size of a tile, rearranging the grid to make space. * @param tileId The ID of the tile to modify. @@ -432,13 +510,19 @@ const largeTileDimensions = (g: Grid): [number, number] => [ export function cycleTileSize(tileId: string, g: Grid): Grid { const from = g.cells.findIndex((c) => c?.item.id === tileId); if (from === -1) return g; // Tile removed, no change - const fromWidth = g.cells[from]!.columns; - const fromHeight = g.cells[from]!.rows; + const fromCell = g.cells[from]!; + const fromWidth = fromCell.columns; + const fromHeight = fromCell.rows; const fromEnd = areaEnd(from, fromWidth, fromHeight, g); - // The target dimensions, which toggle between 1×1 and larger than 1×1 + const [baseDimensions, enlargedDimensions] = fromCell.item.largeBaseSize + ? [largeTileDimensions(g), extraLargeTileDimensions(g)] + : [[1, 1], largeTileDimensions(g)]; + // The target dimensions, which toggle between the base and enlarged sizes const [toWidth, toHeight] = - fromWidth === 1 && fromHeight === 1 ? largeTileDimensions(g) : [1, 1]; + fromWidth === baseDimensions[0] && fromHeight === baseDimensions[1] + ? enlargedDimensions + : baseDimensions; // If we're expanding the tile, we want to create enough new rows at the // tile's target position such that every new unit of grid area created during @@ -450,12 +534,6 @@ export function cycleTileSize(tileId: string, g: Grid): Grid { Math.ceil((toWidth * toHeight - fromWidth * fromHeight) / g.columns) ); - // This is the grid with the new rows added - const gappyGrid: Grid = { - ...g, - cells: new Array(g.cells.length + newRows * g.columns), - }; - // The next task is to scan for a spot to place the modified tile. Since we // might be creating new rows at the target position, this spot can be shorter // than the target height. @@ -465,8 +543,8 @@ export function cycleTileSize(tileId: string, g: Grid): Grid { // To make the tile appear to expand outwards from its center, we're actually // scanning for locations to put the *center* of the tile. These numbers are // the offsets between the tile's origin and its center. - const scanColumnOffset = Math.floor((toWidth - 1) / 2); - const scanRowOffset = Math.floor((toHeight - 1) / 2); + const scanColumnOffset = Math.floor((toWidth - fromWidth) / 2); + const scanRowOffset = Math.floor((toHeight - fromHeight) / 2); const nextScanLocations = new Set([from]); const rows = row(g.cells.length - 1, g) + 1; @@ -510,22 +588,19 @@ export function cycleTileSize(tileId: string, g: Grid): Grid { const toRow = row(to, g); - // Copy tiles from the original grid to the new one, with the new rows - // inserted at the target location - g.cells.forEach((c, from) => { - if (c?.origin && c.item.id !== tileId) { - const offset = - row(from, g) > toRow + candidateHeight - 1 ? g.columns * newRows : 0; - forEachCellInArea( - from, - areaEnd(from, c.columns, c.rows, g), - g, - (c, i) => { - gappyGrid.cells[i + offset] = c; - } - ); - } - }); + // This is the grid with the new rows added + const gappyGrid = createRows(g, newRows, toRow + candidateHeight); + + // Remove the original tile + const fromInGappyGrid = + from + (row(from, g) >= toRow + candidateHeight ? g.columns * newRows : 0); + const fromEndInGappyGrid = fromInGappyGrid - from + fromEnd; + forEachCellInArea( + fromInGappyGrid, + fromEndInGappyGrid, + gappyGrid, + (_c, i) => (gappyGrid.cells[i] = undefined) + ); // Place the tile in its target position, making a note of the tiles being // overwritten diff --git a/test/video-grid/model-test.ts b/test/video-grid/model-test.ts index fe0364c3..36994872 100644 --- a/test/video-grid/model-test.ts +++ b/test/video-grid/model-test.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { - appendItems, + addItems, column, cycleTileSize, fillGaps, @@ -273,9 +273,9 @@ dbbe fghi jk`, ` -abhc -djge -fik` +akbc +djhe +fig` ); testCycleTileSize( @@ -313,20 +313,54 @@ dde ddf` ); -test("appendItems appends 1×1 tiles", () => { - const grid1 = ` +function testAddItems( + title: string, + items: TileDescriptor[], + input: string, + output: string +): void { + test(`addItems ${title}`, () => { + expect(showGrid(addItems(items, mkGrid(input)))).toBe(output); + }); +} + +testAddItems( + "appends 1×1 tiles", + ["e", "f"].map((i) => ({ id: i } as unknown as TileDescriptor)), + ` aab aac -d`; - const grid2 = ` +d`, + ` aab aac -def`; - const newItems = ["e", "f"].map( - (i) => ({ id: i } as unknown as TileDescriptor) - ); - expect(showGrid(appendItems(newItems, mkGrid(grid1)))).toBe(grid2); -}); +def` +); + +testAddItems( + "places one tile near another on request", + [{ id: "g", placeNear: "b" } as unknown as TileDescriptor], + ` +abc +def`, + ` +abc +gfe +d` +); + +testAddItems( + "places items with a large base size", + [{ id: "g", largeBaseSize: true } as unknown as TileDescriptor], + ` +abc +def`, + ` +abc +ggf +gge +d` +); function testTryMoveTile( title: string,