mirror of
https://github.com/element-hq/element-call.git
synced 2026-10-06 16:47:18 +00:00
Extract the spotlight speaker choice into a pure function
This commit is contained in:
@@ -104,6 +104,7 @@ import {
|
||||
type SpotlightPortraitLayoutMedia,
|
||||
type WindowMode,
|
||||
} from "../layout-types.ts";
|
||||
import { chooseSpotlightSpeaker } from "../layoutMedia.ts";
|
||||
import { ElementCallError, UnknownCallError } from "../../utils/errors.ts";
|
||||
import { type Epoch, type ObservableScope } from "../ObservableScope.ts";
|
||||
import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts";
|
||||
@@ -975,33 +976,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),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
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 } from "./layoutMedia";
|
||||
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel";
|
||||
import { type RemoteUserMediaViewModel } from "./media/RemoteUserMediaViewModel";
|
||||
|
||||
// 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;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user