mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-10 23:25:52 +00:00
Extract the layout decision into a pure function
This commit is contained in:
@@ -93,17 +93,16 @@ import { type HostBridge, nullHostBridge } from "../../HostBridge";
|
||||
import {
|
||||
layoutShallowEquals,
|
||||
type Alignment,
|
||||
type GridLayoutMedia,
|
||||
type Layout,
|
||||
type LayoutMedia,
|
||||
type OneOnOneDesktopLayoutMedia,
|
||||
type OneOnOneMobileLayoutMedia,
|
||||
type SpotlightExpandedLayoutMedia,
|
||||
type SpotlightLandscapeLayoutMedia,
|
||||
type SpotlightPortraitLayoutMedia,
|
||||
type WindowMode,
|
||||
} from "../layout-types.ts";
|
||||
import { chooseSpotlightSpeaker, computeSpotlight } from "../layoutMedia.ts";
|
||||
import {
|
||||
chooseSpotlightSpeaker,
|
||||
computeLayoutMedia,
|
||||
computeSpotlight,
|
||||
type OneOnOneMedia,
|
||||
} from "../layoutMedia.ts";
|
||||
import { ElementCallError, UnknownCallError } from "../../utils/errors.ts";
|
||||
import { type Epoch, type ObservableScope } from "../ObservableScope.ts";
|
||||
import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts";
|
||||
@@ -147,7 +146,6 @@ import {
|
||||
import { Publisher } from "./localMember/Publisher.ts";
|
||||
import { type Connection } from "./remoteMembers/Connection.ts";
|
||||
import {
|
||||
type LayoutMode,
|
||||
type LayoutSwitchViewModel,
|
||||
createLayoutSwitchViewModel,
|
||||
} from "../LayoutSwitchViewModel.ts";
|
||||
@@ -265,10 +263,6 @@ export function callViewModelOptionsFromParams(
|
||||
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
|
||||
export const THROTTLE_SOUND_EFFECT_MS = 500;
|
||||
|
||||
// This is the number of participants that we think constitutes a "small" call
|
||||
// on mobile. No spotlight tile should be shown below this threshold.
|
||||
const smallMobileCallThreshold = 3;
|
||||
|
||||
// How long the footer should be shown for when hovering over or interacting
|
||||
// with the interface
|
||||
const showFooterMs = 4000;
|
||||
@@ -1773,133 +1767,3 @@ function getE2eeKeyProvider(
|
||||
return keyProvider;
|
||||
}
|
||||
}
|
||||
|
||||
interface OneOnOneMedia {
|
||||
local: LocalUserMediaViewModel;
|
||||
remote: UserMediaViewModel | RingingMediaViewModel;
|
||||
}
|
||||
|
||||
interface LayoutMediaInputs {
|
||||
windowMode: WindowMode;
|
||||
/** The layout chosen with the layout switch. */
|
||||
layoutMode: LayoutMode;
|
||||
spotlightExpanded: boolean;
|
||||
/** Set when the call qualifies for a one-on-one layout. */
|
||||
oneOnOne: OneOnOneMedia | null;
|
||||
localVideoEnabled: boolean;
|
||||
spotlight: MediaViewModel[];
|
||||
grid: UserMediaViewModel[];
|
||||
pip: UserMediaViewModel | undefined;
|
||||
desktop: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides which layout to use and which media goes where in it.
|
||||
*/
|
||||
function computeLayoutMedia({
|
||||
windowMode,
|
||||
layoutMode,
|
||||
spotlightExpanded,
|
||||
oneOnOne,
|
||||
localVideoEnabled,
|
||||
spotlight,
|
||||
grid,
|
||||
pip,
|
||||
desktop,
|
||||
}: LayoutMediaInputs): LayoutMedia {
|
||||
switch (windowMode) {
|
||||
case "normal":
|
||||
if (layoutMode === "grid")
|
||||
return oneOnOne === null
|
||||
? gridLayoutMedia(spotlight, grid)
|
||||
: oneOnOneDesktopLayoutMedia(oneOnOne);
|
||||
return spotlightExpanded
|
||||
? spotlightExpandedLayoutMedia(false, spotlight, pip)
|
||||
: spotlightLandscapeLayoutMedia(false, spotlight, grid);
|
||||
case "narrow":
|
||||
if (oneOnOne !== null)
|
||||
return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled);
|
||||
return grid.length > smallMobileCallThreshold || hasScreenShare(spotlight)
|
||||
? spotlightPortraitLayoutMedia(spotlight, grid)
|
||||
: gridLayoutMedia(spotlight, grid);
|
||||
case "flat":
|
||||
if (oneOnOne !== null)
|
||||
return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled);
|
||||
// Yes, grid mode actually gets you a "spotlight" layout in this window
|
||||
// mode.
|
||||
return layoutMode === "grid"
|
||||
? spotlightLandscapeLayoutMedia(true, spotlight, grid)
|
||||
: spotlightExpandedLayoutMedia(true, spotlight, pip);
|
||||
case "pip":
|
||||
return { type: "pip", edgeToEdge: !desktop, spotlight };
|
||||
}
|
||||
}
|
||||
|
||||
function hasScreenShare(spotlight: MediaViewModel[]): boolean {
|
||||
return spotlight.some((vm) => vm.type === "screen share");
|
||||
}
|
||||
|
||||
function gridLayoutMedia(
|
||||
spotlight: MediaViewModel[],
|
||||
grid: UserMediaViewModel[],
|
||||
): GridLayoutMedia {
|
||||
return {
|
||||
type: "grid",
|
||||
edgeToEdge: false,
|
||||
spotlight: hasScreenShare(spotlight) ? spotlight : undefined,
|
||||
grid,
|
||||
};
|
||||
}
|
||||
|
||||
function oneOnOneDesktopLayoutMedia(
|
||||
media: OneOnOneMedia,
|
||||
): OneOnOneDesktopLayoutMedia {
|
||||
return media.remote.type === "ringing"
|
||||
? {
|
||||
type: "one-on-one-desktop",
|
||||
edgeToEdge: false,
|
||||
spotlight: media.local,
|
||||
pip: media.remote,
|
||||
}
|
||||
: {
|
||||
type: "one-on-one-desktop",
|
||||
edgeToEdge: false,
|
||||
spotlight: media.remote,
|
||||
pip: media.local,
|
||||
};
|
||||
}
|
||||
|
||||
function spotlightExpandedLayoutMedia(
|
||||
edgeToEdge: boolean,
|
||||
spotlight: MediaViewModel[],
|
||||
pip: UserMediaViewModel | undefined,
|
||||
): SpotlightExpandedLayoutMedia {
|
||||
return { type: "spotlight-expanded", edgeToEdge, spotlight, pip };
|
||||
}
|
||||
|
||||
function spotlightLandscapeLayoutMedia(
|
||||
edgeToEdge: boolean,
|
||||
spotlight: MediaViewModel[],
|
||||
grid: UserMediaViewModel[],
|
||||
): SpotlightLandscapeLayoutMedia {
|
||||
return { type: "spotlight-landscape", edgeToEdge, spotlight, grid };
|
||||
}
|
||||
|
||||
function oneOnOneMobileLayoutMedia(
|
||||
media: OneOnOneMedia,
|
||||
localVideoEnabled: boolean,
|
||||
): OneOnOneMobileLayoutMedia {
|
||||
return {
|
||||
type: "one-on-one-mobile",
|
||||
edgeToEdge: true,
|
||||
spotlight: media.remote,
|
||||
pip: localVideoEnabled ? media.local : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function spotlightPortraitLayoutMedia(
|
||||
spotlight: MediaViewModel[],
|
||||
grid: UserMediaViewModel[],
|
||||
): SpotlightPortraitLayoutMedia {
|
||||
return { type: "spotlight-portrait", edgeToEdge: false, spotlight, grid };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,12 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { chooseSpotlightSpeaker, computeSpotlight } from "./layoutMedia";
|
||||
import {
|
||||
chooseSpotlightSpeaker,
|
||||
computeLayoutMedia,
|
||||
computeSpotlight,
|
||||
type LayoutMediaInputs,
|
||||
} from "./layoutMedia";
|
||||
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel";
|
||||
import { type RemoteUserMediaViewModel } from "./media/RemoteUserMediaViewModel";
|
||||
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel";
|
||||
@@ -110,3 +115,106 @@ describe("computeSpotlight", () => {
|
||||
).toEqual({ spotlight: [local], pip: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeLayoutMedia", () => {
|
||||
const base: LayoutMediaInputs = {
|
||||
windowMode: "normal",
|
||||
layoutMode: "grid",
|
||||
spotlightExpanded: false,
|
||||
oneOnOne: null,
|
||||
localVideoEnabled: true,
|
||||
spotlight: [alice],
|
||||
grid: [local, alice, bob],
|
||||
pip: local,
|
||||
desktop: false,
|
||||
};
|
||||
const layout = (
|
||||
overrides: Partial<LayoutMediaInputs>,
|
||||
): ReturnType<typeof computeLayoutMedia> =>
|
||||
computeLayoutMedia({ ...base, ...overrides });
|
||||
|
||||
it("uses a plain grid unless a screen share needs the spotlight", () => {
|
||||
expect(layout({})).toEqual({
|
||||
type: "grid",
|
||||
edgeToEdge: false,
|
||||
spotlight: undefined,
|
||||
grid: base.grid,
|
||||
});
|
||||
expect(layout({ spotlight: [screenShare] }).spotlight).toEqual([
|
||||
screenShare,
|
||||
]);
|
||||
});
|
||||
|
||||
it("switches between landscape and expanded spotlight", () => {
|
||||
expect(layout({ layoutMode: "spotlight" })).toMatchObject({
|
||||
type: "spotlight-landscape",
|
||||
edgeToEdge: false,
|
||||
});
|
||||
expect(
|
||||
layout({ layoutMode: "spotlight", spotlightExpanded: true }),
|
||||
).toEqual({
|
||||
type: "spotlight-expanded",
|
||||
edgeToEdge: false,
|
||||
spotlight: [alice],
|
||||
pip: local,
|
||||
});
|
||||
});
|
||||
|
||||
it("puts the remote user in the desktop one-on-one spotlight, or the local user when ringing", () => {
|
||||
expect(layout({ oneOnOne: { local, remote: alice } })).toEqual({
|
||||
type: "one-on-one-desktop",
|
||||
edgeToEdge: false,
|
||||
spotlight: alice,
|
||||
pip: local,
|
||||
});
|
||||
expect(layout({ oneOnOne: { local, remote: ringing } })).toMatchObject({
|
||||
spotlight: local,
|
||||
pip: ringing,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a mobile one-on-one layout in narrow and flat windows, hiding the PiP without video", () => {
|
||||
const oneOnOne = { local, remote: alice };
|
||||
expect(layout({ windowMode: "narrow", oneOnOne })).toEqual({
|
||||
type: "one-on-one-mobile",
|
||||
edgeToEdge: true,
|
||||
spotlight: alice,
|
||||
pip: local,
|
||||
});
|
||||
expect(
|
||||
layout({ windowMode: "flat", oneOnOne, localVideoEnabled: false }),
|
||||
).toMatchObject({ type: "one-on-one-mobile", pip: undefined });
|
||||
});
|
||||
|
||||
it("uses a portrait spotlight in narrow windows for big calls or screen shares", () => {
|
||||
expect(layout({ windowMode: "narrow" }).type).toBe("grid");
|
||||
expect(
|
||||
layout({ windowMode: "narrow", grid: [local, alice, bob, carol] }).type,
|
||||
).toBe("spotlight-portrait");
|
||||
expect(
|
||||
layout({ windowMode: "narrow", spotlight: [screenShare] }).type,
|
||||
).toBe("spotlight-portrait");
|
||||
});
|
||||
|
||||
it("uses edge-to-edge spotlight layouts in flat windows", () => {
|
||||
expect(layout({ windowMode: "flat" })).toMatchObject({
|
||||
type: "spotlight-landscape",
|
||||
edgeToEdge: true,
|
||||
});
|
||||
expect(
|
||||
layout({ windowMode: "flat", layoutMode: "spotlight" }),
|
||||
).toMatchObject({
|
||||
type: "spotlight-expanded",
|
||||
edgeToEdge: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the PiP layout, edge to edge except on desktop", () => {
|
||||
expect(layout({ windowMode: "pip" })).toEqual({
|
||||
type: "pip",
|
||||
edgeToEdge: true,
|
||||
spotlight: [alice],
|
||||
});
|
||||
expect(layout({ windowMode: "pip", desktop: true }).edgeToEdge).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,29 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type LayoutMode } from "./LayoutSwitchViewModel.ts";
|
||||
import {
|
||||
type GridLayoutMedia,
|
||||
type LayoutMedia,
|
||||
type OneOnOneDesktopLayoutMedia,
|
||||
type OneOnOneMobileLayoutMedia,
|
||||
type SpotlightExpandedLayoutMedia,
|
||||
type SpotlightLandscapeLayoutMedia,
|
||||
type SpotlightPortraitLayoutMedia,
|
||||
type WindowMode,
|
||||
} from "./layout-types.ts";
|
||||
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
|
||||
import { type MediaViewModel } from "./media/MediaViewModel.ts";
|
||||
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts";
|
||||
import { type ScreenShareViewModel } from "./media/ScreenShareViewModel.ts";
|
||||
import { type UserMediaViewModel } from "./media/UserMediaViewModel.ts";
|
||||
|
||||
/**
|
||||
* This is the number of participants that we think constitutes a "small" call
|
||||
* on mobile. No spotlight tile should be shown below this threshold.
|
||||
*/
|
||||
const smallMobileCallThreshold = 3;
|
||||
|
||||
/**
|
||||
* Picks who to spotlight based on who is speaking, preferring the previous
|
||||
* pick so that the spotlight doesn't flip eagerly between speakers.
|
||||
@@ -66,3 +84,133 @@ export function computeSpotlight({
|
||||
pip: localPip === speaker ? undefined : localPip,
|
||||
};
|
||||
}
|
||||
|
||||
export interface OneOnOneMedia {
|
||||
local: LocalUserMediaViewModel;
|
||||
remote: UserMediaViewModel | RingingMediaViewModel;
|
||||
}
|
||||
|
||||
export interface LayoutMediaInputs {
|
||||
windowMode: WindowMode;
|
||||
/** The layout chosen with the layout switch. */
|
||||
layoutMode: LayoutMode;
|
||||
spotlightExpanded: boolean;
|
||||
/** Set when the call qualifies for a one-on-one layout. */
|
||||
oneOnOne: OneOnOneMedia | null;
|
||||
localVideoEnabled: boolean;
|
||||
spotlight: MediaViewModel[];
|
||||
grid: UserMediaViewModel[];
|
||||
pip: UserMediaViewModel | undefined;
|
||||
desktop: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides which layout to use and which media goes where in it.
|
||||
*/
|
||||
export function computeLayoutMedia({
|
||||
windowMode,
|
||||
layoutMode,
|
||||
spotlightExpanded,
|
||||
oneOnOne,
|
||||
localVideoEnabled,
|
||||
spotlight,
|
||||
grid,
|
||||
pip,
|
||||
desktop,
|
||||
}: LayoutMediaInputs): LayoutMedia {
|
||||
switch (windowMode) {
|
||||
case "normal":
|
||||
if (layoutMode === "grid")
|
||||
return oneOnOne === null
|
||||
? gridLayoutMedia(spotlight, grid)
|
||||
: oneOnOneDesktopLayoutMedia(oneOnOne);
|
||||
return spotlightExpanded
|
||||
? spotlightExpandedLayoutMedia(false, spotlight, pip)
|
||||
: spotlightLandscapeLayoutMedia(false, spotlight, grid);
|
||||
case "narrow":
|
||||
if (oneOnOne !== null)
|
||||
return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled);
|
||||
return grid.length > smallMobileCallThreshold || hasScreenShare(spotlight)
|
||||
? spotlightPortraitLayoutMedia(spotlight, grid)
|
||||
: gridLayoutMedia(spotlight, grid);
|
||||
case "flat":
|
||||
if (oneOnOne !== null)
|
||||
return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled);
|
||||
// Yes, grid mode actually gets you a "spotlight" layout in this window
|
||||
// mode.
|
||||
return layoutMode === "grid"
|
||||
? spotlightLandscapeLayoutMedia(true, spotlight, grid)
|
||||
: spotlightExpandedLayoutMedia(true, spotlight, pip);
|
||||
case "pip":
|
||||
return { type: "pip", edgeToEdge: !desktop, spotlight };
|
||||
}
|
||||
}
|
||||
|
||||
function hasScreenShare(spotlight: MediaViewModel[]): boolean {
|
||||
return spotlight.some((vm) => vm.type === "screen share");
|
||||
}
|
||||
|
||||
function gridLayoutMedia(
|
||||
spotlight: MediaViewModel[],
|
||||
grid: UserMediaViewModel[],
|
||||
): GridLayoutMedia {
|
||||
return {
|
||||
type: "grid",
|
||||
edgeToEdge: false,
|
||||
spotlight: hasScreenShare(spotlight) ? spotlight : undefined,
|
||||
grid,
|
||||
};
|
||||
}
|
||||
|
||||
function oneOnOneDesktopLayoutMedia(
|
||||
media: OneOnOneMedia,
|
||||
): OneOnOneDesktopLayoutMedia {
|
||||
return media.remote.type === "ringing"
|
||||
? {
|
||||
type: "one-on-one-desktop",
|
||||
edgeToEdge: false,
|
||||
spotlight: media.local,
|
||||
pip: media.remote,
|
||||
}
|
||||
: {
|
||||
type: "one-on-one-desktop",
|
||||
edgeToEdge: false,
|
||||
spotlight: media.remote,
|
||||
pip: media.local,
|
||||
};
|
||||
}
|
||||
|
||||
function spotlightExpandedLayoutMedia(
|
||||
edgeToEdge: boolean,
|
||||
spotlight: MediaViewModel[],
|
||||
pip: UserMediaViewModel | undefined,
|
||||
): SpotlightExpandedLayoutMedia {
|
||||
return { type: "spotlight-expanded", edgeToEdge, spotlight, pip };
|
||||
}
|
||||
|
||||
function spotlightLandscapeLayoutMedia(
|
||||
edgeToEdge: boolean,
|
||||
spotlight: MediaViewModel[],
|
||||
grid: UserMediaViewModel[],
|
||||
): SpotlightLandscapeLayoutMedia {
|
||||
return { type: "spotlight-landscape", edgeToEdge, spotlight, grid };
|
||||
}
|
||||
|
||||
function oneOnOneMobileLayoutMedia(
|
||||
media: OneOnOneMedia,
|
||||
localVideoEnabled: boolean,
|
||||
): OneOnOneMobileLayoutMedia {
|
||||
return {
|
||||
type: "one-on-one-mobile",
|
||||
edgeToEdge: true,
|
||||
spotlight: media.remote,
|
||||
pip: localVideoEnabled ? media.local : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function spotlightPortraitLayoutMedia(
|
||||
spotlight: MediaViewModel[],
|
||||
grid: UserMediaViewModel[],
|
||||
): SpotlightPortraitLayoutMedia {
|
||||
return { type: "spotlight-portrait", edgeToEdge: false, spotlight, grid };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user