diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index bf38693..6c2a709 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -216,6 +216,7 @@ export function InCallView({ focused: screenshareFeeds.length === 0 && callFeed === activeSpeaker, isLocal: member.userId === localUserId && deviceId === localDeviceId, presenter, + largeBaseSize: false, connectionState, }); } @@ -228,6 +229,7 @@ export function InCallView({ // Add the screenshares too for (const screenshareFeed of screenshareFeeds) { const member = screenshareFeed.getMember()!; + const deviceId = screenshareFeed.deviceId!; const connectionState = participants .get(member) ?.get(screenshareFeed.deviceId!)?.connectionState; @@ -242,6 +244,8 @@ export function InCallView({ focused: true, isLocal: screenshareFeed.isLocal(), presenter: false, + largeBaseSize: true, + placeNear: `${member.userId} ${deviceId}`, connectionState, }); } diff --git a/src/video-grid/NewVideoGrid.tsx b/src/video-grid/NewVideoGrid.tsx index 8922f6a..c255959 100644 --- a/src/video-grid/NewVideoGrid.tsx +++ b/src/video-grid/NewVideoGrid.tsx @@ -43,7 +43,7 @@ import { fillGaps, forEachCellInArea, cycleTileSize, - appendItems, + addItems, tryMoveTile, resize, } from "./model"; @@ -94,7 +94,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/TileDescriptor.tsx b/src/video-grid/TileDescriptor.tsx index cc37528..c215bc6 100644 --- a/src/video-grid/TileDescriptor.tsx +++ b/src/video-grid/TileDescriptor.tsx @@ -28,5 +28,7 @@ export interface TileDescriptor { presenter: boolean; callFeed?: CallFeed; isLocal?: boolean; + largeBaseSize: boolean; + placeNear?: string; connectionState: ConnectionState; } diff --git a/src/video-grid/model.ts b/src/video-grid/model.ts index b2d7984..5150ace 100644 --- a/src/video-grid/model.ts +++ b/src/video-grid/model.ts @@ -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. @@ -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 dd25e8a..f7d0330 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, @@ -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,