mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-19 23:52:26 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a203304742 | ||
|
|
16f5472a11 |
@@ -1,17 +0,0 @@
|
||||
id: "lk-jwt-service-othersite"
|
||||
as_token: "ec_dev_as_token_othersite_0123456789ab"
|
||||
hs_token: "ec_dev_hs_token_othersite_0123456789ab"
|
||||
sender_localpart: "_lk_jwt_service"
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: false
|
||||
regex: "@.*:homeserver-1" # Cover all local users
|
||||
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
|
||||
url: "http://auth-server-1:16080"
|
||||
# Enable membership look-ups via /is_joined.
|
||||
io.element.msc4502.scopes:
|
||||
["urn:matrix:client:io.element.msc4502:rooms:is_joined"]
|
||||
# Route /_matrix/client/(v*|unstable/*)/rtc/livekit/* C-S and S-S requests to this
|
||||
# service.
|
||||
io.element.msc4512.proxy_prefix: "rtc/livekit"
|
||||
io.element.msc4512.proxy_url: "http://auth-server-1:16080"
|
||||
@@ -1,17 +0,0 @@
|
||||
id: "lk-jwt-service"
|
||||
as_token: "ec_dev_as_token_main_0123456789ab"
|
||||
hs_token: "ec_dev_hs_token_main_0123456789ab"
|
||||
sender_localpart: "_lk_jwt_service"
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: false
|
||||
regex: "@.*:homeserver" # Cover all local users
|
||||
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
|
||||
url: "http://auth-server:6080"
|
||||
# Enable membership look-ups via /is_joined.
|
||||
io.element.msc4502.scopes:
|
||||
["urn:matrix:client:io.element.msc4502:rooms:is_joined"]
|
||||
# Route /_matrix/client/(v*|unstable/*)/rtc/livekit/* C-S and S-S requests to this
|
||||
# service.
|
||||
io.element.msc4512.proxy_prefix: "rtc/livekit"
|
||||
io.element.msc4512.proxy_url: "http://auth-server:6080"
|
||||
@@ -42,13 +42,6 @@ experimental_features:
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -42,13 +42,6 @@ experimental_features:
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -42,13 +42,6 @@ experimental_features:
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -42,13 +42,6 @@ experimental_features:
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
+4
-28
@@ -3,7 +3,7 @@ networks:
|
||||
|
||||
services:
|
||||
auth-service:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
|
||||
pull_policy: always
|
||||
hostname: auth-server
|
||||
environment:
|
||||
@@ -15,14 +15,6 @@ services:
|
||||
# a self-signed certificate
|
||||
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
|
||||
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
|
||||
# Registers this instance as an application service on the main homeserver
|
||||
- LIVEKIT_AS_REGISTRATION_FILE=/etc/lk-jwt-service/app-service.yaml
|
||||
- LIVEKIT_HS_SERVER_NAME=synapse.m.localhost
|
||||
# Neither homeserver serves /.well-known/matrix/client, so the C-S API
|
||||
# location has to be given explicitly.
|
||||
- LIVEKIT_CS_API_URL_OVERRIDES=synapse.m.localhost=http://homeserver:8008,synapse.othersite.m.localhost=http://homeserver-1:18008
|
||||
volumes:
|
||||
- ./backend/app-service.yaml:/etc/lk-jwt-service/app-service.yaml:Z
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
@@ -33,7 +25,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
auth-service-1:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
|
||||
pull_policy: always
|
||||
hostname: auth-server-1
|
||||
environment:
|
||||
@@ -45,14 +37,6 @@ services:
|
||||
# a self-signed certificate
|
||||
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
|
||||
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
|
||||
# Registers this instance as an application service on the other homeserver.
|
||||
- LIVEKIT_AS_REGISTRATION_FILE=/etc/lk-jwt-service/app-service.yaml
|
||||
- LIVEKIT_HS_SERVER_NAME=synapse.othersite.m.localhost
|
||||
# Neither homeserver serves /.well-known/matrix/client, so the C-S API
|
||||
# location has to be given explicitly.
|
||||
- LIVEKIT_CS_API_URL_OVERRIDES=synapse.m.localhost=http://homeserver:8008,synapse.othersite.m.localhost=http://homeserver-1:18008
|
||||
volumes:
|
||||
- ./backend/app-service-othersite.yaml:/etc/lk-jwt-service/app-service.yaml:Z
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
@@ -110,10 +94,7 @@ services:
|
||||
|
||||
synapse:
|
||||
hostname: homeserver
|
||||
# develop (not latest) is required for application service C-S/S-S proxying
|
||||
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
|
||||
# release yet.
|
||||
image: ghcr.io/element-hq/synapse:develop
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
pull_policy: always
|
||||
environment:
|
||||
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
|
||||
@@ -126,16 +107,12 @@ services:
|
||||
volumes:
|
||||
- ./backend/synapse_tmp:/data:Z
|
||||
- ./backend/dev_homeserver.yaml:/data/cfg/homeserver.yaml:Z
|
||||
- ./backend/app-service.yaml:/data/cfg/app-service.yaml:Z
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
synapse-1:
|
||||
hostname: homeserver-1
|
||||
# develop (not latest) is required for application service C-S/S-S proxying
|
||||
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
|
||||
# release yet.
|
||||
image: ghcr.io/element-hq/synapse:develop
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
pull_policy: always
|
||||
environment:
|
||||
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
|
||||
@@ -148,7 +125,6 @@ services:
|
||||
volumes:
|
||||
- ./backend/synapse_tmp_othersite:/data:Z
|
||||
- ./backend/dev_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
- ./backend/app-service-othersite.yaml:/data/cfg/app-service.yaml:Z
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
|
||||
@@ -3,23 +3,17 @@
|
||||
services:
|
||||
synapse:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
# develop (not latest) is required for application service C-S/S-S proxying
|
||||
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
|
||||
# release yet.
|
||||
image: ghcr.io/element-hq/synapse:develop@sha256:337921fd22be310d453344b2265f6c9942315446595df3cf81866f71ca2055a7
|
||||
image: ghcr.io/element-hq/synapse:latest@sha256:da325af40104051923899e5f5a2f1d537e6e3ccf2f0f38285689ae9bbfdb190a
|
||||
volumes:
|
||||
- ./backend/playwright_homeserver.yaml:/data/cfg/homeserver.yaml:Z
|
||||
synapse-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
# develop (not latest) is required for application service C-S/S-S proxying
|
||||
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
|
||||
# release yet.
|
||||
image: ghcr.io/element-hq/synapse:develop@sha256:337921fd22be310d453344b2265f6c9942315446595df3cf81866f71ca2055a7
|
||||
image: ghcr.io/element-hq/synapse:latest@sha256:da325af40104051923899e5f5a2f1d537e6e3ccf2f0f38285689ae9bbfdb190a
|
||||
volumes:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
element-web:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:c76d29903090eeb08ff625272277ede02ea8ee93cbaa067b77adfa89c89a494d
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
|
||||
element-web-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:c76d29903090eeb08ff625272277ede02ea8ee93cbaa067b77adfa89c89a494d
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
|
||||
|
||||
@@ -109,15 +109,10 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
await TestHelpers.dismissInviteUnknownUserModal(ewPage1);
|
||||
|
||||
// Accept the invite
|
||||
await TestHelpers.closeReleaseAnnouncement(
|
||||
ewPage2,
|
||||
"Introducing Sections",
|
||||
);
|
||||
await TestHelpers.expandAllSections(ewPage2);
|
||||
await expect(
|
||||
TestHelpers.roomListItem(ewPage2, "Welcome Room"),
|
||||
ewPage2.getByRole("option", { name: "Welcome Room" }),
|
||||
).toBeVisible();
|
||||
await TestHelpers.roomListItem(ewPage2, "Welcome Room").click();
|
||||
await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
|
||||
await ewPage2.getByRole("button", { name: "Accept" }).click();
|
||||
await expect(
|
||||
ewPage2
|
||||
@@ -157,12 +152,8 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
).toBeVisible();
|
||||
|
||||
// Accept the DM invite from brooks
|
||||
await TestHelpers.closeReleaseAnnouncement(
|
||||
ewPage2,
|
||||
"Introducing Sections",
|
||||
);
|
||||
await TestHelpers.expandAllSections(ewPage2);
|
||||
await TestHelpers.roomListItem(ewPage2, brooksDisplayName).click();
|
||||
// This how playwright record selects the DM invite in the room list
|
||||
await ewPage2.getByRole("button", { name: "Open room" }).click();
|
||||
await ewPage2.getByRole("button", { name: "Start chatting" }).click();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { SpaHelpers } from "./spa-helpers";
|
||||
|
||||
// Skip test for Firefox, due to page.keyboard.press("Tab") not reliable on headless mode
|
||||
test.skip(
|
||||
({ browserName }) => browserName === "firefox",
|
||||
@@ -19,12 +17,6 @@ test("can only interact with header and footer while reconnecting", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
// The reconnecting state is entered via the probablyLeft timer, which
|
||||
// mirrors the delayed leave event's timeout. With delegation that timeout
|
||||
// is one hour, putting it out of reach of the clock fast-forward below.
|
||||
// Keep the leave client-managed so its short timeout applies.
|
||||
await SpaHelpers.disableLeaveDelegation(page);
|
||||
await page.getByTestId("home_callName").click();
|
||||
await page.getByTestId("home_callName").fill("Test call");
|
||||
await page.getByTestId("home_displayName").click();
|
||||
|
||||
@@ -19,7 +19,6 @@ async function setupTwoUserSpaCall(
|
||||
browser: Browser,
|
||||
page: Page,
|
||||
browserName: string,
|
||||
opts: { disableGuestLeaveDelegation?: boolean } = {},
|
||||
): Promise<{ guestPage: Page }> {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
@@ -53,9 +52,6 @@ async function setupTwoUserSpaCall(
|
||||
|
||||
await guestPage.goto("/");
|
||||
|
||||
if (opts.disableGuestLeaveDelegation)
|
||||
await SpaHelpers.disableLeaveDelegation(guestPage);
|
||||
|
||||
let pevaraHasSentStickyEvent = false;
|
||||
|
||||
const pevaraResolver = Promise.withResolvers<void>();
|
||||
@@ -106,13 +102,7 @@ test("One to One rejoin after improper leave does not crash EC", async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
// With delegation, the backend sends the guest's delayed leave event within
|
||||
// moments of the improper leave, so the stale membership this test needs
|
||||
// would be cleaned up before the rejoin. Keep the guest's leave
|
||||
// client-managed so the stale membership lingers.
|
||||
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName, {
|
||||
disableGuestLeaveDelegation: true,
|
||||
});
|
||||
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName);
|
||||
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
|
||||
@@ -111,24 +111,6 @@ async function setRtcModeFromSettings(
|
||||
await page.getByTestId("modal_close").click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the delayed-leave delegation support probes fail so that the client
|
||||
* manages its delayed leave event itself instead of delegating it to the
|
||||
* backend.
|
||||
*
|
||||
* Must be installed before the page joins a call.
|
||||
*/
|
||||
async function disableLeaveDelegation(page: Page): Promise<void> {
|
||||
// Covers both the transport probe (<livekit_service_url>/delegate_delayed_leave)
|
||||
// and the homeserver probe (MSC4195, .../rtc/livekit/delegate_delayed_leave).
|
||||
await page.route("**/delegate_delayed_leave", async (route) =>
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expect a certain number of video tiles to be present and visible.
|
||||
*/
|
||||
@@ -151,6 +133,5 @@ export const SpaHelpers = {
|
||||
createCall,
|
||||
getCallInviteLink,
|
||||
joinCallFromInviteLink,
|
||||
disableLeaveDelegation,
|
||||
expectVideoTilesCount,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type JSHandle,
|
||||
type Page,
|
||||
type FrameLocator,
|
||||
type Locator,
|
||||
} from "@playwright/test";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
@@ -204,16 +203,6 @@ export class TestHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
public static async expandAllSections(page: Page): Promise<void> {
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Expand all sections" })
|
||||
.click({ timeout: 2000 });
|
||||
} catch {
|
||||
// Already expanded or button not present
|
||||
}
|
||||
}
|
||||
|
||||
public static async createRoom(
|
||||
name: string,
|
||||
page: Page,
|
||||
@@ -264,9 +253,9 @@ export class TestHelpers {
|
||||
roomName: string,
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
|
||||
await TestHelpers.expandAllSections(page);
|
||||
await TestHelpers.roomListItem(page, roomName).click({ timeout: 10000 });
|
||||
await page.getByRole("option", { name: roomName }).click({
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Accept" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
@@ -361,17 +350,6 @@ export class TestHelpers {
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates a room in the room list by its name.
|
||||
*
|
||||
* Matches on the aria-label prefix because the item's role differs between
|
||||
* the flat room list (`option`) and the sectioned room list (`button`), and
|
||||
* the label may carry a suffix such as " invitation.".
|
||||
*/
|
||||
public static roomListItem(page: Page, roomName: string): Locator {
|
||||
return page.locator(`[aria-label^="Open room ${roomName}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to a room in the room list by its name.
|
||||
* @param page - The EW page
|
||||
@@ -381,8 +359,7 @@ export class TestHelpers {
|
||||
page: Page,
|
||||
roomName: string,
|
||||
): Promise<void> {
|
||||
await TestHelpers.expandAllSections(page);
|
||||
await TestHelpers.roomListItem(page, roomName).click();
|
||||
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
|
||||
}
|
||||
|
||||
public static async dismissInviteUnknownUserModal(page: Page): Promise<void> {
|
||||
|
||||
Generated
+14
-14
@@ -39,13 +39,13 @@ importers:
|
||||
version: 11.7.12
|
||||
'@livekit/components-core':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
version: 0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
'@livekit/components-react':
|
||||
specifier: ^2.0.0
|
||||
version: 2.9.24(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
|
||||
version: 2.9.24(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
|
||||
'@livekit/track-processors':
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))
|
||||
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))
|
||||
'@mediapipe/tasks-vision':
|
||||
specifier: ^0.10.18
|
||||
version: 0.10.35
|
||||
@@ -183,7 +183,7 @@ importers:
|
||||
version: 5.88.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(typescript@5.9.3)
|
||||
livekit-client:
|
||||
specifier: ^2.18.1
|
||||
version: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
version: 2.22.2(@types/dom-mediacapture-record@1.0.22)
|
||||
lodash-es:
|
||||
specifier: ^4.17.21
|
||||
version: 4.18.1
|
||||
@@ -4605,8 +4605,8 @@ packages:
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
livekit-client@2.22.3:
|
||||
resolution: {integrity: sha512-jw9zBKXY5Gtr5MZ7vEON3QhMNccuDvYHck1PFSyG1aaateQPqgKZFBMgZkFZaXHIf9RV4MDW5xpTK2b/+qbwOg==}
|
||||
livekit-client@2.22.2:
|
||||
resolution: {integrity: sha512-o3yLYoH2eHVR/DmIZzgC96FMvtR4+g/DjeqNwS5zp12EjqrqiRULxm6Oq/YsTE97WnA6fhy23+a2hGkbce5SPA==}
|
||||
peerDependencies:
|
||||
'@types/dom-mediacapture-record': ^1
|
||||
|
||||
@@ -7079,21 +7079,21 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
'@livekit/components-core@0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
|
||||
'@livekit/components-core@0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
|
||||
loglevel: 1.9.1
|
||||
rxjs: 7.8.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@livekit/components-react@2.9.24(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
|
||||
'@livekit/components-react@2.9.24(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@livekit/components-core': 0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
'@livekit/components-core': 0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
clsx: 2.1.1
|
||||
events: 3.3.0
|
||||
jose: 6.2.9
|
||||
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
tslib: 2.8.1
|
||||
@@ -7105,11 +7105,11 @@ snapshots:
|
||||
dependencies:
|
||||
'@bufbuild/protobuf': 1.10.1
|
||||
|
||||
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))':
|
||||
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))':
|
||||
dependencies:
|
||||
'@mediapipe/tasks-vision': 0.10.35
|
||||
'@types/dom-mediacapture-transform': 0.1.11
|
||||
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
|
||||
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {}
|
||||
|
||||
@@ -10180,7 +10180,7 @@ snapshots:
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22):
|
||||
livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22):
|
||||
dependencies:
|
||||
'@livekit/mutex': 1.1.1
|
||||
'@livekit/protocol': 1.50.4
|
||||
|
||||
@@ -25,11 +25,11 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import styles from "./MediaMuteAndSwitchButton.module.css";
|
||||
import { MicButton, VideoButton } from "../button";
|
||||
import { type AudioInputDeviceLabel } from "../state/MediaDevices";
|
||||
import { type DeviceLabel } from "../state/MediaDevices";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
|
||||
export interface MenuOptions {
|
||||
label: AudioInputDeviceLabel;
|
||||
label: DeviceLabel;
|
||||
id: string;
|
||||
}
|
||||
|
||||
@@ -180,9 +180,6 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
case "number":
|
||||
labelText = numberedLabel(label.number);
|
||||
break;
|
||||
case "default":
|
||||
labelText = t("settings.devices.default");
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import {
|
||||
setLogExtension as setLKLogExtension,
|
||||
setLogLevel as setLKLogLevel,
|
||||
LoggerNames as LKLoggerNames,
|
||||
} from "livekit-client";
|
||||
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
@@ -227,9 +226,6 @@ export class Initializer {
|
||||
|
||||
enableExtendedLivekitLogs.value$.subscribe((enabled) => {
|
||||
setLKLogLevel(enabled ? "trace" : "info");
|
||||
// ICE candidate types and connection states are what you need to diagnose
|
||||
// "could not establish pc connection", so always keep those in the rageshake
|
||||
setLKLogLevel("debug", LKLoggerNames.ICE);
|
||||
});
|
||||
|
||||
window.setLKLogLevel = setLKLogLevel;
|
||||
|
||||
@@ -24,12 +24,10 @@ import { testAudioContext } from "../useAudioContext.test";
|
||||
import * as MediaDevicesContext from "../MediaDevicesContext";
|
||||
import { LivekitRoomAudioRenderer } from "./MatrixAudioRenderer";
|
||||
import {
|
||||
mockLocalParticipant,
|
||||
mockMediaDevices,
|
||||
mockRemoteParticipant,
|
||||
mockTrack,
|
||||
} from "../utils/test";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
export const TestAudioContextConstructor = vi.fn(
|
||||
@@ -133,57 +131,6 @@ it("should render for member", () => {
|
||||
expect(queryAllByTestId("audio")).toHaveLength(1);
|
||||
});
|
||||
|
||||
function spyOnWarn(): ReturnType<typeof vi.fn> {
|
||||
const warn = vi.fn();
|
||||
vi.spyOn(rootLogger, "getChild").mockReturnValue({
|
||||
warn,
|
||||
} as unknown as typeof rootLogger);
|
||||
return warn;
|
||||
}
|
||||
|
||||
it("should not render or warn for the local participant", () => {
|
||||
const warn = spyOnWarn();
|
||||
const local = mockLocalParticipant({ identity: "@alice:DEV0" });
|
||||
vi.mocked(useTracks).mockReturnValue([mockTrack(local)]);
|
||||
const { queryAllByTestId } = render(
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<LivekitRoomAudioRenderer
|
||||
validIdentities={[]}
|
||||
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
|
||||
url={""}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
expect(queryAllByTestId("audio")).toHaveLength(0);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should warn only once per unexpected participant", () => {
|
||||
const warn = spyOnWarn();
|
||||
const { rerender } = renderTestComponent(
|
||||
[{ userId: "@bob", deviceId: "DEV0" }],
|
||||
["@alice:DEV0"],
|
||||
[
|
||||
{
|
||||
participantId: "@alice:DEV0",
|
||||
kind: Track.Kind.Audio,
|
||||
source: Track.Source.Microphone,
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
rerender(
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<LivekitRoomAudioRenderer
|
||||
validIdentities={[]}
|
||||
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
|
||||
url={""}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not render without member", () => {
|
||||
const { container, queryAllByTestId } = renderTestComponent(
|
||||
[{ userId: "@bob", deviceId: "DEV0" }],
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { getTrackReferenceId } from "@livekit/components-core";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
import { type RemoteAudioTrack, Track } from "livekit-client";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
useTracks,
|
||||
AudioTrack,
|
||||
@@ -60,9 +60,6 @@ export function LivekitRoomAudioRenderer({
|
||||
muted,
|
||||
}: MatrixAudioRendererProps): ReactNode {
|
||||
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
|
||||
// Identities we have already warned about, so that re-renders (which happen
|
||||
// on every active speaker update) don't repeat the warning.
|
||||
const warnedIdentities = useRef(new Set<string>());
|
||||
const tracks = useTracks(
|
||||
[
|
||||
Track.Source.Microphone,
|
||||
@@ -75,27 +72,22 @@ export function LivekitRoomAudioRenderer({
|
||||
room: livekitRoom,
|
||||
},
|
||||
)
|
||||
// Only keep remote audio tracks (we never render our own audio)
|
||||
.filter(
|
||||
(ref) =>
|
||||
ref.publication.kind === Track.Kind.Audio && !ref.participant.isLocal,
|
||||
)
|
||||
// Only keep audio tracks
|
||||
.filter((ref) => ref.publication.kind === Track.Kind.Audio)
|
||||
// Only keep tracks from participants that are in the validIdentities list
|
||||
.filter((ref) => {
|
||||
const { identity } = ref.participant;
|
||||
const isValid = validIdentities.includes(identity);
|
||||
if (!isValid && !warnedIdentities.current.has(identity)) {
|
||||
warnedIdentities.current.add(identity);
|
||||
const isValid = validIdentities.includes(ref.participant.identity);
|
||||
if (!isValid) {
|
||||
// TODO make sure to also skip the warn logging for the local identity
|
||||
// Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call.
|
||||
logger.warn(
|
||||
`Audio track ${identity} from ${url} has no matching matrix call member`,
|
||||
`Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`,
|
||||
`current members: ${validIdentities.join()}`,
|
||||
`track will not get rendered`,
|
||||
);
|
||||
} else if (isValid) {
|
||||
warnedIdentities.current.delete(identity);
|
||||
return false;
|
||||
}
|
||||
return isValid;
|
||||
return true;
|
||||
});
|
||||
|
||||
// This component is also (in addition to the "only play audio for connected members" logic above)
|
||||
|
||||
@@ -1,87 +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 { describe, expect, it, vi } from "vitest";
|
||||
import { type LocalVideoTrack } from "livekit-client";
|
||||
import {
|
||||
type BackgroundOptions,
|
||||
type ProcessorWrapper,
|
||||
} from "@livekit/track-processors";
|
||||
|
||||
import { applyProcessor, trackProcessorSync } from "./TrackProcessorContext";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { flushPromises, testScope } from "../utils/test";
|
||||
|
||||
const processor = {} as ProcessorWrapper<BackgroundOptions>;
|
||||
|
||||
function mockTrack(
|
||||
readyState: MediaStreamTrackState,
|
||||
current?: ProcessorWrapper<BackgroundOptions>,
|
||||
): LocalVideoTrack {
|
||||
return {
|
||||
mediaStreamTrack: { readyState },
|
||||
getProcessor: vi.fn().mockReturnValue(current),
|
||||
setProcessor: vi.fn().mockResolvedValue(undefined),
|
||||
stopProcessor: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as LocalVideoTrack;
|
||||
}
|
||||
|
||||
describe("applyProcessor", () => {
|
||||
it("attaches the processor to a live track", () => {
|
||||
const track = mockTrack("live");
|
||||
applyProcessor(track, processor);
|
||||
expect(track.setProcessor).toHaveBeenCalledWith(processor);
|
||||
});
|
||||
|
||||
it("does not attach the processor to an ended track", () => {
|
||||
const track = mockTrack("ended");
|
||||
applyProcessor(track, processor);
|
||||
expect(track.setProcessor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not surface a rejected setProcessor", async () => {
|
||||
const track = mockTrack("live");
|
||||
vi.mocked(track.setProcessor).mockRejectedValue(
|
||||
new TypeError("Input track cannot be ended"),
|
||||
);
|
||||
const unhandled = vi.fn();
|
||||
process.on("unhandledRejection", unhandled);
|
||||
applyProcessor(track, processor);
|
||||
await flushPromises();
|
||||
process.off("unhandledRejection", unhandled);
|
||||
expect(unhandled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops the processor when none is wanted", () => {
|
||||
const track = mockTrack("live", processor);
|
||||
applyProcessor(track, undefined);
|
||||
expect(track.stopProcessor).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not surface a rejected stopProcessor", async () => {
|
||||
const track = mockTrack("live", processor);
|
||||
vi.mocked(track.stopProcessor).mockRejectedValue(new Error("nope"));
|
||||
const unhandled = vi.fn();
|
||||
process.on("unhandledRejection", unhandled);
|
||||
applyProcessor(track, undefined);
|
||||
await flushPromises();
|
||||
process.off("unhandledRejection", unhandled);
|
||||
expect(unhandled).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("trackProcessorSync", () => {
|
||||
it("applies the processor to the current track", () => {
|
||||
const track = mockTrack("live");
|
||||
trackProcessorSync(
|
||||
testScope(),
|
||||
constant(track),
|
||||
constant({ supported: true, processor }),
|
||||
);
|
||||
expect(track.setProcessor).toHaveBeenCalledWith(processor);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { type LocalVideoTrack } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { combineLatest, map, type Observable } from "rxjs";
|
||||
import { useObservable } from "observable-hooks";
|
||||
|
||||
@@ -66,34 +65,6 @@ export function useTrackProcessorObservable$(): Observable<ProcessorState> {
|
||||
return state$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches or detaches the processor so that the track matches the desired
|
||||
* state, without throwing.
|
||||
*/
|
||||
export function applyProcessor(
|
||||
videoTrack: LocalVideoTrack,
|
||||
processor: ProcessorWrapper<BackgroundOptions> | undefined,
|
||||
): void {
|
||||
if (processor && !videoTrack.getProcessor()) {
|
||||
// A MediaStreamTrackProcessor cannot be constructed on an ended track
|
||||
// (e.g. the camera was stopped while the processor was being applied),
|
||||
// and setProcessor rejects with a TypeError. The track is going away
|
||||
// anyway, so there is nothing to attach to.
|
||||
if (videoTrack.mediaStreamTrack.readyState === "ended") {
|
||||
logger.debug("Not attaching video processor to an ended track");
|
||||
return;
|
||||
}
|
||||
videoTrack.setProcessor(processor).catch((e) => {
|
||||
logger.warn("Failed to attach video processor", e);
|
||||
});
|
||||
}
|
||||
if (!processor && videoTrack.getProcessor()) {
|
||||
videoTrack.stopProcessor().catch((e) => {
|
||||
logger.warn("Failed to stop video processor", e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates your video tracks to always use the given processor.
|
||||
*/
|
||||
@@ -107,7 +78,13 @@ export const trackProcessorSync = (
|
||||
.subscribe(([videoTrack, processorState]) => {
|
||||
if (!processorState) return;
|
||||
if (!videoTrack) return;
|
||||
applyProcessor(videoTrack, processorState.processor);
|
||||
const { processor } = processorState;
|
||||
if (processor && !videoTrack.getProcessor()) {
|
||||
void videoTrack.setProcessor(processor);
|
||||
}
|
||||
if (!processor && videoTrack.getProcessor()) {
|
||||
void videoTrack.stopProcessor();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -117,7 +94,12 @@ export const useTrackProcessorSync = (
|
||||
const { processor } = useTrackProcessor();
|
||||
useEffect(() => {
|
||||
if (!videoTrack) return;
|
||||
applyProcessor(videoTrack, processor);
|
||||
if (processor && !videoTrack.getProcessor()) {
|
||||
void videoTrack.setProcessor(processor);
|
||||
}
|
||||
if (!processor && videoTrack.getProcessor()) {
|
||||
void videoTrack.stopProcessor();
|
||||
}
|
||||
}, [processor, videoTrack]);
|
||||
};
|
||||
|
||||
|
||||
@@ -135,8 +135,7 @@ export const LobbyView: FC<Props> = ({
|
||||
const initialAudioOptions = useInitial(
|
||||
() =>
|
||||
audioEnabled && {
|
||||
// "" is the virtual browser default: no deviceId constraint
|
||||
deviceId: getValue(devices.audioInput.selected$)?.id || undefined,
|
||||
deviceId: getValue(devices.audioInput.selected$)?.id,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Corp.
|
||||
|
||||
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, vi, it, expect } from "vitest";
|
||||
import * as ComponentsCore from "@livekit/components-core";
|
||||
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import { MediaDevices } from "./MediaDevices";
|
||||
import { audioInput as audioInputSetting } from "../settings/settings";
|
||||
import { withTestScheduler } from "../utils/test";
|
||||
|
||||
const BUILT_IN_MIC = {
|
||||
deviceId: "b3d4cb5e0c8e1d7a2f9b6c4e5d8a7f1c3e2b9d6a5c4f8e7d1b2a3c4d5e6f7a8b",
|
||||
kind: "audioinput",
|
||||
label: "iPhone Microphone",
|
||||
groupId: "1",
|
||||
} as unknown as MediaDeviceInfo;
|
||||
|
||||
const HEADSET_MIC = {
|
||||
deviceId: "c4e5d6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5",
|
||||
kind: "audioinput",
|
||||
label: "Headset Microphone",
|
||||
groupId: "2",
|
||||
} as unknown as MediaDeviceInfo;
|
||||
|
||||
// Chrome exposes a synthetic "default" device that follows the OS default.
|
||||
const CHROME_DEFAULT = {
|
||||
...HEADSET_MIC,
|
||||
deviceId: "default",
|
||||
label: `Default - ${HEADSET_MIC.label}`,
|
||||
} as unknown as MediaDeviceInfo;
|
||||
|
||||
vi.mock("@livekit/components-core", () => ({
|
||||
createMediaDeviceObserver: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("AudioInput", () => {
|
||||
let testScope: ObservableScope;
|
||||
|
||||
beforeEach(() => {
|
||||
testScope = new ObservableScope();
|
||||
// Device preferences persist in localStorage across tests
|
||||
audioInputSetting.setValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testScope.end();
|
||||
});
|
||||
|
||||
it("uses the browser default input when there is no default pseudo-device", () => {
|
||||
withTestScheduler(({ behavior, cold, expectObservable }) => {
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
|
||||
// iOS / Firefox: physical devices only, built-in first
|
||||
cold("a", { a: [BUILT_IN_MIC, HEADSET_MIC] }),
|
||||
);
|
||||
|
||||
const { audioInput } = new MediaDevices(testScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
expectObservable(audioInput.available$).toBe("a", {
|
||||
a: new Map([
|
||||
["", { type: "default", name: null }],
|
||||
[BUILT_IN_MIC.deviceId, { type: "name", name: BUILT_IN_MIC.label }],
|
||||
[HEADSET_MIC.deviceId, { type: "name", name: HEADSET_MIC.label }],
|
||||
]),
|
||||
});
|
||||
expectObservable(audioInput.selected$.pipe()).toBe("a", {
|
||||
a: expect.objectContaining({ id: "" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps using Chrome's default device", () => {
|
||||
withTestScheduler(({ behavior, cold, expectObservable }) => {
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
|
||||
cold("a", { a: [CHROME_DEFAULT, BUILT_IN_MIC, HEADSET_MIC] }),
|
||||
);
|
||||
|
||||
const { audioInput } = new MediaDevices(testScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
expectObservable(audioInput.selected$).toBe("a", {
|
||||
a: expect.objectContaining({ id: "default" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("honours an explicit choice and falls back to the browser default when it disappears", () => {
|
||||
withTestScheduler(({ behavior, cold, schedule, expectObservable }) => {
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
|
||||
cold("a---b", {
|
||||
a: [BUILT_IN_MIC, HEADSET_MIC],
|
||||
b: [BUILT_IN_MIC],
|
||||
}),
|
||||
);
|
||||
|
||||
const { audioInput } = new MediaDevices(testScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
schedule("--a", { a: () => audioInput.select(HEADSET_MIC.deviceId) });
|
||||
|
||||
expectObservable(audioInput.selected$).toBe("a-b-c", {
|
||||
a: expect.objectContaining({ id: "" }),
|
||||
b: expect.objectContaining({ id: HEADSET_MIC.deviceId }),
|
||||
c: expect.objectContaining({ id: "" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,26 +5,16 @@ 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,
|
||||
it,
|
||||
type Mock,
|
||||
test,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
|
||||
import {
|
||||
ConnectionState as LivekitConnectionState,
|
||||
LocalParticipant,
|
||||
type LocalTrack,
|
||||
type LocalTrackPublication,
|
||||
ParticipantEvent,
|
||||
type Room as LivekitRoom,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { BehaviorSubject, NEVER } from "rxjs";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { ObservableScope } from "../../ObservableScope";
|
||||
@@ -33,12 +23,10 @@ import {
|
||||
flushPromises,
|
||||
mockLivekitRoom,
|
||||
mockMediaDevices,
|
||||
deviceStub,
|
||||
} from "../../../utils/test";
|
||||
import { Publisher } from "./Publisher";
|
||||
import { type Connection } from "../remoteMembers/Connection";
|
||||
import { type MuteStates } from "../../MuteStates";
|
||||
import { type MediaDevices } from "../../MediaDevices";
|
||||
|
||||
let scope: ObservableScope;
|
||||
|
||||
@@ -194,159 +182,6 @@ beforeEach(() => {
|
||||
} as unknown as Connection;
|
||||
});
|
||||
|
||||
describe("Publisher device sync", () => {
|
||||
type SelectedInput =
|
||||
| { id: string; hardwareDeviceChange$: typeof NEVER }
|
||||
| undefined;
|
||||
|
||||
function selectedInput(id: string): SelectedInput {
|
||||
return { id, hardwareDeviceChange$: NEVER };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Publisher whose LiveKit room starts out capturing from
|
||||
* capturedDeviceId (undefined meaning no deviceId constraint, i.e. the
|
||||
* browser default) and reports activeDeviceId as its active audio input.
|
||||
*/
|
||||
function createDeviceSyncPublisher({
|
||||
selected$,
|
||||
capturedDeviceId,
|
||||
activeDeviceId,
|
||||
switchActiveDevice = vi.fn().mockResolvedValue(true),
|
||||
}: {
|
||||
selected$: BehaviorSubject<SelectedInput>;
|
||||
capturedDeviceId?: string;
|
||||
activeDeviceId: string;
|
||||
switchActiveDevice?: Mock;
|
||||
}): { publisher: Publisher; switchActiveDevice: Mock } {
|
||||
const livekitRoom = mockLivekitRoom({
|
||||
localParticipant,
|
||||
state: LivekitConnectionState.Connected,
|
||||
// ConnectionFactory leaves the deviceId out for the browser default
|
||||
options: { audioCaptureDefaults: { deviceId: capturedDeviceId } },
|
||||
switchActiveDevice,
|
||||
getActiveDevice: () => activeDeviceId,
|
||||
} as unknown as Partial<LivekitRoom>);
|
||||
|
||||
const publisher = new Publisher(
|
||||
{ ...connection, livekitRoom },
|
||||
mockMediaDevices({
|
||||
audioInput: { ...deviceStub, selected$ },
|
||||
} as unknown as Partial<MediaDevices>),
|
||||
muteStates,
|
||||
constant({ supported: false, processor: undefined }),
|
||||
logger,
|
||||
false,
|
||||
);
|
||||
|
||||
return { publisher, switchActiveDevice };
|
||||
}
|
||||
|
||||
it("does not pin a device for the virtual browser default input", async () => {
|
||||
const selected$ = new BehaviorSubject<SelectedInput>(selectedInput(""));
|
||||
const { publisher, switchActiveDevice } = createDeviceSyncPublisher({
|
||||
selected$,
|
||||
activeDeviceId: "hardware-id-of-whatever-the-browser-chose",
|
||||
});
|
||||
|
||||
// Already capturing from the browser default: nothing to switch, even
|
||||
// though LiveKit reports the physical device it ended up with.
|
||||
expect(switchActiveDevice).not.toHaveBeenCalled();
|
||||
|
||||
selected$.next(selectedInput("headset"));
|
||||
expect(switchActiveDevice).toHaveBeenLastCalledWith(
|
||||
"audioinput",
|
||||
"headset",
|
||||
);
|
||||
|
||||
selected$.next(selectedInput(""));
|
||||
expect(switchActiveDevice).toHaveBeenLastCalledWith(
|
||||
"audioinput",
|
||||
"default",
|
||||
false,
|
||||
);
|
||||
expect(switchActiveDevice).toHaveBeenCalledTimes(2);
|
||||
|
||||
await publisher.destroy();
|
||||
});
|
||||
|
||||
it("pins the device the browser default resolved to when picked explicitly", async () => {
|
||||
const selected$ = new BehaviorSubject<SelectedInput>(selectedInput(""));
|
||||
const { publisher, switchActiveDevice } = createDeviceSyncPublisher({
|
||||
selected$,
|
||||
// The browser default happens to capture from the built-in microphone
|
||||
activeDeviceId: "built-in-mic",
|
||||
});
|
||||
|
||||
expect(switchActiveDevice).not.toHaveBeenCalled();
|
||||
|
||||
// Picking that same microphone has to replace the loose constraint with a
|
||||
// pin, or the capture would keep following the OS default
|
||||
selected$.next(selectedInput("built-in-mic"));
|
||||
expect(switchActiveDevice).toHaveBeenCalledTimes(1);
|
||||
expect(switchActiveDevice).toHaveBeenLastCalledWith(
|
||||
"audioinput",
|
||||
"built-in-mic",
|
||||
);
|
||||
|
||||
// Once pinned, re-emitting the same selection is a no-op again
|
||||
selected$.next(selectedInput("built-in-mic"));
|
||||
expect(switchActiveDevice).toHaveBeenCalledTimes(1);
|
||||
|
||||
await publisher.destroy();
|
||||
});
|
||||
|
||||
it("retries the same selection after a failed switch", async () => {
|
||||
const selected$ = new BehaviorSubject<SelectedInput>(selectedInput(""));
|
||||
const switchActiveDevice = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("could not open the device"))
|
||||
.mockResolvedValue(true);
|
||||
const { publisher } = createDeviceSyncPublisher({
|
||||
selected$,
|
||||
// Pinned to the headset, so moving to the browser default is a switch
|
||||
capturedDeviceId: "headset",
|
||||
activeDeviceId: "headset",
|
||||
switchActiveDevice,
|
||||
});
|
||||
|
||||
expect(switchActiveDevice).toHaveBeenCalledTimes(1);
|
||||
await flushPromises();
|
||||
|
||||
// The failed switch left us on the headset, so selecting the browser
|
||||
// default again has to try once more rather than count as already done
|
||||
selected$.next(selectedInput(""));
|
||||
expect(switchActiveDevice).toHaveBeenCalledTimes(2);
|
||||
expect(switchActiveDevice).toHaveBeenLastCalledWith(
|
||||
"audioinput",
|
||||
"default",
|
||||
false,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
// This time it worked, so there is nothing left to do
|
||||
selected$.next(selectedInput(""));
|
||||
expect(switchActiveDevice).toHaveBeenCalledTimes(2);
|
||||
|
||||
await publisher.destroy();
|
||||
});
|
||||
|
||||
it("does not switch when the room already captures from the selected device", async () => {
|
||||
const selected$ = new BehaviorSubject<SelectedInput>(
|
||||
selectedInput("headset"),
|
||||
);
|
||||
const { publisher, switchActiveDevice } = createDeviceSyncPublisher({
|
||||
selected$,
|
||||
capturedDeviceId: "headset",
|
||||
activeDeviceId: "headset",
|
||||
});
|
||||
|
||||
expect(switchActiveDevice).not.toHaveBeenCalled();
|
||||
|
||||
await publisher.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Publisher", () => {
|
||||
let publisher: Publisher;
|
||||
|
||||
|
||||
@@ -355,19 +355,8 @@ export class Publisher {
|
||||
const syncDevice = (
|
||||
kind: MediaDeviceKind,
|
||||
selected$: Observable<SelectedDevice | undefined>,
|
||||
): Subscription => {
|
||||
// The ID we last asked LiveKit for. Needed for the virtual browser
|
||||
// default input ("") because LiveKit reports the physical device it
|
||||
// ended up capturing from, which never equals "".
|
||||
// ConnectionFactory already applied the selection when the room was
|
||||
// created; a browser-default input is represented there by an absent
|
||||
// deviceId.
|
||||
let requestedId: string | undefined =
|
||||
kind === "audioinput" &&
|
||||
lkRoom.options.audioCaptureDefaults?.deviceId === undefined
|
||||
? ""
|
||||
: undefined;
|
||||
return selected$.pipe(scope.bind()).subscribe((device) => {
|
||||
): Subscription =>
|
||||
selected$.pipe(scope.bind()).subscribe((device) => {
|
||||
if (lkRoom.state != LivekitConnectionState.Connected) return;
|
||||
// if (this.connectionState$.value !== ConnectionState.Connected) return;
|
||||
this.logger.info(
|
||||
@@ -376,41 +365,20 @@ export class Publisher {
|
||||
" !== ",
|
||||
device?.id,
|
||||
);
|
||||
if (device === undefined) return;
|
||||
const browserDefaultInput = device.id === "" && kind !== "audiooutput";
|
||||
// While we capture from the browser default, LiveKit reports the
|
||||
// physical device it resolved to, which can be any of the devices the
|
||||
// user could also pick explicitly. getActiveDevice therefore can't
|
||||
// tell an explicit pin apart from the default resolving to the same
|
||||
// device, and only requestedId can: without this, picking the device
|
||||
// the default happens to use would leave the loose constraint in
|
||||
// place, and the capture would keep following the OS default.
|
||||
const capturingBrowserDefault = requestedId === "";
|
||||
if (browserDefaultInput) {
|
||||
if (capturingBrowserDefault) return;
|
||||
} else if (
|
||||
!capturingBrowserDefault &&
|
||||
lkRoom.getActiveDevice(kind) === device.id
|
||||
if (
|
||||
device !== undefined &&
|
||||
lkRoom.getActiveDevice(kind) !== device.id
|
||||
) {
|
||||
return;
|
||||
lkRoom
|
||||
.switchActiveDevice(kind, device.id)
|
||||
.catch((e: Error) =>
|
||||
this.logger.error(
|
||||
`Failed to sync ${kind} device with LiveKit`,
|
||||
e,
|
||||
),
|
||||
);
|
||||
}
|
||||
const previousRequestedId = requestedId;
|
||||
requestedId = device.id;
|
||||
// For the browser default input, ask for "default" as a non-exact
|
||||
// constraint: browsers that have no such device ignore it and capture
|
||||
// from the OS default input.
|
||||
(browserDefaultInput
|
||||
? lkRoom.switchActiveDevice(kind, "default", false)
|
||||
: lkRoom.switchActiveDevice(kind, device.id)
|
||||
).catch((e: Error) => {
|
||||
// LiveKit keeps capturing from the previous device, so record that
|
||||
// again to leave the next selection of this device something to do.
|
||||
// A selection made while the switch was in flight wins, though.
|
||||
if (requestedId === device.id) requestedId = previousRequestedId;
|
||||
this.logger.error(`Failed to sync ${kind} device with LiveKit`, e);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
syncDevice("audioinput", devices.audioInput.selected$);
|
||||
if (!controlledAudioDevices)
|
||||
|
||||
@@ -394,34 +394,6 @@ describe("Start connection states", () => {
|
||||
expect(connectedState).toEqual(ConnectionState.LivekitConnected);
|
||||
});
|
||||
|
||||
it("stopping while connecting does not report an error", async () => {
|
||||
setupTest();
|
||||
const connection = setupRemoteConnection();
|
||||
|
||||
const capturedStates: (ConnectionState | Error)[] = [];
|
||||
const s = connection.state$.subscribe((value) => {
|
||||
capturedStates.push(value);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
|
||||
// livekit-client rejects a pending connect() when disconnect() is called.
|
||||
const pendingConnect = Promise.withResolvers<void>();
|
||||
fakeLivekitRoom.connect.mockReturnValue(pendingConnect.promise);
|
||||
fakeLivekitRoom.disconnect.mockResolvedValue(undefined);
|
||||
|
||||
const started = connection.start();
|
||||
await vi.waitFor(() => expect(fakeLivekitRoom.connect).toHaveBeenCalled());
|
||||
const stopping = connection.stop();
|
||||
pendingConnect.reject(new Error("Client initiated disconnect"));
|
||||
await stopping;
|
||||
|
||||
// start() resolves rather than rejecting (it is not awaited by the
|
||||
// ConnectionManager, so a rejection would be unhandled).
|
||||
await expect(started).resolves.toBeUndefined();
|
||||
expect(capturedStates.at(-1)).toEqual(ConnectionState.Stopped);
|
||||
expect(capturedStates.some((st) => st instanceof Error)).toBe(false);
|
||||
});
|
||||
|
||||
it("shutting down the scope should stop the connection", async () => {
|
||||
setupTest();
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -373,15 +373,6 @@ export class Connection {
|
||||
// If we were stopped while connecting, don't proceed to update state.
|
||||
if (this.stopped) return;
|
||||
} catch (error) {
|
||||
if (this.stopped) {
|
||||
// stop() was called while we were connecting, which makes the pending
|
||||
// connect reject. That is the abort we asked for, not a failure, so
|
||||
// don't record an error state on a stopped connection or rethrow it
|
||||
// (start() is not awaited by the ConnectionManager, so a throw here
|
||||
// becomes an unhandled promise rejection).
|
||||
this.logger.debug(`Connect aborted because the connection was stopped`);
|
||||
return;
|
||||
}
|
||||
this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
|
||||
this._state$.next(
|
||||
error instanceof ElementCallError
|
||||
@@ -420,11 +411,9 @@ export class Connection {
|
||||
`stop: disconnecing from lk room ${this.transport.livekit_service_url}`,
|
||||
);
|
||||
if (this.stopped) return;
|
||||
// Mark as stopped before disconnecting so that a connect() aborted by the
|
||||
// disconnect sees the flag and does not report the abort as an error.
|
||||
this.stopped = true;
|
||||
await this.livekitRoom.disconnect();
|
||||
this._state$.next(ConnectionState.Stopped);
|
||||
this.stopped = true;
|
||||
this.logger.debug(
|
||||
`stop: DONE disconnecing from lk room ${this.transport.livekit_service_url}`,
|
||||
);
|
||||
|
||||
@@ -174,9 +174,7 @@ function generateRoomOption({
|
||||
publishDefaults,
|
||||
audioCaptureDefaults: {
|
||||
...liveKitOptions.audioCaptureDefaults,
|
||||
// "" is the virtual browser default: leave the constraint out so the
|
||||
// browser captures from the OS default input.
|
||||
deviceId: devices.audioInput.selected$.value?.id || undefined,
|
||||
deviceId: devices.audioInput.selected$.value?.id,
|
||||
echoCancellation: echoCancellationSetting.getValue(),
|
||||
noiseSuppression: noiseSuppressionSetting.getValue(),
|
||||
autoGainControl: autoGainControlSetting.getValue(),
|
||||
|
||||
@@ -36,14 +36,11 @@ export type DeviceLabel =
|
||||
| { type: "name"; name: string }
|
||||
| { type: "number"; number: number };
|
||||
|
||||
export type AudioInputDeviceLabel =
|
||||
| DeviceLabel
|
||||
| { type: "default"; name: string | null };
|
||||
|
||||
export type AudioOutputDeviceLabel =
|
||||
| AudioInputDeviceLabel
|
||||
| DeviceLabel
|
||||
| { type: "speaker" }
|
||||
| { type: "earpiece" };
|
||||
| { type: "earpiece" }
|
||||
| { type: "default"; name: string | null };
|
||||
|
||||
/**
|
||||
* Base selected-device value shared by all media kinds.
|
||||
@@ -194,10 +191,7 @@ function selectDevice$<Label>(
|
||||
});
|
||||
}
|
||||
|
||||
class AudioInput implements MediaDevice<
|
||||
AudioInputDeviceLabel,
|
||||
SelectedAudioInputDevice
|
||||
> {
|
||||
class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
|
||||
private logger = rootLogger.getChild("[MediaDevices AudioInput]");
|
||||
|
||||
private readonly availableRaw$: Behavior<MediaDeviceInfo[]> =
|
||||
@@ -209,29 +203,7 @@ class AudioInput implements MediaDevice<
|
||||
);
|
||||
|
||||
public readonly available$ = this.scope.behavior(
|
||||
this.availableRaw$.pipe(
|
||||
map((availableRaw) => {
|
||||
const available: Map<string, AudioInputDeviceLabel> =
|
||||
buildDeviceMap(availableRaw);
|
||||
// Browsers without a "default" pseudo-device (Firefox, Safari, and
|
||||
// in particular iOS) get a virtual default entry, listed first so
|
||||
// that it is what we use when the user has not chosen a microphone.
|
||||
// Its ID is the empty string, which consumers translate into "no
|
||||
// deviceId constraint": the browser then captures from whatever the
|
||||
// OS routes as the default input (a wired or Bluetooth headset when
|
||||
// one is connected) instead of EC pinning whichever device happens
|
||||
// to be enumerated first, which on iOS is the built-in microphone.
|
||||
// Unlike Chrome's "default" device, a stream opened this way does
|
||||
// not follow later changes of the OS default on desktop browsers;
|
||||
// iOS re-routes the audio session itself.
|
||||
if (available.size && !available.has("") && !available.has("default"))
|
||||
return new Map<string, AudioInputDeviceLabel>([
|
||||
["", { type: "default", name: null }],
|
||||
...available,
|
||||
]);
|
||||
return available;
|
||||
}),
|
||||
),
|
||||
this.availableRaw$.pipe(map(buildDeviceMap)),
|
||||
);
|
||||
|
||||
public readonly selected$ = this.scope.behavior(
|
||||
@@ -299,6 +271,9 @@ export class AudioOutput implements MediaDevice<
|
||||
// set to empty map if we are on Safari, because it does not support setSinkId
|
||||
available = new Map();
|
||||
}
|
||||
// Note: creating virtual default input devices would be another problem
|
||||
// entirely, because requesting a media stream from deviceId "" won't
|
||||
// automatically track the default device.
|
||||
return available;
|
||||
}),
|
||||
),
|
||||
@@ -402,7 +377,7 @@ export class MediaDevices {
|
||||
false,
|
||||
);
|
||||
public readonly audioInput: MediaDevice<
|
||||
AudioInputDeviceLabel,
|
||||
DeviceLabel,
|
||||
SelectedAudioInputDevice
|
||||
> = new AudioInput(this.usingNames$, this.scope);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user