Extract the layout decision into a pure function

This commit is contained in:
Johannes Marbach
2026-09-28 17:36:24 +02:00
parent 33cfd2d8ad
commit 6600c891f3
3 changed files with 263 additions and 143 deletions
+6 -142
View File
@@ -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 };
}
+109 -1
View File
@@ -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);
});
});
+148
View File
@@ -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 };
}