mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-10 23:25:52 +00:00
Merge pull request #4300 from element-hq/johannes/pure-layout-functions
Make the layout decisions pure / testable functions
This commit is contained in:
@@ -30,7 +30,6 @@ import {
|
||||
scan,
|
||||
startWith,
|
||||
Subject,
|
||||
switchAll,
|
||||
switchMap,
|
||||
switchScan,
|
||||
take,
|
||||
@@ -83,6 +82,7 @@ import {
|
||||
type ReactionOption,
|
||||
} from "../../reactions";
|
||||
import { shallowEquals as shallowArrayEquals } from "../../utils/array";
|
||||
import { shallowEquals as shallowObjectEquals } from "../../utils/object";
|
||||
import { type MediaDevices } from "../MediaDevices";
|
||||
import { constant, type Behavior } from "../Behavior";
|
||||
import { E2eeType } from "../../e2ee/e2eeType";
|
||||
@@ -92,17 +92,17 @@ import { HeaderStyle, type UrlParams } from "../../UrlParams";
|
||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||
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,
|
||||
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";
|
||||
@@ -263,21 +263,10 @@ 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;
|
||||
|
||||
/**
|
||||
* The general shape of the space the call is drawn in. Called a window because
|
||||
* that is what it is in the standalone app; for a component it is the container
|
||||
* the host gave us, which may be a small corner of a large window.
|
||||
*/
|
||||
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
|
||||
|
||||
interface LayoutScanState {
|
||||
layout: Layout | null;
|
||||
overflowing: boolean;
|
||||
@@ -981,33 +970,16 @@ export function createCallViewModel$(
|
||||
mediaItems.length === 0
|
||||
? of([])
|
||||
: combineLatest(
|
||||
mediaItems.map((m) =>
|
||||
m.speaking$.pipe(map((s) => [m, s] as const)),
|
||||
mediaItems.map((media) =>
|
||||
media.speaking$.pipe(map((speaking) => ({ media, speaking }))),
|
||||
),
|
||||
),
|
||||
),
|
||||
scan<
|
||||
(readonly [UserMediaViewModel, boolean])[],
|
||||
{ media: UserMediaViewModel; speaking: boolean }[],
|
||||
UserMediaViewModel | undefined,
|
||||
undefined
|
||||
>((prev, mediaItems) => {
|
||||
// Only remote users that are still in the call should be sticky
|
||||
const [stickyMedia, stickySpeaking] =
|
||||
(!prev?.local && mediaItems.find(([m]) => m === prev)) || [];
|
||||
// Decide who to spotlight:
|
||||
// If the previous speaker is still speaking, stick with them rather
|
||||
// than switching eagerly to someone else
|
||||
return stickySpeaking
|
||||
? stickyMedia!
|
||||
: // Otherwise, select any remote user who is speaking
|
||||
(mediaItems.find(([m, s]) => !m.local && s)?.[0] ??
|
||||
// Otherwise, stick with the person who was last speaking
|
||||
stickyMedia ??
|
||||
// Otherwise, spotlight an arbitrary remote user
|
||||
mediaItems.find(([m]) => !m.local)?.[0] ??
|
||||
// Otherwise, spotlight the local user
|
||||
mediaItems.find(([m]) => m.local)?.[0]);
|
||||
}, undefined),
|
||||
>(chooseSpotlightSpeaker, undefined),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1051,33 +1023,12 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
|
||||
const spotlightAndPip$ = scope.behavior<{
|
||||
spotlight: MediaViewModel[];
|
||||
pip$: Observable<UserMediaViewModel | undefined>;
|
||||
}>(
|
||||
ringingMedia$.pipe(
|
||||
switchMap((ringingMedia) => {
|
||||
if (ringingMedia !== null)
|
||||
return of({ spotlight: [ringingMedia], pip$: localUserMediaForPip$ });
|
||||
|
||||
return screenShares$.pipe(
|
||||
switchMap((screenShares) => {
|
||||
if (screenShares.length > 0)
|
||||
return of({ spotlight: screenShares, pip$: spotlightSpeaker$ });
|
||||
|
||||
return spotlightSpeaker$.pipe(
|
||||
map((speaker) => ({
|
||||
spotlight: speaker ? [speaker] : [],
|
||||
// Hide PiP if redundant (i.e. if local user is already in spotlight)
|
||||
pip$: localUserMediaForPip$.pipe(
|
||||
map((m) => (m === speaker ? undefined : m)),
|
||||
),
|
||||
})),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const spotlightAndPip$ = scope.behavior(
|
||||
combineLatest(
|
||||
[ringingMedia$, screenShares$, spotlightSpeaker$, localUserMediaForPip$],
|
||||
(ringing, screenShares, speaker, localPip) =>
|
||||
computeSpotlight({ ringing, screenShares, speaker, localPip }),
|
||||
).pipe(distinctUntilChanged(shallowObjectEquals)),
|
||||
);
|
||||
|
||||
const spotlight$ = scope.behavior<MediaViewModel[]>(
|
||||
@@ -1135,56 +1086,7 @@ export function createCallViewModel$(
|
||||
hasRemoteScreenShares$,
|
||||
);
|
||||
|
||||
const gridLayoutMedia$: Observable<GridLayoutMedia> = combineLatest(
|
||||
[grid$, spotlight$],
|
||||
(grid, spotlight) => ({
|
||||
type: "grid",
|
||||
edgeToEdge: false,
|
||||
spotlight: spotlight.some((vm) => vm.type === "screen share")
|
||||
? spotlight
|
||||
: undefined,
|
||||
grid,
|
||||
}),
|
||||
);
|
||||
|
||||
const spotlightLandscapeLayoutMedia$ = (
|
||||
edgeToEdge: boolean,
|
||||
): Observable<SpotlightLandscapeLayoutMedia> =>
|
||||
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
|
||||
type: "spotlight-landscape",
|
||||
edgeToEdge,
|
||||
spotlight,
|
||||
grid,
|
||||
}));
|
||||
|
||||
const spotlightPortraitLayoutMedia$: Observable<SpotlightPortraitLayoutMedia> =
|
||||
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
|
||||
type: "spotlight-portrait",
|
||||
edgeToEdge: false,
|
||||
spotlight,
|
||||
grid,
|
||||
}));
|
||||
|
||||
const spotlightExpandedLayoutMedia$ = (
|
||||
edgeToEdge: boolean,
|
||||
): Observable<SpotlightExpandedLayoutMedia> =>
|
||||
spotlightAndPip$.pipe(
|
||||
switchMap(({ spotlight, pip$ }) =>
|
||||
pip$.pipe(
|
||||
map((pip) => ({
|
||||
type: "spotlight-expanded" as const,
|
||||
edgeToEdge,
|
||||
spotlight,
|
||||
pip: pip ?? undefined,
|
||||
})),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const oneOnOneLayoutMedia$: Behavior<{
|
||||
local: LocalUserMediaViewModel;
|
||||
remote: UserMediaViewModel | RingingMediaViewModel;
|
||||
} | null> = scope.behavior(
|
||||
const oneOnOneLayoutMedia$: Behavior<OneOnOneMedia | null> = scope.behavior(
|
||||
combineLatest([userMedia$, screenShares$]).pipe(
|
||||
switchMap(([userMedia, screenShares]) => {
|
||||
// One-on-one layout only supports 2 user media, no screen shares
|
||||
@@ -1222,47 +1124,12 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
|
||||
const oneOnOneDesktopLayoutMedia$: Observable<OneOnOneDesktopLayoutMedia | null> =
|
||||
const localVideoEnabled$ = scope.behavior<boolean>(
|
||||
oneOnOneLayoutMedia$.pipe(
|
||||
map((media) => {
|
||||
if (media === null) return null;
|
||||
return media.remote.type === "ringing"
|
||||
? {
|
||||
type: "one-on-one-desktop" as const,
|
||||
edgeToEdge: false,
|
||||
spotlight: media.local,
|
||||
pip: media.remote,
|
||||
}
|
||||
: {
|
||||
type: "one-on-one-desktop" as const,
|
||||
edgeToEdge: false,
|
||||
spotlight: media.remote,
|
||||
pip: media.local,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const oneOnOneMobileLayoutMedia$: Observable<OneOnOneMobileLayoutMedia | null> =
|
||||
oneOnOneLayoutMedia$.pipe(
|
||||
switchMap((media) => {
|
||||
if (media === null) return of(null);
|
||||
return media.local.videoEnabled$.pipe(
|
||||
map((videoEnabled) => ({
|
||||
type: "one-on-one-mobile" as const,
|
||||
edgeToEdge: true as const,
|
||||
spotlight: media.remote,
|
||||
pip: videoEnabled ? media.local : undefined,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const pipLayoutMedia$: Observable<LayoutMedia> = spotlight$.pipe(
|
||||
map((spotlight) => ({
|
||||
type: "pip",
|
||||
edgeToEdge: platform !== "desktop",
|
||||
spotlight,
|
||||
})),
|
||||
switchMap((media) =>
|
||||
media === null ? of(false) : media.local.videoEnabled$,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
spotlight$
|
||||
@@ -1297,66 +1164,38 @@ export function createCallViewModel$(
|
||||
* The media to be used to produce a layout.
|
||||
*/
|
||||
const layoutMedia$ = scope.behavior<LayoutMedia>(
|
||||
windowMode$.pipe(
|
||||
switchMap((windowMode) => {
|
||||
switch (windowMode) {
|
||||
case "normal":
|
||||
return layoutSwitchVm.layout$.pipe(
|
||||
switchMap((layout) => {
|
||||
switch (layout) {
|
||||
case "grid":
|
||||
return oneOnOneDesktopLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne),
|
||||
),
|
||||
);
|
||||
case "spotlight":
|
||||
return spotlightExpanded$.pipe(
|
||||
switchMap((expanded) =>
|
||||
expanded
|
||||
? spotlightExpandedLayoutMedia$(false)
|
||||
: spotlightLandscapeLayoutMedia$(false),
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
case "narrow":
|
||||
return oneOnOneMobileLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null
|
||||
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
|
||||
grid.length > smallMobileCallThreshold ||
|
||||
spotlight.some((vm) => vm.type === "screen share")
|
||||
? spotlightPortraitLayoutMedia$
|
||||
: gridLayoutMedia$,
|
||||
).pipe(switchAll())
|
||||
: of(oneOnOne),
|
||||
),
|
||||
);
|
||||
case "flat":
|
||||
return oneOnOneMobileLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null
|
||||
? layoutSwitchVm.layout$.pipe(
|
||||
switchMap((layout) => {
|
||||
switch (layout) {
|
||||
case "grid":
|
||||
// Yes, grid mode actually gets you a "spotlight" layout in
|
||||
// this window mode.
|
||||
return spotlightLandscapeLayoutMedia$(true);
|
||||
case "spotlight":
|
||||
return spotlightExpandedLayoutMedia$(true);
|
||||
}
|
||||
}),
|
||||
)
|
||||
: of(oneOnOne),
|
||||
),
|
||||
);
|
||||
case "pip":
|
||||
return pipLayoutMedia$;
|
||||
}
|
||||
}),
|
||||
// We deliberately don't use combineLatest here. Several of the inputs
|
||||
// below derive from the same source. So one upstream change cascades
|
||||
// through them one at a time. combineLatest would see each step of the
|
||||
// cascade and emit layouts built from inputs that never coexisted.
|
||||
// Instead, we treat the emissions as a mere "something changed" signal
|
||||
// and read every input's current value. The derived behaviors were
|
||||
// subscribed before this one and a BehaviorSubject updates its value
|
||||
// before notifying. So by the time the first signal arrives every value
|
||||
// is already consistent. The duplicate signals from the rest of the
|
||||
// cascade are dropped by distinctUntilChanged.
|
||||
merge(
|
||||
windowMode$,
|
||||
layoutSwitchVm.layout$,
|
||||
spotlightExpanded$,
|
||||
oneOnOneLayoutMedia$,
|
||||
localVideoEnabled$,
|
||||
spotlightAndPip$,
|
||||
grid$,
|
||||
).pipe(
|
||||
map(() =>
|
||||
computeLayoutMedia({
|
||||
windowMode: windowMode$.value,
|
||||
layoutMode: layoutSwitchVm.layout$.value,
|
||||
spotlightExpanded: spotlightExpanded$.value,
|
||||
oneOnOne: oneOnOneLayoutMedia$.value,
|
||||
localVideoEnabled: localVideoEnabled$.value,
|
||||
...spotlightAndPip$.value,
|
||||
grid: grid$.value,
|
||||
desktop: platform === "desktop",
|
||||
}),
|
||||
),
|
||||
distinctUntilChanged(shallowObjectEquals<LayoutMedia>),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1657,7 +1496,7 @@ export function createCallViewModel$(
|
||||
layoutInternals$.pipe(
|
||||
map(({ layout }) => layout),
|
||||
// Drop redundant layout updates before they would hit React.
|
||||
distinctUntilChanged<Layout>(layoutShallowEquals),
|
||||
distinctUntilChanged(shallowObjectEquals<Layout>),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
switchAll,
|
||||
} from "rxjs";
|
||||
|
||||
import { type WindowMode } from "./CallViewModel/CallViewModel.ts";
|
||||
import { type WindowMode } from "./layout-types.ts";
|
||||
import { constant, type Behavior } from "./Behavior.ts";
|
||||
import { type ObservableScope } from "./ObservableScope.ts";
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, expect } from "vitest";
|
||||
import {
|
||||
type Alignment,
|
||||
layoutShallowEquals,
|
||||
type Layout,
|
||||
} from "./layout-types";
|
||||
import {
|
||||
type SpotlightTileViewModel,
|
||||
type GridTileViewModel,
|
||||
} from "./TileViewModel";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
const spotlightTile = {} as unknown as SpotlightTileViewModel;
|
||||
const gridTile = {} as unknown as GridTileViewModel;
|
||||
const pipAlignment$ = new BehaviorSubject<Alignment>({
|
||||
inline: "end",
|
||||
block: "end",
|
||||
});
|
||||
|
||||
const spotlightExpanded: Layout = {
|
||||
type: "spotlight-expanded",
|
||||
spotlight: spotlightTile,
|
||||
pipAlignment$,
|
||||
};
|
||||
|
||||
const spotlightPortrait: Layout = {
|
||||
type: "spotlight-portrait",
|
||||
spotlight: spotlightTile,
|
||||
grid: [gridTile],
|
||||
setVisibleTiles: () => {},
|
||||
};
|
||||
|
||||
test("layoutShallowEquals considers a layout to be equal to its shallow clone", () =>
|
||||
expect(layoutShallowEquals(spotlightExpanded, { ...spotlightExpanded })).toBe(
|
||||
true,
|
||||
));
|
||||
|
||||
test("layoutShallowEquals detects a missing key", () => {
|
||||
expect(
|
||||
layoutShallowEquals(spotlightExpanded, {
|
||||
...spotlightExpanded,
|
||||
pip: gridTile,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
layoutShallowEquals(
|
||||
{ ...spotlightExpanded, pip: gridTile },
|
||||
spotlightExpanded,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("layoutShallowEquals considers grid arrays with equal contents to be equal", () =>
|
||||
expect(
|
||||
layoutShallowEquals(spotlightPortrait, {
|
||||
...spotlightPortrait,
|
||||
grid: [...spotlightPortrait.grid],
|
||||
}),
|
||||
).toBe(true));
|
||||
|
||||
test("layoutShallowEquals detects grid arrays with different contents", () =>
|
||||
expect(
|
||||
layoutShallowEquals(spotlightPortrait, {
|
||||
...spotlightPortrait,
|
||||
grid: [...spotlightPortrait.grid, gridTile],
|
||||
}),
|
||||
).toBe(false));
|
||||
@@ -16,7 +16,13 @@ import {
|
||||
type SpotlightTileViewModel,
|
||||
} from "./TileViewModel.ts";
|
||||
import { type Behavior } from "./Behavior.ts";
|
||||
import { shallowEquals as arrayShallowEquals } from "../utils/array.ts";
|
||||
|
||||
/**
|
||||
* The general shape of the space the call is drawn in. Called a window because
|
||||
* that is what it is in the standalone app; for a component it is the container
|
||||
* the host gave us, which may be a small corner of a large window.
|
||||
*/
|
||||
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
|
||||
|
||||
export interface GridLayoutMedia {
|
||||
type: "grid";
|
||||
@@ -141,30 +147,3 @@ export type Layout =
|
||||
| OneOnOneDesktopLayout
|
||||
| OneOnOneMobileLayout
|
||||
| PipLayout;
|
||||
|
||||
/**
|
||||
* Tests whether the top-level properties and array elements of layout `a` are
|
||||
* equal to those of layout `b`. Useful for deduping redundant layout updates.
|
||||
*/
|
||||
export function layoutShallowEquals(a: Layout, b: Layout): boolean {
|
||||
// If a and b have the same number of keys and every key in a is also in b,
|
||||
// then they have the same keys.
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
|
||||
for (const key of aKeys) {
|
||||
if (!(key in b)) return false;
|
||||
|
||||
// Now check that they have the same values.
|
||||
const aValue = (a as any)[key];
|
||||
const bValue = (b as any)[key];
|
||||
if (Array.isArray(aValue) && Array.isArray(bValue)) {
|
||||
// Special case for arrays so we can detect when the grid tiles arrays are
|
||||
// essentially the same.
|
||||
if (!arrayShallowEquals(aValue, bValue)) return false;
|
||||
} else if (aValue !== bValue) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
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";
|
||||
import { type ScreenShareViewModel } from "./media/ScreenShareViewModel";
|
||||
|
||||
// The layout functions only look at `type` and `local`, so stubs suffice
|
||||
const local = { type: "user", local: true } as LocalUserMediaViewModel;
|
||||
const alice = { type: "user", local: false } as RemoteUserMediaViewModel;
|
||||
const bob = { type: "user", local: false } as RemoteUserMediaViewModel;
|
||||
const carol = { type: "user", local: false } as RemoteUserMediaViewModel;
|
||||
const screenShare = {
|
||||
type: "screen share",
|
||||
local: false,
|
||||
} as ScreenShareViewModel;
|
||||
const ringing = { type: "ringing" } as RingingMediaViewModel;
|
||||
|
||||
describe("chooseSpotlightSpeaker", () => {
|
||||
const items = (
|
||||
...speaking: boolean[]
|
||||
): {
|
||||
media: RemoteUserMediaViewModel | LocalUserMediaViewModel;
|
||||
speaking: boolean;
|
||||
}[] =>
|
||||
[local, alice, bob].map((media, i) => ({
|
||||
media,
|
||||
speaking: speaking[i] ?? false,
|
||||
}));
|
||||
|
||||
it("prefers a remote speaker over the local user", () => {
|
||||
expect(chooseSpotlightSpeaker(undefined, items(true, false, true))).toBe(
|
||||
bob,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to any remote user, then the local user", () => {
|
||||
expect(chooseSpotlightSpeaker(undefined, items())).toBe(alice);
|
||||
expect(
|
||||
chooseSpotlightSpeaker(undefined, [{ media: local, speaking: false }]),
|
||||
).toBe(local);
|
||||
expect(chooseSpotlightSpeaker(undefined, [])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sticks with the previous speaker while they keep speaking", () => {
|
||||
expect(chooseSpotlightSpeaker(bob, items(false, true, true))).toBe(bob);
|
||||
});
|
||||
|
||||
it("sticks with the previous speaker when nobody is speaking", () => {
|
||||
expect(chooseSpotlightSpeaker(bob, items())).toBe(bob);
|
||||
});
|
||||
|
||||
it("switches when someone else speaks and the previous speaker is silent", () => {
|
||||
expect(chooseSpotlightSpeaker(bob, items(false, true, false))).toBe(alice);
|
||||
});
|
||||
|
||||
it("forgets a previous speaker who left, and never sticks to the local user", () => {
|
||||
expect(chooseSpotlightSpeaker(carol, items())).toBe(alice);
|
||||
expect(chooseSpotlightSpeaker(local, items())).toBe(alice);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSpotlight", () => {
|
||||
it("spotlights ringing media with the local user in the PiP", () => {
|
||||
expect(
|
||||
computeSpotlight({
|
||||
ringing,
|
||||
screenShares: [screenShare],
|
||||
speaker: alice,
|
||||
localPip: local,
|
||||
}),
|
||||
).toEqual({ spotlight: [ringing], pip: local });
|
||||
});
|
||||
|
||||
it("spotlights screen shares with the speaker in the PiP", () => {
|
||||
expect(
|
||||
computeSpotlight({
|
||||
ringing: null,
|
||||
screenShares: [screenShare],
|
||||
speaker: alice,
|
||||
localPip: local,
|
||||
}),
|
||||
).toEqual({ spotlight: [screenShare], pip: alice });
|
||||
});
|
||||
|
||||
it("otherwise spotlights the speaker and hides a redundant PiP", () => {
|
||||
expect(
|
||||
computeSpotlight({
|
||||
ringing: null,
|
||||
screenShares: [],
|
||||
speaker: alice,
|
||||
localPip: local,
|
||||
}),
|
||||
).toEqual({ spotlight: [alice], pip: local });
|
||||
expect(
|
||||
computeSpotlight({
|
||||
ringing: null,
|
||||
screenShares: [],
|
||||
speaker: local,
|
||||
localPip: local,
|
||||
}),
|
||||
).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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
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.
|
||||
*
|
||||
* @param prev The previous pick, if any.
|
||||
* @param items All user media in the call along with whether each is speaking.
|
||||
*/
|
||||
export function chooseSpotlightSpeaker<M extends { local: boolean }>(
|
||||
prev: M | undefined,
|
||||
items: readonly { media: M; speaking: boolean }[],
|
||||
): M | undefined {
|
||||
// Only remote users that are still in the call should be sticky
|
||||
const sticky = items.find((i) => i.media === prev && !i.media.local);
|
||||
// If the previous speaker is still speaking, stick with them
|
||||
if (sticky?.speaking) return sticky.media;
|
||||
return (
|
||||
// Otherwise, select any remote user who is speaking
|
||||
items.find((i) => !i.media.local && i.speaking)?.media ??
|
||||
// Otherwise, stick with the person who was last speaking
|
||||
sticky?.media ??
|
||||
// Otherwise, spotlight an arbitrary remote user
|
||||
items.find((i) => !i.media.local)?.media ??
|
||||
// Otherwise, spotlight the local user
|
||||
items.find((i) => i.media.local)?.media
|
||||
);
|
||||
}
|
||||
|
||||
export interface SpotlightInputs {
|
||||
ringing: RingingMediaViewModel | null;
|
||||
screenShares: ScreenShareViewModel[];
|
||||
speaker: UserMediaViewModel | undefined;
|
||||
/** Local user media suitable for showing in a PiP, if any. */
|
||||
localPip: UserMediaViewModel | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides what goes in the spotlight, and what goes in the picture-in-picture
|
||||
* tile of the expanded spotlight layout.
|
||||
*/
|
||||
export function computeSpotlight({
|
||||
ringing,
|
||||
screenShares,
|
||||
speaker,
|
||||
localPip,
|
||||
}: SpotlightInputs): {
|
||||
spotlight: MediaViewModel[];
|
||||
pip: UserMediaViewModel | undefined;
|
||||
} {
|
||||
if (ringing !== null) return { spotlight: [ringing], pip: localPip };
|
||||
if (screenShares.length > 0) return { spotlight: screenShares, pip: speaker };
|
||||
return {
|
||||
spotlight: speaker ? [speaker] : [],
|
||||
// Hide PiP if redundant (i.e. if local user is already in spotlight)
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, expect } from "vitest";
|
||||
import { shallowEquals } from "./object";
|
||||
|
||||
interface Thing {
|
||||
type: string;
|
||||
items: object[];
|
||||
value: number;
|
||||
extra?: object;
|
||||
}
|
||||
|
||||
const item = {};
|
||||
const withArray: Thing = { type: "a", items: [item], value: 1 };
|
||||
|
||||
test("shallowEquals considers an object to be equal to its shallow clone", () =>
|
||||
expect(shallowEquals(withArray, { ...withArray })).toBe(true));
|
||||
|
||||
test("shallowEquals detects a missing key", () => {
|
||||
expect(shallowEquals(withArray, { ...withArray, extra: item })).toBe(false);
|
||||
expect(shallowEquals({ ...withArray, extra: item }, withArray)).toBe(false);
|
||||
});
|
||||
|
||||
test("shallowEquals detects a differing value", () =>
|
||||
expect(shallowEquals(withArray, { ...withArray, value: 2 })).toBe(false));
|
||||
|
||||
test("shallowEquals considers arrays with equal contents to be equal", () =>
|
||||
expect(
|
||||
shallowEquals(withArray, { ...withArray, items: [...withArray.items] }),
|
||||
).toBe(true));
|
||||
|
||||
test("shallowEquals detects arrays with different contents", () =>
|
||||
expect(
|
||||
shallowEquals(withArray, { ...withArray, items: [...withArray.items, {}] }),
|
||||
).toBe(false));
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { shallowEquals as arrayShallowEquals } from "./array.ts";
|
||||
|
||||
/**
|
||||
* Determine whether two objects are equal by shallow comparison of their
|
||||
* top-level properties. Array-valued properties are themselves compared
|
||||
* shallowly, so two objects holding distinct arrays with the same elements are
|
||||
* considered equal.
|
||||
*/
|
||||
export function shallowEquals<T extends object>(
|
||||
a: Readonly<T>,
|
||||
b: Readonly<T>,
|
||||
): boolean {
|
||||
// If a and b have the same number of keys and every key in a is also in b,
|
||||
// then they have the same keys.
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
|
||||
for (const key of aKeys) {
|
||||
if (!(key in b)) return false;
|
||||
|
||||
// Now check that they have the same values.
|
||||
const aValue = (a as any)[key];
|
||||
const bValue = (b as any)[key];
|
||||
if (Array.isArray(aValue) && Array.isArray(bValue)) {
|
||||
if (!arrayShallowEquals(aValue, bValue)) return false;
|
||||
} else if (aValue !== bValue) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user