Merge branch 'livekit' into toger5/connection-state-refactor
This commit is contained in:
@@ -47,7 +47,7 @@ services:
|
|||||||
- ecbackend
|
- ecbackend
|
||||||
|
|
||||||
livekit:
|
livekit:
|
||||||
image: livekit/livekit-server:latest
|
image: livekit/livekit-server:v1.9.4
|
||||||
pull_policy: always
|
pull_policy: always
|
||||||
hostname: livekit-sfu
|
hostname: livekit-sfu
|
||||||
command: --dev --config /etc/livekit.yaml
|
command: --dev --config /etc/livekit.yaml
|
||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
- ecbackend
|
- ecbackend
|
||||||
|
|
||||||
livekit-1:
|
livekit-1:
|
||||||
image: livekit/livekit-server:latest
|
image: livekit/livekit-server:v1.9.4
|
||||||
pull_policy: always
|
pull_policy: always
|
||||||
hostname: livekit-sfu-1
|
hostname: livekit-sfu-1
|
||||||
command: --dev --config /etc/livekit.yaml
|
command: --dev --config /etc/livekit.yaml
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export default defineConfig({
|
|||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: "chromium",
|
name: "chromium",
|
||||||
|
testIgnore: "**/mobile/**",
|
||||||
use: {
|
use: {
|
||||||
...devices["Desktop Chrome"],
|
...devices["Desktop Chrome"],
|
||||||
permissions: [
|
permissions: [
|
||||||
@@ -56,9 +57,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: "firefox",
|
name: "firefox",
|
||||||
|
testIgnore: "**/mobile/**",
|
||||||
use: {
|
use: {
|
||||||
...devices["Desktop Firefox"],
|
...devices["Desktop Firefox"],
|
||||||
ignoreHTTPSErrors: true,
|
ignoreHTTPSErrors: true,
|
||||||
@@ -70,6 +71,27 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "mobile",
|
||||||
|
testMatch: "**/mobile/**",
|
||||||
|
use: {
|
||||||
|
...devices["Pixel 7"],
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
permissions: [
|
||||||
|
"clipboard-write",
|
||||||
|
"clipboard-read",
|
||||||
|
"microphone",
|
||||||
|
"camera",
|
||||||
|
],
|
||||||
|
launchOptions: {
|
||||||
|
args: [
|
||||||
|
"--use-fake-ui-for-media-stream",
|
||||||
|
"--use-fake-device-for-media-stream",
|
||||||
|
"--mute-audio",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// No safari for now, until I find a solution to fix `Not allowed to request resource` due to calling
|
// No safari for now, until I find a solution to fix `Not allowed to request resource` due to calling
|
||||||
// clear http to the homeserver
|
// clear http to the homeserver
|
||||||
|
|||||||
73
playwright/fixtures/fixture-mobile-create.ts
Normal file
73
playwright/fixtures/fixture-mobile-create.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Browser, type Page, test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
export interface MobileCreateFixtures {
|
||||||
|
asMobile: {
|
||||||
|
creatorPage: Page;
|
||||||
|
inviteLink: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mobileTest = test.extend<MobileCreateFixtures>({
|
||||||
|
asMobile: async ({ browser }, pUse) => {
|
||||||
|
const fixtures = await createCallAndInvite(browser);
|
||||||
|
await pUse({
|
||||||
|
creatorPage: fixtures.page,
|
||||||
|
inviteLink: fixtures.inviteLink,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a call and generate an invite link
|
||||||
|
*/
|
||||||
|
async function createCallAndInvite(
|
||||||
|
browser: Browser,
|
||||||
|
): Promise<{ page: Page; inviteLink: string }> {
|
||||||
|
const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||||
|
const creatorPage = await creatorContext.newPage();
|
||||||
|
|
||||||
|
await creatorPage.goto("/");
|
||||||
|
|
||||||
|
// ========
|
||||||
|
// ARRANGE: The first user creates a call as guest, join it, then click the invite button to copy the invite link
|
||||||
|
// ========
|
||||||
|
await creatorPage.getByTestId("home_callName").click();
|
||||||
|
await creatorPage.getByTestId("home_callName").fill("Welcome");
|
||||||
|
await creatorPage.getByTestId("home_displayName").click();
|
||||||
|
await creatorPage.getByTestId("home_displayName").fill("Inviter");
|
||||||
|
await creatorPage.getByTestId("home_go").click();
|
||||||
|
await expect(creatorPage.locator("video")).toBeVisible();
|
||||||
|
|
||||||
|
await creatorPage
|
||||||
|
.getByRole("button", { name: "Continue in browser" })
|
||||||
|
.click();
|
||||||
|
// join
|
||||||
|
await creatorPage.getByTestId("lobby_joinCall").click();
|
||||||
|
|
||||||
|
// Get the invite link
|
||||||
|
await creatorPage.getByRole("button", { name: "Invite" }).click();
|
||||||
|
await expect(
|
||||||
|
creatorPage.getByRole("heading", { name: "Invite to this call" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(creatorPage.getByRole("img", { name: "QR Code" })).toBeVisible();
|
||||||
|
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
|
||||||
|
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
|
||||||
|
await creatorPage.getByTestId("modal_inviteLink").click();
|
||||||
|
|
||||||
|
const inviteLink = (await creatorPage.evaluate(
|
||||||
|
"navigator.clipboard.readText()",
|
||||||
|
)) as string;
|
||||||
|
expect(inviteLink).toContain("room/#/");
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: creatorPage,
|
||||||
|
inviteLink,
|
||||||
|
};
|
||||||
|
}
|
||||||
115
playwright/mobile/create-call-mobile.spec.ts
Normal file
115
playwright/mobile/create-call-mobile.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 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 { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mobileTest } from "../fixtures/fixture-mobile-create.ts";
|
||||||
|
|
||||||
|
test("@mobile Start a new call then leave and show the feedback screen", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await page.getByTestId("home_callName").click();
|
||||||
|
await page.getByTestId("home_callName").fill("HelloCall");
|
||||||
|
await page.getByTestId("home_displayName").click();
|
||||||
|
await page.getByTestId("home_displayName").fill("John Doe");
|
||||||
|
await page.getByTestId("home_go").click();
|
||||||
|
|
||||||
|
// await page.pause();
|
||||||
|
await expect(page.locator("video")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Continue in browser" }).click();
|
||||||
|
// Join the call
|
||||||
|
await page.getByTestId("lobby_joinCall").click();
|
||||||
|
|
||||||
|
// Ensure that the call is connected
|
||||||
|
await page
|
||||||
|
.locator("div")
|
||||||
|
.filter({ hasText: /^HelloCall$/ })
|
||||||
|
.click();
|
||||||
|
// Check the number of participants
|
||||||
|
await expect(page.locator("div").filter({ hasText: /^1$/ })).toBeVisible();
|
||||||
|
// The tooltip with the name should be visible
|
||||||
|
await expect(page.getByTestId("name_tag")).toContainText("John Doe");
|
||||||
|
|
||||||
|
// leave the call
|
||||||
|
await page.getByTestId("incall_leave").click();
|
||||||
|
await expect(page.getByRole("heading")).toContainText(
|
||||||
|
"John Doe, your call has ended. How did it go?",
|
||||||
|
);
|
||||||
|
await expect(page.getByRole("main")).toContainText(
|
||||||
|
"Why not finish by setting up a password to keep your account?",
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("link", { name: "Not now, return to home screen" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
mobileTest(
|
||||||
|
"Test earpiece overlay in controlledAudioDevices mode",
|
||||||
|
async ({ asMobile, browser }) => {
|
||||||
|
test.slow(); // Triples the timeout
|
||||||
|
const { creatorPage, inviteLink } = asMobile;
|
||||||
|
|
||||||
|
// ========
|
||||||
|
// ACT: The other user use the invite link to join the call as a guest
|
||||||
|
// ========
|
||||||
|
const guestInviteeContext = await browser.newContext({
|
||||||
|
reducedMotion: "reduce",
|
||||||
|
});
|
||||||
|
const guestPage = await guestInviteeContext.newPage();
|
||||||
|
await guestPage.goto(inviteLink + "&controlledAudioDevices=true");
|
||||||
|
|
||||||
|
await guestPage
|
||||||
|
.getByRole("button", { name: "Continue in browser" })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await guestPage.getByTestId("joincall_displayName").fill("Invitee");
|
||||||
|
await expect(guestPage.getByTestId("joincall_joincall")).toBeVisible();
|
||||||
|
await guestPage.getByTestId("joincall_joincall").click();
|
||||||
|
await guestPage.getByTestId("lobby_joinCall").click();
|
||||||
|
|
||||||
|
// ========
|
||||||
|
// ASSERT: check that there are two members in the call
|
||||||
|
// ========
|
||||||
|
|
||||||
|
// There should be two participants now
|
||||||
|
await expect(
|
||||||
|
guestPage.getByTestId("roomHeader_participants_count"),
|
||||||
|
).toContainText("2");
|
||||||
|
expect(await guestPage.getByTestId("videoTile").count()).toBe(2);
|
||||||
|
|
||||||
|
// Same in creator page
|
||||||
|
await expect(
|
||||||
|
creatorPage.getByTestId("roomHeader_participants_count"),
|
||||||
|
).toContainText("2");
|
||||||
|
expect(await creatorPage.getByTestId("videoTile").count()).toBe(2);
|
||||||
|
|
||||||
|
// TEST: control audio devices from the invitee page
|
||||||
|
|
||||||
|
await guestPage.evaluate(() => {
|
||||||
|
window.controls.setAvailableAudioDevices([
|
||||||
|
{ id: "speaker", name: "Speaker", isSpeaker: true },
|
||||||
|
{ id: "earpiece", name: "Handset", isEarpiece: true },
|
||||||
|
{ id: "headphones", name: "Headphones" },
|
||||||
|
]);
|
||||||
|
window.controls.setAudioDevice("earpiece");
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
guestPage.getByRole("heading", { name: "Handset Mode" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
guestPage.getByRole("button", { name: "Back to Speaker Mode" }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Should auto-mute the video when earpiece is selected
|
||||||
|
await expect(guestPage.getByTestId("incall_videomute")).toBeDisabled();
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -332,6 +332,42 @@ describe("UrlParams", () => {
|
|||||||
expect(computeUrlParams("?intent=join_existing").skipLobby).toBe(false);
|
expect(computeUrlParams("?intent=join_existing").skipLobby).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("noiseSuppression", () => {
|
||||||
|
it("defaults to true", () => {
|
||||||
|
expect(computeUrlParams().noiseSuppression).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is parsed", () => {
|
||||||
|
expect(
|
||||||
|
computeUrlParams("?intent=start_call&noiseSuppression=true")
|
||||||
|
.noiseSuppression,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
computeUrlParams("?intent=start_call&noiseSuppression&bar=foo")
|
||||||
|
.noiseSuppression,
|
||||||
|
).toBe(true);
|
||||||
|
expect(computeUrlParams("?noiseSuppression=false").noiseSuppression).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("echoCancellation", () => {
|
||||||
|
it("defaults to true", () => {
|
||||||
|
expect(computeUrlParams().echoCancellation).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is parsed", () => {
|
||||||
|
expect(computeUrlParams("?echoCancellation=true").echoCancellation).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(computeUrlParams("?echoCancellation=false").echoCancellation).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("header", () => {
|
describe("header", () => {
|
||||||
it("uses header if provided", () => {
|
it("uses header if provided", () => {
|
||||||
expect(computeUrlParams("?header=app_bar&hideHeader=true").header).toBe(
|
expect(computeUrlParams("?header=app_bar&hideHeader=true").header).toBe(
|
||||||
|
|||||||
@@ -233,6 +233,17 @@ export interface UrlConfiguration {
|
|||||||
*/
|
*/
|
||||||
waitForCallPickup: boolean;
|
waitForCallPickup: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to enable echo cancellation for audio capture.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
echoCancellation?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to enable noise suppression for audio capture.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
noiseSuppression?: boolean;
|
||||||
|
|
||||||
callIntent?: RTCCallIntent;
|
callIntent?: RTCCallIntent;
|
||||||
}
|
}
|
||||||
interface IntentAndPlatformDerivedConfiguration {
|
interface IntentAndPlatformDerivedConfiguration {
|
||||||
@@ -525,6 +536,8 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
|||||||
]),
|
]),
|
||||||
waitForCallPickup: parser.getFlag("waitForCallPickup"),
|
waitForCallPickup: parser.getFlag("waitForCallPickup"),
|
||||||
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
|
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
|
||||||
|
noiseSuppression: parser.getFlagParam("noiseSuppression", true),
|
||||||
|
echoCancellation: parser.getFlagParam("echoCancellation", true),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Log the final configuration for debugging purposes.
|
// Log the final configuration for debugging purposes.
|
||||||
|
|||||||
@@ -247,9 +247,8 @@ export class PosthogAnalytics {
|
|||||||
// wins, and the first writer will send tracking with an ID that doesn't match the one on the server
|
// wins, and the first writer will send tracking with an ID that doesn't match the one on the server
|
||||||
// until the next time account data is refreshed and this function is called (most likely on next
|
// until the next time account data is refreshed and this function is called (most likely on next
|
||||||
// page load). This will happen pretty infrequently, so we can tolerate the possibility.
|
// page load). This will happen pretty infrequently, so we can tolerate the possibility.
|
||||||
const accountDataAnalyticsId = analyticsIdGenerator();
|
analyticsID = analyticsIdGenerator();
|
||||||
await this.setAccountAnalyticsId(accountDataAnalyticsId);
|
await this.setAccountAnalyticsId(analyticsID);
|
||||||
analyticsID = await this.hashedEcAnalyticsId(accountDataAnalyticsId);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// The above could fail due to network requests, but not essential to starting the application,
|
// The above could fail due to network requests, but not essential to starting the application,
|
||||||
@@ -270,37 +269,14 @@ export class PosthogAnalytics {
|
|||||||
|
|
||||||
private async getAnalyticsId(): Promise<string | null> {
|
private async getAnalyticsId(): Promise<string | null> {
|
||||||
const client: MatrixClient = window.matrixclient;
|
const client: MatrixClient = window.matrixclient;
|
||||||
let accountAnalyticsId: string | null;
|
|
||||||
if (widget) {
|
if (widget) {
|
||||||
accountAnalyticsId = getUrlParams().posthogUserId;
|
return getUrlParams().posthogUserId;
|
||||||
} else {
|
} else {
|
||||||
const accountData = await client.getAccountDataFromServer(
|
const accountData = await client.getAccountDataFromServer(
|
||||||
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
|
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
|
||||||
);
|
);
|
||||||
accountAnalyticsId = accountData?.id ?? null;
|
return accountData?.id ?? null;
|
||||||
}
|
}
|
||||||
if (accountAnalyticsId) {
|
|
||||||
// we dont just use the element web analytics ID because that would allow to associate
|
|
||||||
// users between the two posthog instances. By using a hash from the username and the element web analytics id
|
|
||||||
// it is not possible to conclude the element web posthog user id from the element call user id and vice versa.
|
|
||||||
return await this.hashedEcAnalyticsId(accountAnalyticsId);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async hashedEcAnalyticsId(
|
|
||||||
accountAnalyticsId: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const client: MatrixClient = window.matrixclient;
|
|
||||||
const posthogIdMaterial = "ec" + accountAnalyticsId + client.getUserId();
|
|
||||||
const bufferForPosthogId = await crypto.subtle.digest(
|
|
||||||
"sha-256",
|
|
||||||
new TextEncoder().encode(posthogIdMaterial),
|
|
||||||
);
|
|
||||||
const view = new Int32Array(bufferForPosthogId);
|
|
||||||
return Array.from(view)
|
|
||||||
.map((b) => Math.abs(b).toString(16).padStart(2, "0"))
|
|
||||||
.join("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setAccountAnalyticsId(analyticsID: string): Promise<void> {
|
private async setAccountAnalyticsId(analyticsID: string): Promise<void> {
|
||||||
|
|||||||
@@ -7,34 +7,17 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--cpd-space-2x);
|
gap: var(--cpd-space-2x);
|
||||||
}
|
transition: opacity 200ms;
|
||||||
|
|
||||||
@keyframes fade-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay[data-show="true"] {
|
.overlay[data-show="true"] {
|
||||||
animation: fade-in 200ms;
|
opacity: 1;
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-out {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay[data-show="false"] {
|
.overlay[data-show="false"] {
|
||||||
animation: fade-out 130ms forwards;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
transition-duration: 130ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay::before {
|
.overlay::before {
|
||||||
|
|||||||
@@ -502,6 +502,48 @@ describe("CallViewModel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("layout reacts to window size", () => {
|
||||||
|
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||||
|
const windowSizeInputMarbles = "abc";
|
||||||
|
const expectedLayoutMarbles = " abc";
|
||||||
|
withCallViewModel(
|
||||||
|
{
|
||||||
|
remoteParticipants$: constant([aliceParticipant]),
|
||||||
|
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
|
||||||
|
windowSize$: behavior(windowSizeInputMarbles, {
|
||||||
|
a: { width: 300, height: 600 }, // Start very narrow, like a phone
|
||||||
|
b: { width: 1000, height: 800 }, // Go to normal desktop window size
|
||||||
|
c: { width: 200, height: 180 }, // Go to PiP size
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
(vm) => {
|
||||||
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
|
expectedLayoutMarbles,
|
||||||
|
{
|
||||||
|
a: {
|
||||||
|
// This is the expected one-on-one layout for a narrow window
|
||||||
|
type: "spotlight-expanded",
|
||||||
|
spotlight: [`${aliceId}:0`],
|
||||||
|
pip: `${localId}:0`,
|
||||||
|
},
|
||||||
|
b: {
|
||||||
|
// In a larger window, expect the normal one-on-one layout
|
||||||
|
type: "one-on-one",
|
||||||
|
local: `${localId}:0`,
|
||||||
|
remote: `${aliceId}:0`,
|
||||||
|
},
|
||||||
|
c: {
|
||||||
|
// In a PiP-sized window, we of course expect a PiP layout
|
||||||
|
type: "pip",
|
||||||
|
spotlight: [`${aliceId}:0`],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("spotlight speakers swap places", () => {
|
test("spotlight speakers swap places", () => {
|
||||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||||
// Go immediately into spotlight mode for the test
|
// Go immediately into spotlight mode for the test
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { type Room as MatrixRoom } from "matrix-js-sdk";
|
import { type Room as MatrixRoom } from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
|
||||||
combineLatest,
|
combineLatest,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
filter,
|
filter,
|
||||||
@@ -125,6 +124,7 @@ import {
|
|||||||
} from "./remoteMembers/MatrixMemberMetadata.ts";
|
} from "./remoteMembers/MatrixMemberMetadata.ts";
|
||||||
import { Publisher } from "./localMember/Publisher.ts";
|
import { Publisher } from "./localMember/Publisher.ts";
|
||||||
import { type Connection } from "./remoteMembers/Connection.ts";
|
import { type Connection } from "./remoteMembers/Connection.ts";
|
||||||
|
import { createLayoutModeSwitch } from "./LayoutSwitch.ts";
|
||||||
|
|
||||||
const logger = rootLogger.getChild("[CallViewModel]");
|
const logger = rootLogger.getChild("[CallViewModel]");
|
||||||
//TODO
|
//TODO
|
||||||
@@ -146,6 +146,8 @@ export interface CallViewModelOptions {
|
|||||||
livekitRoomFactory?: (options?: RoomOptions) => LivekitRoom;
|
livekitRoomFactory?: (options?: RoomOptions) => LivekitRoom;
|
||||||
/** Optional behavior overriding the local connection state, mainly for testing purposes. */
|
/** Optional behavior overriding the local connection state, mainly for testing purposes. */
|
||||||
connectionState$?: Behavior<ConnectionState>;
|
connectionState$?: Behavior<ConnectionState>;
|
||||||
|
/** Optional behavior overriding the computed window size, mainly for testing purposes. */
|
||||||
|
windowSize$?: Behavior<{ width: number; height: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not play any sounds if the participant count has exceeded this
|
// Do not play any sounds if the participant count has exceeded this
|
||||||
@@ -342,6 +344,7 @@ export interface CallViewModel {
|
|||||||
// DISCUSSION own membership manager ALSO this probably can be simplifis
|
// DISCUSSION own membership manager ALSO this probably can be simplifis
|
||||||
reconnecting$: Behavior<boolean>;
|
reconnecting$: Behavior<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A view model providing all the application logic needed to show the in-call
|
* A view model providing all the application logic needed to show the in-call
|
||||||
* UI (may eventually be expanded to cover the lobby and feedback screens in the
|
* UI (may eventually be expanded to cover the lobby and feedback screens in the
|
||||||
@@ -412,6 +415,8 @@ export function createCallViewModel$(
|
|||||||
livekitKeyProvider,
|
livekitKeyProvider,
|
||||||
getUrlParams().controlledAudioDevices,
|
getUrlParams().controlledAudioDevices,
|
||||||
options.livekitRoomFactory,
|
options.livekitRoomFactory,
|
||||||
|
getUrlParams().echoCancellation,
|
||||||
|
getUrlParams().noiseSuppression,
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectionManager = createConnectionManager$({
|
const connectionManager = createConnectionManager$({
|
||||||
@@ -948,11 +953,19 @@ export function createCallViewModel$(
|
|||||||
|
|
||||||
const pipEnabled$ = scope.behavior(setPipEnabled$, false);
|
const pipEnabled$ = scope.behavior(setPipEnabled$, false);
|
||||||
|
|
||||||
|
const windowSize$ =
|
||||||
|
options.windowSize$ ??
|
||||||
|
scope.behavior<{ width: number; height: number }>(
|
||||||
|
fromEvent(window, "resize").pipe(
|
||||||
|
startWith(null),
|
||||||
|
map(() => ({ width: window.innerWidth, height: window.innerHeight })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A guess at what the window's mode should be based on its size and shape.
|
||||||
const naturalWindowMode$ = scope.behavior<WindowMode>(
|
const naturalWindowMode$ = scope.behavior<WindowMode>(
|
||||||
fromEvent(window, "resize").pipe(
|
windowSize$.pipe(
|
||||||
map(() => {
|
map(({ width, height }) => {
|
||||||
const height = window.innerHeight;
|
|
||||||
const width = window.innerWidth;
|
|
||||||
if (height <= 400 && width <= 340) return "pip";
|
if (height <= 400 && width <= 340) return "pip";
|
||||||
// Our layouts for flat windows are better at adapting to a small width
|
// Our layouts for flat windows are better at adapting to a small width
|
||||||
// than our layouts for narrow windows are at adapting to a small height,
|
// than our layouts for narrow windows are at adapting to a small height,
|
||||||
@@ -962,7 +975,6 @@ export function createCallViewModel$(
|
|||||||
return "normal";
|
return "normal";
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
"normal",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -979,49 +991,11 @@ export function createCallViewModel$(
|
|||||||
spotlightExpandedToggle$.pipe(accumulate(false, (expanded) => !expanded)),
|
spotlightExpandedToggle$.pipe(accumulate(false, (expanded) => !expanded)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const gridModeUserSelection$ = new BehaviorSubject<GridMode>("grid");
|
const { setGridMode, gridMode$ } = createLayoutModeSwitch(
|
||||||
|
scope,
|
||||||
// Callback to set the grid mode desired by the user.
|
windowMode$,
|
||||||
// Notice that this is only a preference, the actual grid mode can be overridden
|
hasRemoteScreenShares$,
|
||||||
// if there is a remote screen share active.
|
);
|
||||||
const setGridMode = (value: GridMode): void => {
|
|
||||||
gridModeUserSelection$.next(value);
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* The layout mode of the media tile grid.
|
|
||||||
*/
|
|
||||||
const gridMode$ =
|
|
||||||
// If the user hasn't selected spotlight and somebody starts screen sharing,
|
|
||||||
// automatically switch to spotlight mode and reset when screen sharing ends
|
|
||||||
scope.behavior<GridMode>(
|
|
||||||
gridModeUserSelection$.pipe(
|
|
||||||
switchMap((userSelection): Observable<GridMode> => {
|
|
||||||
if (userSelection === "spotlight") {
|
|
||||||
// If already in spotlight mode, stay there
|
|
||||||
return of("spotlight");
|
|
||||||
} else {
|
|
||||||
// Otherwise, check if there is a remote screen share active
|
|
||||||
// as this could force us into spotlight mode.
|
|
||||||
return combineLatest([hasRemoteScreenShares$, windowMode$]).pipe(
|
|
||||||
map(([hasScreenShares, windowMode]): GridMode => {
|
|
||||||
const isFlatMode = windowMode === "flat";
|
|
||||||
if (hasScreenShares || isFlatMode) {
|
|
||||||
logger.debug(
|
|
||||||
`Forcing spotlight mode, hasScreenShares=${hasScreenShares} windowMode=${windowMode}`,
|
|
||||||
);
|
|
||||||
// override to spotlight mode
|
|
||||||
return "spotlight";
|
|
||||||
} else {
|
|
||||||
// respect user choice
|
|
||||||
return "grid";
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
"grid",
|
|
||||||
);
|
|
||||||
|
|
||||||
const gridLayoutMedia$: Observable<GridLayoutMedia> = combineLatest(
|
const gridLayoutMedia$: Observable<GridLayoutMedia> = combineLatest(
|
||||||
[grid$, spotlight$],
|
[grid$, spotlight$],
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export interface CallViewModelInputs {
|
|||||||
speaking: Map<Participant, Observable<boolean>>;
|
speaking: Map<Participant, Observable<boolean>>;
|
||||||
mediaDevices: MediaDevices;
|
mediaDevices: MediaDevices;
|
||||||
initialSyncState: SyncState;
|
initialSyncState: SyncState;
|
||||||
|
windowSize$: Behavior<{ width: number; height: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const localParticipant = mockLocalParticipant({ identity: "" });
|
const localParticipant = mockLocalParticipant({ identity: "" });
|
||||||
@@ -89,6 +90,7 @@ export function withCallViewModel(
|
|||||||
speaking = new Map(),
|
speaking = new Map(),
|
||||||
mediaDevices = mockMediaDevices({}),
|
mediaDevices = mockMediaDevices({}),
|
||||||
initialSyncState = SyncState.Syncing,
|
initialSyncState = SyncState.Syncing,
|
||||||
|
windowSize$ = constant({ width: 1000, height: 800 }),
|
||||||
}: Partial<CallViewModelInputs> = {},
|
}: Partial<CallViewModelInputs> = {},
|
||||||
continuation: (
|
continuation: (
|
||||||
vm: CallViewModel,
|
vm: CallViewModel,
|
||||||
@@ -173,6 +175,7 @@ export function withCallViewModel(
|
|||||||
setE2EEEnabled: async () => Promise.resolve(),
|
setE2EEEnabled: async () => Promise.resolve(),
|
||||||
}),
|
}),
|
||||||
connectionState$,
|
connectionState$,
|
||||||
|
windowSize$,
|
||||||
},
|
},
|
||||||
raisedHands$,
|
raisedHands$,
|
||||||
reactions$,
|
reactions$,
|
||||||
|
|||||||
202
src/state/CallViewModel/LayoutSwitch.test.ts
Normal file
202
src/state/CallViewModel/LayoutSwitch.test.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 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 { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { firstValueFrom, of } from "rxjs";
|
||||||
|
|
||||||
|
import { createLayoutModeSwitch } from "./LayoutSwitch";
|
||||||
|
import { ObservableScope } from "../ObservableScope";
|
||||||
|
import { constant } from "../Behavior";
|
||||||
|
import { withTestScheduler } from "../../utils/test";
|
||||||
|
|
||||||
|
let scope: ObservableScope;
|
||||||
|
beforeEach(() => {
|
||||||
|
scope = new ObservableScope();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
scope.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Default mode", () => {
|
||||||
|
test("Should be in grid layout by default", async () => {
|
||||||
|
const { gridMode$ } = createLayoutModeSwitch(
|
||||||
|
scope,
|
||||||
|
constant("normal"),
|
||||||
|
of(false),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mode = await firstValueFrom(gridMode$);
|
||||||
|
expect(mode).toBe("grid");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Should switch to spotlight mode when window mode is flat", async () => {
|
||||||
|
const { gridMode$ } = createLayoutModeSwitch(
|
||||||
|
scope,
|
||||||
|
constant("flat"),
|
||||||
|
of(false),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mode = await firstValueFrom(gridMode$);
|
||||||
|
expect(mode).toBe("spotlight");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Should allow switching modes manually", () => {
|
||||||
|
withTestScheduler(({ cold, behavior, expectObservable, schedule }): void => {
|
||||||
|
const { gridMode$, setGridMode } = createLayoutModeSwitch(
|
||||||
|
scope,
|
||||||
|
behavior("n", { n: "normal" }),
|
||||||
|
cold("f", { f: false, t: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
schedule("--sgs", {
|
||||||
|
s: () => setGridMode("spotlight"),
|
||||||
|
g: () => setGridMode("grid"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(gridMode$).toBe("g-sgs", {
|
||||||
|
g: "grid",
|
||||||
|
s: "spotlight",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Should switch to spotlight mode when there is a remote screen share", () => {
|
||||||
|
withTestScheduler(({ cold, behavior, expectObservable }): void => {
|
||||||
|
const shareMarble = "f--t";
|
||||||
|
const gridsMarble = "g--s";
|
||||||
|
const { gridMode$ } = createLayoutModeSwitch(
|
||||||
|
scope,
|
||||||
|
behavior("n", { n: "normal" }),
|
||||||
|
cold(shareMarble, { f: false, t: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectObservable(gridMode$).toBe(gridsMarble, {
|
||||||
|
g: "grid",
|
||||||
|
s: "spotlight",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Can manually force grid when there is a screenshare", () => {
|
||||||
|
withTestScheduler(({ cold, behavior, expectObservable, schedule }): void => {
|
||||||
|
const { gridMode$, setGridMode } = createLayoutModeSwitch(
|
||||||
|
scope,
|
||||||
|
behavior("n", { n: "normal" }),
|
||||||
|
cold("-ft", { f: false, t: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
schedule("---g", {
|
||||||
|
g: () => setGridMode("grid"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(gridMode$).toBe("ggsg", {
|
||||||
|
g: "grid",
|
||||||
|
s: "spotlight",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Should auto-switch after manually selected grid", () => {
|
||||||
|
withTestScheduler(({ cold, behavior, expectObservable, schedule }): void => {
|
||||||
|
const { gridMode$, setGridMode } = createLayoutModeSwitch(
|
||||||
|
scope,
|
||||||
|
behavior("n", { n: "normal" }),
|
||||||
|
// Two screenshares will happen in sequence
|
||||||
|
cold("-ft-ft", { f: false, t: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// There was a screen-share that forced spotlight, then
|
||||||
|
// the user manually switch back to grid
|
||||||
|
schedule("---g", {
|
||||||
|
g: () => setGridMode("grid"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we did want to respect manual selection, the expectation would be:
|
||||||
|
// const expectation = "ggsg";
|
||||||
|
const expectation = "ggsg-s";
|
||||||
|
|
||||||
|
expectObservable(gridMode$).toBe(expectation, {
|
||||||
|
g: "grid",
|
||||||
|
s: "spotlight",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Should switch back to grid mode when the remote screen share ends", () => {
|
||||||
|
withTestScheduler(({ cold, behavior, expectObservable }): void => {
|
||||||
|
const shareMarble = "f--t--f-";
|
||||||
|
const gridsMarble = "g--s--g-";
|
||||||
|
const { gridMode$ } = createLayoutModeSwitch(
|
||||||
|
scope,
|
||||||
|
behavior("n", { n: "normal" }),
|
||||||
|
cold(shareMarble, { f: false, t: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectObservable(gridMode$).toBe(gridsMarble, {
|
||||||
|
g: "grid",
|
||||||
|
s: "spotlight",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can auto-switch to spotlight again after first screen share ends", () => {
|
||||||
|
withTestScheduler(({ cold, behavior, expectObservable }): void => {
|
||||||
|
const shareMarble = "ftft";
|
||||||
|
const gridsMarble = "gsgs";
|
||||||
|
const { gridMode$ } = createLayoutModeSwitch(
|
||||||
|
scope,
|
||||||
|
behavior("n", { n: "normal" }),
|
||||||
|
cold(shareMarble, { f: false, t: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectObservable(gridMode$).toBe(gridsMarble, {
|
||||||
|
g: "grid",
|
||||||
|
s: "spotlight",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can switch manually to grid after screen share while manually in spotlight", () => {
|
||||||
|
withTestScheduler(({ cold, behavior, schedule, expectObservable }): void => {
|
||||||
|
// Initially, no one is sharing. Then the user manually switches to
|
||||||
|
// spotlight. After a screen share starts, the user manually switches to
|
||||||
|
// grid.
|
||||||
|
const shareMarbles = " f-t-";
|
||||||
|
const setModeMarbles = "-s-g";
|
||||||
|
const expectation = " gs-g";
|
||||||
|
const { gridMode$, setGridMode } = createLayoutModeSwitch(
|
||||||
|
scope,
|
||||||
|
behavior("n", { n: "normal" }),
|
||||||
|
cold(shareMarbles, { f: false, t: true }),
|
||||||
|
);
|
||||||
|
schedule(setModeMarbles, {
|
||||||
|
g: () => setGridMode("grid"),
|
||||||
|
s: () => setGridMode("spotlight"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(gridMode$).toBe(expectation, {
|
||||||
|
g: "grid",
|
||||||
|
s: "spotlight",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Should auto-switch to spotlight when in flat window mode", () => {
|
||||||
|
withTestScheduler(({ cold, behavior, expectObservable }): void => {
|
||||||
|
const { gridMode$ } = createLayoutModeSwitch(
|
||||||
|
scope,
|
||||||
|
behavior("naf", { n: "normal", a: "narrow", f: "flat" }),
|
||||||
|
cold("f", { f: false, t: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectObservable(gridMode$).toBe("g-s-", {
|
||||||
|
g: "grid",
|
||||||
|
s: "spotlight",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
130
src/state/CallViewModel/LayoutSwitch.ts
Normal file
130
src/state/CallViewModel/LayoutSwitch.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 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 {
|
||||||
|
BehaviorSubject,
|
||||||
|
combineLatest,
|
||||||
|
map,
|
||||||
|
type Observable,
|
||||||
|
scan,
|
||||||
|
} from "rxjs";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { type GridMode, type WindowMode } from "./CallViewModel.ts";
|
||||||
|
import { type Behavior } from "../Behavior.ts";
|
||||||
|
import { type ObservableScope } from "../ObservableScope.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a layout mode switch that allows switching between grid and spotlight modes.
|
||||||
|
* The actual layout mode can be overridden to spotlight mode if there is a remote screen share active
|
||||||
|
* or if the window mode is flat.
|
||||||
|
*
|
||||||
|
* @param scope - The observable scope to manage subscriptions.
|
||||||
|
* @param windowMode$ - The current window mode observable.
|
||||||
|
* @param hasRemoteScreenShares$ - An observable indicating if there are remote screen shares active.
|
||||||
|
*/
|
||||||
|
export function createLayoutModeSwitch(
|
||||||
|
scope: ObservableScope,
|
||||||
|
windowMode$: Behavior<WindowMode>,
|
||||||
|
hasRemoteScreenShares$: Observable<boolean>,
|
||||||
|
): {
|
||||||
|
gridMode$: Behavior<GridMode>;
|
||||||
|
setGridMode: (value: GridMode) => void;
|
||||||
|
} {
|
||||||
|
const gridModeUserSelection$ = new BehaviorSubject<GridMode>("grid");
|
||||||
|
|
||||||
|
// Callback to set the grid mode desired by the user.
|
||||||
|
// Notice that this is only a preference, the actual grid mode can be overridden
|
||||||
|
// if there is a remote screen share active.
|
||||||
|
const setGridMode = (value: GridMode): void => {
|
||||||
|
gridModeUserSelection$.next(value);
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* The layout mode of the media tile grid.
|
||||||
|
*/
|
||||||
|
const gridMode$ =
|
||||||
|
// If the user hasn't selected spotlight and somebody starts screen sharing,
|
||||||
|
// automatically switch to spotlight mode and reset when screen sharing ends
|
||||||
|
scope.behavior<GridMode>(
|
||||||
|
combineLatest([
|
||||||
|
gridModeUserSelection$,
|
||||||
|
hasRemoteScreenShares$,
|
||||||
|
windowMode$,
|
||||||
|
]).pipe(
|
||||||
|
// Scan to keep track if we have auto-switched already or not.
|
||||||
|
// To allow the user to override the auto-switch by selecting grid mode again.
|
||||||
|
scan<
|
||||||
|
[GridMode, boolean, WindowMode],
|
||||||
|
{
|
||||||
|
mode: GridMode;
|
||||||
|
/** Remember if the change was user driven or not */
|
||||||
|
hasAutoSwitched: boolean;
|
||||||
|
/** To know if it is new screen share or an already handled */
|
||||||
|
hasScreenShares: boolean;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(prev, [userSelection, hasScreenShares, windowMode]) => {
|
||||||
|
const isFlatMode = windowMode === "flat";
|
||||||
|
|
||||||
|
// Always force spotlight in flat mode, grid layout is not supported
|
||||||
|
// in that mode.
|
||||||
|
// TODO: strange that we do that for flat mode but not for other modes?
|
||||||
|
// TODO: Why is this not handled in layoutMedia$ like other window modes?
|
||||||
|
if (isFlatMode) {
|
||||||
|
logger.debug(`Forcing spotlight mode, windowMode=${windowMode}`);
|
||||||
|
return {
|
||||||
|
mode: "spotlight",
|
||||||
|
hasAutoSwitched: prev.hasAutoSwitched,
|
||||||
|
hasScreenShares,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// User explicitly chose spotlight.
|
||||||
|
// Respect that choice.
|
||||||
|
if (userSelection === "spotlight") {
|
||||||
|
return {
|
||||||
|
mode: "spotlight",
|
||||||
|
hasAutoSwitched: prev.hasAutoSwitched,
|
||||||
|
hasScreenShares,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// User has chosen grid mode. If a screen share starts, we will
|
||||||
|
// auto-switch to spotlight mode for better experience.
|
||||||
|
// But we only do it once, if the user switches back to grid mode,
|
||||||
|
// we respect that choice until they explicitly change it again.
|
||||||
|
const isNewShare = hasScreenShares && !prev.hasScreenShares;
|
||||||
|
if (isNewShare && !prev.hasAutoSwitched) {
|
||||||
|
return {
|
||||||
|
mode: "spotlight",
|
||||||
|
hasAutoSwitched: true,
|
||||||
|
hasScreenShares: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respect user's grid choice
|
||||||
|
// XXX If we want to forbid switching automatically again after we can
|
||||||
|
// return hasAutoSwitched: acc.hasAutoSwitched here instead of setting to false.
|
||||||
|
return {
|
||||||
|
mode: "grid",
|
||||||
|
hasAutoSwitched: false,
|
||||||
|
hasScreenShares,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// initial value
|
||||||
|
{ mode: "grid", hasAutoSwitched: false, hasScreenShares: false },
|
||||||
|
),
|
||||||
|
map(({ mode }) => mode),
|
||||||
|
),
|
||||||
|
"grid",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridMode$,
|
||||||
|
setGridMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -271,12 +271,14 @@ export const createLocalMembership$ = ({
|
|||||||
// - overwrite current publisher
|
// - overwrite current publisher
|
||||||
scope.reconcile(localConnection$, async (connection) => {
|
scope.reconcile(localConnection$, async (connection) => {
|
||||||
if (connection !== null) {
|
if (connection !== null) {
|
||||||
publisher$.next(createPublisherFactory(connection));
|
const publisher = createPublisherFactory(connection);
|
||||||
|
publisher$.next(publisher);
|
||||||
|
// Clean-up callback
|
||||||
|
return Promise.resolve(async (): Promise<void> => {
|
||||||
|
await publisher.stopPublishing();
|
||||||
|
publisher.stopTracks();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return Promise.resolve(async (): Promise<void> => {
|
|
||||||
await publisher$?.value?.stopPublishing();
|
|
||||||
publisher$?.value?.stopTracks();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use reconcile here to not run concurrent createAndSetupTracks calls
|
// Use reconcile here to not run concurrent createAndSetupTracks calls
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import {
|
import {
|
||||||
type E2EEOptions,
|
|
||||||
Room as LivekitRoom,
|
Room as LivekitRoom,
|
||||||
type RoomOptions,
|
type RoomOptions,
|
||||||
type BaseKeyProvider,
|
type BaseKeyProvider,
|
||||||
|
type E2EEManagerOptions,
|
||||||
|
type BaseE2EEManager,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
import E2EEWorker from "livekit-client/e2ee-worker?worker";
|
import E2EEWorker from "livekit-client/e2ee-worker?worker";
|
||||||
@@ -41,8 +42,10 @@ export class ECConnectionFactory implements ConnectionFactory {
|
|||||||
* @param client - The OpenID client parts for authentication, needed to get openID and JWT tokens.
|
* @param client - The OpenID client parts for authentication, needed to get openID and JWT tokens.
|
||||||
* @param devices - Used for video/audio out/in capture options.
|
* @param devices - Used for video/audio out/in capture options.
|
||||||
* @param processorState$ - Effects like background blur (only for publishing connection?)
|
* @param processorState$ - Effects like background blur (only for publishing connection?)
|
||||||
* @param e2eeLivekitOptions - The E2EE options to use for the LiveKit Room.
|
* @param livekitKeyProvider
|
||||||
* @param controlledAudioDevices - Option to indicate whether audio output device is controlled externally (native mobile app).
|
* @param controlledAudioDevices - Option to indicate whether audio output device is controlled externally (native mobile app).
|
||||||
|
* @param echoCancellation - Whether to enable echo cancellation for audio capture.
|
||||||
|
* @param noiseSuppression - Whether to enable noise suppression for audio capture.
|
||||||
* @param livekitRoomFactory - Optional factory function (for testing) to create LivekitRoom instances. If not provided, a default factory is used.
|
* @param livekitRoomFactory - Optional factory function (for testing) to create LivekitRoom instances. If not provided, a default factory is used.
|
||||||
*/
|
*/
|
||||||
public constructor(
|
public constructor(
|
||||||
@@ -52,20 +55,24 @@ export class ECConnectionFactory implements ConnectionFactory {
|
|||||||
livekitKeyProvider: BaseKeyProvider | undefined,
|
livekitKeyProvider: BaseKeyProvider | undefined,
|
||||||
private controlledAudioDevices: boolean,
|
private controlledAudioDevices: boolean,
|
||||||
livekitRoomFactory?: () => LivekitRoom,
|
livekitRoomFactory?: () => LivekitRoom,
|
||||||
|
echoCancellation: boolean = true,
|
||||||
|
noiseSuppression: boolean = true,
|
||||||
) {
|
) {
|
||||||
const defaultFactory = (): LivekitRoom =>
|
const defaultFactory = (): LivekitRoom =>
|
||||||
new LivekitRoom(
|
new LivekitRoom(
|
||||||
generateRoomOption(
|
generateRoomOption({
|
||||||
this.devices,
|
devices: this.devices,
|
||||||
this.processorState$.value,
|
processorState: this.processorState$.value,
|
||||||
livekitKeyProvider && {
|
e2eeLivekitOptions: livekitKeyProvider && {
|
||||||
keyProvider: livekitKeyProvider,
|
keyProvider: livekitKeyProvider,
|
||||||
// It's important that every room use a separate E2EE worker.
|
// It's important that every room use a separate E2EE worker.
|
||||||
// They get confused if given streams from multiple rooms.
|
// They get confused if given streams from multiple rooms.
|
||||||
worker: new E2EEWorker(),
|
worker: new E2EEWorker(),
|
||||||
},
|
},
|
||||||
this.controlledAudioDevices,
|
controlledAudioDevices: this.controlledAudioDevices,
|
||||||
),
|
echoCancellation,
|
||||||
|
noiseSuppression,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory;
|
this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory;
|
||||||
}
|
}
|
||||||
@@ -90,12 +97,24 @@ export class ECConnectionFactory implements ConnectionFactory {
|
|||||||
/**
|
/**
|
||||||
* Generate the initial LiveKit RoomOptions based on the current media devices and processor state.
|
* Generate the initial LiveKit RoomOptions based on the current media devices and processor state.
|
||||||
*/
|
*/
|
||||||
function generateRoomOption(
|
function generateRoomOption({
|
||||||
devices: MediaDevices,
|
devices,
|
||||||
processorState: ProcessorState,
|
processorState,
|
||||||
e2eeLivekitOptions: E2EEOptions | undefined,
|
e2eeLivekitOptions,
|
||||||
controlledAudioDevices: boolean,
|
controlledAudioDevices,
|
||||||
): RoomOptions {
|
echoCancellation,
|
||||||
|
noiseSuppression,
|
||||||
|
}: {
|
||||||
|
devices: MediaDevices;
|
||||||
|
processorState: ProcessorState;
|
||||||
|
e2eeLivekitOptions:
|
||||||
|
| E2EEManagerOptions
|
||||||
|
| { e2eeManager: BaseE2EEManager }
|
||||||
|
| undefined;
|
||||||
|
controlledAudioDevices: boolean;
|
||||||
|
echoCancellation: boolean;
|
||||||
|
noiseSuppression: boolean;
|
||||||
|
}): RoomOptions {
|
||||||
return {
|
return {
|
||||||
...defaultLiveKitOptions,
|
...defaultLiveKitOptions,
|
||||||
videoCaptureDefaults: {
|
videoCaptureDefaults: {
|
||||||
@@ -106,6 +125,8 @@ function generateRoomOption(
|
|||||||
audioCaptureDefaults: {
|
audioCaptureDefaults: {
|
||||||
...defaultLiveKitOptions.audioCaptureDefaults,
|
...defaultLiveKitOptions.audioCaptureDefaults,
|
||||||
deviceId: devices.audioInput.selected$.value?.id,
|
deviceId: devices.audioInput.selected$.value?.id,
|
||||||
|
echoCancellation,
|
||||||
|
noiseSuppression,
|
||||||
},
|
},
|
||||||
audioOutput: {
|
audioOutput: {
|
||||||
// When using controlled audio devices, we don't want to set the
|
// When using controlled audio devices, we don't want to set the
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type LivekitTransport,
|
type LivekitTransport,
|
||||||
type ParticipantId,
|
type ParticipantId,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { BehaviorSubject, combineLatest, map, of, switchMap, tap } from "rxjs";
|
import { combineLatest, map, of, switchMap, tap } from "rxjs";
|
||||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
|
import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
|
||||||
|
|
||||||
@@ -60,11 +60,7 @@ export class ConnectionManagerData {
|
|||||||
transport: LivekitTransport,
|
transport: LivekitTransport,
|
||||||
): (LocalParticipant | RemoteParticipant)[] {
|
): (LocalParticipant | RemoteParticipant)[] {
|
||||||
const key = transport.livekit_service_url + "|" + transport.livekit_alias;
|
const key = transport.livekit_service_url + "|" + transport.livekit_alias;
|
||||||
const existing = this.store.get(key);
|
return this.store.get(key)?.[1] ?? [];
|
||||||
if (existing) {
|
|
||||||
return existing[1];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Get all connections where the given participant is publishing.
|
* Get all connections where the given participant is publishing.
|
||||||
@@ -115,9 +111,6 @@ export function createConnectionManager$({
|
|||||||
logger: parentLogger,
|
logger: parentLogger,
|
||||||
}: Props): IConnectionManager {
|
}: Props): IConnectionManager {
|
||||||
const logger = parentLogger.getChild("[ConnectionManager]");
|
const logger = parentLogger.getChild("[ConnectionManager]");
|
||||||
|
|
||||||
const running$ = new BehaviorSubject(true);
|
|
||||||
scope.onEnd(() => running$.next(false));
|
|
||||||
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
|
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,10 +122,7 @@ export function createConnectionManager$({
|
|||||||
* externally this is modified via `registerTransports()`.
|
* externally this is modified via `registerTransports()`.
|
||||||
*/
|
*/
|
||||||
const transports$ = scope.behavior(
|
const transports$ = scope.behavior(
|
||||||
combineLatest([running$, inputTransports$]).pipe(
|
inputTransports$.pipe(
|
||||||
map(([running, transports]) =>
|
|
||||||
transports.mapInner((transport) => (running ? transport : [])),
|
|
||||||
),
|
|
||||||
map((transports) => transports.mapInner(removeDuplicateTransports)),
|
map((transports) => transports.mapInner(removeDuplicateTransports)),
|
||||||
tap(({ value: transports }) => {
|
tap(({ value: transports }) => {
|
||||||
logger.trace(
|
logger.trace(
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 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 { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { Room as LivekitRoom } from "livekit-client";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
import fetchMock from "fetch-mock";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import EventEmitter from "events";
|
||||||
|
|
||||||
|
import { ObservableScope } from "../../ObservableScope.ts";
|
||||||
|
import { ECConnectionFactory } from "./ConnectionFactory.ts";
|
||||||
|
import type { OpenIDClientParts } from "../../../livekit/openIDSFU.ts";
|
||||||
|
import { exampleTransport, mockMediaDevices } from "../../../utils/test.ts";
|
||||||
|
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
|
||||||
|
import { constant } from "../../Behavior";
|
||||||
|
|
||||||
|
// At the top of your test file, after imports
|
||||||
|
vi.mock("livekit-client", async (importOriginal) => {
|
||||||
|
return {
|
||||||
|
...(await importOriginal()),
|
||||||
|
Room: vi.fn().mockImplementation(function (this: LivekitRoom, options) {
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
return {
|
||||||
|
on: emitter.on.bind(emitter),
|
||||||
|
off: emitter.off.bind(emitter),
|
||||||
|
emit: emitter.emit.bind(emitter),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
remoteParticipants: new Map(),
|
||||||
|
} as unknown as LivekitRoom;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let testScope: ObservableScope;
|
||||||
|
let mockClient: OpenIDClientParts;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testScope = new ObservableScope();
|
||||||
|
mockClient = {
|
||||||
|
getOpenIdToken: vi.fn().mockReturnValue(""),
|
||||||
|
getDeviceId: vi.fn().mockReturnValue("DEV000"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ECConnectionFactory - Audio inputs options", () => {
|
||||||
|
test.each([
|
||||||
|
{ echo: true, noise: true },
|
||||||
|
{ echo: true, noise: false },
|
||||||
|
{ echo: false, noise: true },
|
||||||
|
{ echo: false, noise: false },
|
||||||
|
])(
|
||||||
|
"it sets echoCancellation=$echo and noiseSuppression=$noise based on constructor parameters",
|
||||||
|
({ echo, noise }) => {
|
||||||
|
// test("it sets echoCancellation and noiseSuppression based on constructor parameters", () => {
|
||||||
|
const RoomConstructor = vi.mocked(LivekitRoom);
|
||||||
|
|
||||||
|
const ecConnectionFactory = new ECConnectionFactory(
|
||||||
|
mockClient,
|
||||||
|
mockMediaDevices({}),
|
||||||
|
new BehaviorSubject<ProcessorState>({
|
||||||
|
supported: true,
|
||||||
|
processor: undefined,
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
echo,
|
||||||
|
noise,
|
||||||
|
);
|
||||||
|
ecConnectionFactory.createConnection(exampleTransport, testScope, logger);
|
||||||
|
|
||||||
|
// Check if Room was constructed with expected options
|
||||||
|
expect(RoomConstructor).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
audioCaptureDefaults: expect.objectContaining({
|
||||||
|
echoCancellation: echo,
|
||||||
|
noiseSuppression: noise,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ECConnectionFactory - ControlledAudioDevice", () => {
|
||||||
|
test.each([{ controlled: true }, { controlled: false }])(
|
||||||
|
"it sets controlledAudioDevice=$controlled then uses deviceId accordingly",
|
||||||
|
({ controlled }) => {
|
||||||
|
// test("it sets echoCancellation and noiseSuppression based on constructor parameters", () => {
|
||||||
|
const RoomConstructor = vi.mocked(LivekitRoom);
|
||||||
|
|
||||||
|
const ecConnectionFactory = new ECConnectionFactory(
|
||||||
|
mockClient,
|
||||||
|
mockMediaDevices({
|
||||||
|
audioOutput: {
|
||||||
|
available$: constant(new Map<never, never>()),
|
||||||
|
selected$: constant({ id: "DEV00", virtualEarpiece: false }),
|
||||||
|
select: () => {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new BehaviorSubject<ProcessorState>({
|
||||||
|
supported: true,
|
||||||
|
processor: undefined,
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
controlled,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
ecConnectionFactory.createConnection(exampleTransport, testScope, logger);
|
||||||
|
|
||||||
|
// Check if Room was constructed with expected options
|
||||||
|
expect(RoomConstructor).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
audioOutput: expect.objectContaining({
|
||||||
|
deviceId: controlled ? undefined : "DEV00",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
testScope.end();
|
||||||
|
fetchMock.reset();
|
||||||
|
});
|
||||||
@@ -108,7 +108,7 @@ export function createMatrixLivekitMembers$({
|
|||||||
// Each update where the key of the generator array do not change will result in updates to the `data$` observable in the factory.
|
// Each update where the key of the generator array do not change will result in updates to the `data$` observable in the factory.
|
||||||
(scope, data$, participantId, userId) => {
|
(scope, data$, participantId, userId) => {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Updating data$ for participantId: ${participantId}, userId: ${userId}`,
|
`Generating member for participantId: ${participantId}, userId: ${userId}`,
|
||||||
);
|
);
|
||||||
// will only get called once per `participantId, userId` pair.
|
// will only get called once per `participantId, userId` pair.
|
||||||
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
|
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
|
||||||
|
|||||||
212
src/state/MuteStates.test.ts
Normal file
212
src/state/MuteStates.test.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 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 { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { MuteStates, MuteState } from "./MuteStates";
|
||||||
|
import {
|
||||||
|
type AudioOutputDeviceLabel,
|
||||||
|
type DeviceLabel,
|
||||||
|
type MediaDevice,
|
||||||
|
type SelectedAudioOutputDevice,
|
||||||
|
type SelectedDevice,
|
||||||
|
} from "./MediaDevices";
|
||||||
|
import { constant } from "./Behavior";
|
||||||
|
import { ObservableScope } from "./ObservableScope";
|
||||||
|
import { flushPromises, mockMediaDevices } from "../utils/test";
|
||||||
|
|
||||||
|
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||||
|
vi.mock("../UrlParams", () => ({ getUrlParams }));
|
||||||
|
|
||||||
|
let testScope: ObservableScope;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testScope = new ObservableScope();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
testScope.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MuteState", () => {
|
||||||
|
test("should automatically mute if force mute is set", async () => {
|
||||||
|
const forceMute$ = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
|
const deviceStub = {
|
||||||
|
available$: constant(
|
||||||
|
new Map<string, DeviceLabel>([
|
||||||
|
["fbac11", { type: "name", name: "HD Camera" }],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
selected$: constant({ id: "fbac11" }),
|
||||||
|
select(): void {},
|
||||||
|
} as unknown as MediaDevice<DeviceLabel, SelectedDevice>;
|
||||||
|
|
||||||
|
const muteState = new MuteState(
|
||||||
|
testScope,
|
||||||
|
deviceStub,
|
||||||
|
constant(true),
|
||||||
|
true,
|
||||||
|
forceMute$,
|
||||||
|
);
|
||||||
|
let lastEnabled: boolean = false;
|
||||||
|
muteState.enabled$.subscribe((enabled) => {
|
||||||
|
lastEnabled = enabled;
|
||||||
|
});
|
||||||
|
let setEnabled: ((enabled: boolean) => void) | null = null;
|
||||||
|
muteState.setEnabled$.subscribe((setter) => {
|
||||||
|
setEnabled = setter;
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
setEnabled!(true);
|
||||||
|
await flushPromises();
|
||||||
|
expect(lastEnabled).toBe(true);
|
||||||
|
|
||||||
|
// Now force mute
|
||||||
|
forceMute$.next(true);
|
||||||
|
await flushPromises();
|
||||||
|
// Should automatically mute
|
||||||
|
expect(lastEnabled).toBe(false);
|
||||||
|
|
||||||
|
// Try to unmute can not work
|
||||||
|
expect(setEnabled).toBeNull();
|
||||||
|
|
||||||
|
// Disable force mute
|
||||||
|
forceMute$.next(false);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
// TODO I'd expect it to go back to previous state (enabled)
|
||||||
|
// but actually it goes back to the initial state from construction (disabled)
|
||||||
|
// Should go back to previous state (enabled)
|
||||||
|
// Skip for now
|
||||||
|
// expect(lastEnabled).toBe(true);
|
||||||
|
|
||||||
|
// But yet it can be unmuted now
|
||||||
|
expect(setEnabled).not.toBeNull();
|
||||||
|
|
||||||
|
setEnabled!(true);
|
||||||
|
await flushPromises();
|
||||||
|
expect(lastEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MuteStates", () => {
|
||||||
|
function aAudioOutputDevices(): MediaDevice<
|
||||||
|
AudioOutputDeviceLabel,
|
||||||
|
SelectedAudioOutputDevice
|
||||||
|
> {
|
||||||
|
const selected$ = new BehaviorSubject<
|
||||||
|
SelectedAudioOutputDevice | undefined
|
||||||
|
>({
|
||||||
|
id: "default",
|
||||||
|
virtualEarpiece: false,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
available$: constant(
|
||||||
|
new Map<string, AudioOutputDeviceLabel>([
|
||||||
|
["default", { type: "speaker" }],
|
||||||
|
["0000", { type: "speaker" }],
|
||||||
|
["1111", { type: "earpiece" }],
|
||||||
|
["222", { type: "name", name: "Bluetooth Speaker" }],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
selected$,
|
||||||
|
select(id: string): void {
|
||||||
|
if (!this.available$.getValue().has(id)) {
|
||||||
|
logger.warn(`Attempted to select unknown device id: ${id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selected$.next({
|
||||||
|
id,
|
||||||
|
/** For test purposes we ignore this */
|
||||||
|
virtualEarpiece: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function aVideoInput(): MediaDevice<DeviceLabel, SelectedDevice> {
|
||||||
|
const selected$ = new BehaviorSubject<SelectedDevice | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
available$: constant(
|
||||||
|
new Map<string, DeviceLabel>([
|
||||||
|
["0000", { type: "name", name: "HD Camera" }],
|
||||||
|
["1111", { type: "name", name: "WebCam Pro" }],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
selected$,
|
||||||
|
select(id: string): void {
|
||||||
|
if (!this.available$.getValue().has(id)) {
|
||||||
|
logger.warn(`Attempted to select unknown device id: ${id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selected$.next({ id });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("should mute camera when in earpiece mode", async () => {
|
||||||
|
const audioOutputDevice = aAudioOutputDevices();
|
||||||
|
|
||||||
|
const mediaDevices = mockMediaDevices({
|
||||||
|
audioOutput: audioOutputDevice,
|
||||||
|
videoInput: aVideoInput(),
|
||||||
|
// other devices are not relevant for this test
|
||||||
|
});
|
||||||
|
const muteStates = new MuteStates(
|
||||||
|
testScope,
|
||||||
|
mediaDevices,
|
||||||
|
// consider joined
|
||||||
|
constant(true),
|
||||||
|
);
|
||||||
|
|
||||||
|
let latestSyncedState: boolean | null = null;
|
||||||
|
muteStates.video.setHandler(async (enabled: boolean): Promise<boolean> => {
|
||||||
|
logger.info(`Video mute state set to: ${enabled}`);
|
||||||
|
latestSyncedState = enabled;
|
||||||
|
return Promise.resolve(enabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastVideoEnabled: boolean = false;
|
||||||
|
muteStates.video.enabled$.subscribe((enabled) => {
|
||||||
|
lastVideoEnabled = enabled;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(muteStates.video.setEnabled$.value).toBeDefined();
|
||||||
|
muteStates.video.setEnabled$.value?.(true);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(lastVideoEnabled).toBe(true);
|
||||||
|
|
||||||
|
// Select earpiece audio output
|
||||||
|
audioOutputDevice.select("1111");
|
||||||
|
await flushPromises();
|
||||||
|
// Video should be automatically muted
|
||||||
|
expect(lastVideoEnabled).toBe(false);
|
||||||
|
expect(latestSyncedState).toBe(false);
|
||||||
|
|
||||||
|
// Try to switch to speaker
|
||||||
|
audioOutputDevice.select("0000");
|
||||||
|
await flushPromises();
|
||||||
|
// TODO I'd expect it to go back to previous state (enabled)??
|
||||||
|
// But maybe not? If you move the phone away from your ear you may not want it
|
||||||
|
// to automatically enable video?
|
||||||
|
expect(lastVideoEnabled).toBe(false);
|
||||||
|
|
||||||
|
// But yet it can be unmuted now
|
||||||
|
expect(muteStates.video.setEnabled$.value).toBeDefined();
|
||||||
|
muteStates.video.setEnabled$.value?.(true);
|
||||||
|
await flushPromises();
|
||||||
|
expect(lastVideoEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,7 +27,7 @@ import { ElementWidgetActions, widget } from "../widget";
|
|||||||
import { Config } from "../config/Config";
|
import { Config } from "../config/Config";
|
||||||
import { getUrlParams } from "../UrlParams";
|
import { getUrlParams } from "../UrlParams";
|
||||||
import { type ObservableScope } from "./ObservableScope";
|
import { type ObservableScope } from "./ObservableScope";
|
||||||
import { type Behavior } from "./Behavior";
|
import { type Behavior, constant } from "./Behavior";
|
||||||
|
|
||||||
interface MuteStateData {
|
interface MuteStateData {
|
||||||
enabled$: Observable<boolean>;
|
enabled$: Observable<boolean>;
|
||||||
@@ -38,31 +38,58 @@ interface MuteStateData {
|
|||||||
export type Handler = (desired: boolean) => Promise<boolean>;
|
export type Handler = (desired: boolean) => Promise<boolean>;
|
||||||
const defaultHandler: Handler = async (desired) => Promise.resolve(desired);
|
const defaultHandler: Handler = async (desired) => Promise.resolve(desired);
|
||||||
|
|
||||||
class MuteState<Label, Selected> {
|
/**
|
||||||
|
* Internal class - exported only for testing purposes.
|
||||||
|
* Do not use directly outside of tests.
|
||||||
|
*/
|
||||||
|
export class MuteState<Label, Selected> {
|
||||||
|
// TODO: rewrite this to explain behavior, it is not understandable, and cannot add logging
|
||||||
private readonly enabledByDefault$ =
|
private readonly enabledByDefault$ =
|
||||||
this.enabledByConfig && !getUrlParams().skipLobby
|
this.enabledByConfig && !getUrlParams().skipLobby
|
||||||
? this.joined$.pipe(map((isJoined) => !isJoined))
|
? this.joined$.pipe(map((isJoined) => !isJoined))
|
||||||
: of(false);
|
: of(false);
|
||||||
|
|
||||||
private readonly handler$ = new BehaviorSubject(defaultHandler);
|
private readonly handler$ = new BehaviorSubject(defaultHandler);
|
||||||
|
|
||||||
public setHandler(handler: Handler): void {
|
public setHandler(handler: Handler): void {
|
||||||
if (this.handler$.value !== defaultHandler)
|
if (this.handler$.value !== defaultHandler)
|
||||||
throw new Error("Multiple mute state handlers are not supported");
|
throw new Error("Multiple mute state handlers are not supported");
|
||||||
this.handler$.next(handler);
|
this.handler$.next(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
public unsetHandler(): void {
|
public unsetHandler(): void {
|
||||||
this.handler$.next(defaultHandler);
|
this.handler$.next(defaultHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly canControlDevices$ = combineLatest([
|
||||||
|
this.device.available$,
|
||||||
|
this.forceMute$,
|
||||||
|
]).pipe(
|
||||||
|
map(([available, forceMute]) => {
|
||||||
|
return !forceMute && available.size > 0;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
private readonly data$ = this.scope.behavior<MuteStateData>(
|
private readonly data$ = this.scope.behavior<MuteStateData>(
|
||||||
this.device.available$.pipe(
|
this.canControlDevices$.pipe(
|
||||||
map((available) => available.size > 0),
|
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
withLatestFrom(
|
withLatestFrom(
|
||||||
this.enabledByDefault$,
|
this.enabledByDefault$,
|
||||||
(devicesConnected, enabledByDefault) => {
|
(canControlDevices, enabledByDefault) => {
|
||||||
if (!devicesConnected)
|
logger.info(
|
||||||
|
`MuteState: canControlDevices: ${canControlDevices}, enabled by default: ${enabledByDefault}`,
|
||||||
|
);
|
||||||
|
if (!canControlDevices) {
|
||||||
|
logger.info(
|
||||||
|
`MuteState: devices connected: ${canControlDevices}, disabling`,
|
||||||
|
);
|
||||||
|
// We need to sync the mute state with the handler
|
||||||
|
// to ensure nothing is beeing published.
|
||||||
|
this.handler$.value(false).catch((err) => {
|
||||||
|
logger.error("MuteState-disable: handler error", err);
|
||||||
|
});
|
||||||
return { enabled$: of(false), set: null, toggle: null };
|
return { enabled$: of(false), set: null, toggle: null };
|
||||||
|
}
|
||||||
|
|
||||||
// Assume the default value only once devices are actually connected
|
// Assume the default value only once devices are actually connected
|
||||||
let enabled = enabledByDefault;
|
let enabled = enabledByDefault;
|
||||||
@@ -135,21 +162,45 @@ class MuteState<Label, Selected> {
|
|||||||
private readonly device: MediaDevice<Label, Selected>,
|
private readonly device: MediaDevice<Label, Selected>,
|
||||||
private readonly joined$: Observable<boolean>,
|
private readonly joined$: Observable<boolean>,
|
||||||
private readonly enabledByConfig: boolean,
|
private readonly enabledByConfig: boolean,
|
||||||
|
/**
|
||||||
|
* An optional observable which, when it emits `true`, will force the mute.
|
||||||
|
* Used for video to stop camera when earpiece mode is on.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private readonly forceMute$: Observable<boolean>,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MuteStates {
|
export class MuteStates {
|
||||||
|
/**
|
||||||
|
* True if the selected audio output device is an earpiece.
|
||||||
|
* Used to force-disable video when on earpiece.
|
||||||
|
*/
|
||||||
|
private readonly isEarpiece$ = combineLatest(
|
||||||
|
this.mediaDevices.audioOutput.available$,
|
||||||
|
this.mediaDevices.audioOutput.selected$,
|
||||||
|
).pipe(
|
||||||
|
map(([available, selected]) => {
|
||||||
|
if (!selected?.id) return false;
|
||||||
|
const device = available.get(selected.id);
|
||||||
|
logger.info(`MuteStates: selected audio output device:`, device);
|
||||||
|
return device?.type === "earpiece";
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
public readonly audio = new MuteState(
|
public readonly audio = new MuteState(
|
||||||
this.scope,
|
this.scope,
|
||||||
this.mediaDevices.audioInput,
|
this.mediaDevices.audioInput,
|
||||||
this.joined$,
|
this.joined$,
|
||||||
Config.get().media_devices.enable_audio,
|
Config.get().media_devices.enable_audio,
|
||||||
|
constant(false),
|
||||||
);
|
);
|
||||||
public readonly video = new MuteState(
|
public readonly video = new MuteState(
|
||||||
this.scope,
|
this.scope,
|
||||||
this.mediaDevices.videoInput,
|
this.mediaDevices.videoInput,
|
||||||
this.joined$,
|
this.joined$,
|
||||||
Config.get().media_devices.enable_video,
|
Config.get().media_devices.enable_video,
|
||||||
|
this.isEarpiece$,
|
||||||
);
|
);
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
|||||||
Reference in New Issue
Block a user