mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-29 21:15:19 +00:00
Compare commits
19 Commits
toger5/pla
...
valere/pla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2eea67b35 | ||
|
|
202e06a16d | ||
|
|
e278752942 | ||
|
|
89c878cf3f | ||
|
|
f41175444b | ||
|
|
102664482b | ||
|
|
d5df9e65dd | ||
|
|
f7255b3697 | ||
|
|
3f8b3ba3f1 | ||
|
|
a99a413b88 | ||
|
|
83df152608 | ||
|
|
f5c31626a6 | ||
|
|
5a48820927 | ||
|
|
971d16d243 | ||
|
|
9b40d7420a | ||
|
|
f71e483080 | ||
|
|
f7590a33d7 | ||
|
|
c74accb906 | ||
|
|
b49411abfa |
@@ -7,6 +7,8 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { SpaHelpers } from "./spa-helpers.ts";
|
||||
|
||||
test("Sign up a new account, then login, then logout", async ({ browser }) => {
|
||||
const userId = `test_user-id_${Date.now()}`;
|
||||
|
||||
@@ -69,12 +71,7 @@ test("As a guest, create a call, share link and other join", async ({
|
||||
// ========
|
||||
// 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 SpaHelpers.createCall(creatorPage, "Inviter", "Welcome");
|
||||
|
||||
// join
|
||||
await creatorPage.getByTestId("lobby_joinCall").click();
|
||||
@@ -82,19 +79,7 @@ test("As a guest, create a call, share link and other join", async ({
|
||||
await creatorPage.getByRole("radio", { name: "Spotlight" }).check();
|
||||
|
||||
// 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/#/");
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(creatorPage);
|
||||
|
||||
// ========
|
||||
// ACT: The other user use the invite link to join the call as a guest
|
||||
@@ -103,13 +88,7 @@ test("As a guest, create a call, share link and other join", async ({
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const guestPage = await guestInviteeContext.newPage();
|
||||
|
||||
await guestPage.goto(inviteLink);
|
||||
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();
|
||||
await guestPage.getByRole("radio", { name: "Spotlight" }).check();
|
||||
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink);
|
||||
|
||||
// ========
|
||||
// ASSERT: check that there are two members in the call
|
||||
|
||||
40
playwright/spa-call-sticky.spec.ts
Normal file
40
playwright/spa-call-sticky.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test } from "@playwright/test";
|
||||
|
||||
import { SpaHelpers } from "./spa-helpers";
|
||||
|
||||
test("One to One call using matrix rtc 2.0 aka sticky events", async ({
|
||||
browser,
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
await SpaHelpers.createCall(page, "John Doe", "HelloCall", true, "2_0");
|
||||
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(page);
|
||||
|
||||
// Other
|
||||
const guestInviteeContext = await browser.newContext({
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const guestPage = await guestInviteeContext.newPage();
|
||||
|
||||
await guestPage.goto("/");
|
||||
|
||||
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink);
|
||||
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
});
|
||||
148
playwright/spa-helpers.ts
Normal file
148
playwright/spa-helpers.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
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 { expect, type Page } from "@playwright/test";
|
||||
|
||||
import { type RtcMode } from "./widget/test-helpers.ts";
|
||||
|
||||
/**
|
||||
* Create and join a call from the SPA home page.
|
||||
*
|
||||
* @param page - The Playwright page object
|
||||
* @param userName - The display name to use for the call
|
||||
* @param callName - The name of the call to create
|
||||
* @param autoJoin - Whether to automatically join the call after creating it
|
||||
* @param mode - The RTC mode to use for the call
|
||||
*/
|
||||
async function createCall(
|
||||
page: Page,
|
||||
userName: string,
|
||||
callName: string,
|
||||
autoJoin: boolean = false,
|
||||
mode: RtcMode | undefined = undefined,
|
||||
): Promise<void> {
|
||||
await page.getByTestId("home_callName").click();
|
||||
await page.getByTestId("home_callName").fill(callName);
|
||||
await page.getByTestId("home_displayName").click();
|
||||
await page.getByTestId("home_displayName").fill(userName);
|
||||
await page.getByTestId("home_go").click();
|
||||
|
||||
await expect(page.locator("video")).toBeVisible();
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
|
||||
if (mode) {
|
||||
await setRtcModeFromSettings(page, mode);
|
||||
}
|
||||
|
||||
if (autoJoin) {
|
||||
// Join the call
|
||||
await page.getByTestId("lobby_joinCall").click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the invite link for the current call.
|
||||
*/
|
||||
async function getCallInviteLink(page: Page): Promise<string> {
|
||||
await page.getByRole("button", { name: "Invite" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Invite to this call" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("img", { name: "QR Code" })).toBeVisible();
|
||||
await expect(page.getByTestId("modal_inviteLink")).toBeVisible();
|
||||
await expect(page.getByTestId("modal_inviteLink")).toBeVisible();
|
||||
await page.getByTestId("modal_inviteLink").click();
|
||||
|
||||
const inviteLink = (await page.evaluate(
|
||||
"navigator.clipboard.readText()",
|
||||
)) as string;
|
||||
expect(inviteLink).toContain("room/#/");
|
||||
|
||||
return inviteLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a call from an invitation link.
|
||||
* @param page - The Playwright page object
|
||||
* @param inviteLink - The invite link to join
|
||||
* @param displayName - The display name to use when joining the call
|
||||
* @param mode - The RTC mode to use for the call
|
||||
*/
|
||||
async function joinCallFromInviteLink(
|
||||
page: Page,
|
||||
inviteLink: string,
|
||||
displayName: string = "Invitee",
|
||||
mode: RtcMode | undefined = undefined,
|
||||
): Promise<void> {
|
||||
await page.goto(inviteLink);
|
||||
await page.getByTestId("joincall_displayName").fill(displayName);
|
||||
await expect(page.getByTestId("joincall_joincall")).toBeVisible();
|
||||
await page.getByTestId("joincall_joincall").click();
|
||||
|
||||
if (mode) {
|
||||
await setRtcModeFromSettings(page, mode);
|
||||
}
|
||||
|
||||
await page.getByTestId("lobby_joinCall").click();
|
||||
await page.getByRole("radio", { name: "Spotlight" }).check();
|
||||
}
|
||||
|
||||
async function setRtcModeFromSettings(
|
||||
page: Page,
|
||||
mode: RtcMode,
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
await page.getByRole("tab", { name: "Preferences" }).click();
|
||||
await page.getByText("Developer mode", { exact: true }).check(); // Idempotent: won't uncheck if already checked
|
||||
|
||||
// Move to Developer tab now
|
||||
await page.getByRole("tab", { name: "Developer" }).click();
|
||||
if (mode == "legacy") {
|
||||
await page.getByText("Legacy: state events").click();
|
||||
} else if (mode == "2_0") {
|
||||
await page.getByText("Matrix 2.0").click();
|
||||
} else {
|
||||
// compat
|
||||
await page.getByText("Compatibility: state events").click();
|
||||
}
|
||||
|
||||
await page.getByTestId("modal_close").click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Expect a certain number of video tiles to be present and visible.
|
||||
*/
|
||||
async function expectVideoTilesCount(page: Page, count: number): Promise<void> {
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(2);
|
||||
|
||||
// There are no other options than to wait for all media to be ready?
|
||||
// Or it is too flaky :/
|
||||
await page.waitForTimeout(3000);
|
||||
// No one should be waiting for media
|
||||
await expect(page.getByText("Waiting for media...")).not.toBeVisible();
|
||||
|
||||
// There should be 5 video elements, visible and autoplaying
|
||||
const videoElements = await page.locator("video").all();
|
||||
expect(videoElements.length).toBe(count);
|
||||
|
||||
const blockDisplayCount = await page
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(count);
|
||||
}
|
||||
|
||||
export const SpaHelpers = {
|
||||
createCall,
|
||||
getCallInviteLink,
|
||||
joinCallFromInviteLink,
|
||||
expectVideoTilesCount,
|
||||
};
|
||||
@@ -27,9 +27,6 @@ import { HOST1, HOST2, TestHelpers } from "./test-helpers";
|
||||
widgetTest(
|
||||
`Test swapping publisher from ${HOST1} to ${HOST2}`,
|
||||
async ({ addUser, browserName }) => {
|
||||
// ALWAYS SKIPT THE TEST SINCE IT IS EXPECTED TO FAIL.
|
||||
// confirmed locally that its failing without: https://github.com/element-hq/element-call/pull/3675
|
||||
test.skip(true);
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
|
||||
@@ -78,6 +78,7 @@ export function LivekitRoomAudioRenderer({
|
||||
.filter((ref) => {
|
||||
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.
|
||||
prefixedLogger.warn(
|
||||
`Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`,
|
||||
|
||||
@@ -316,6 +316,7 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
})}
|
||||
</h4>
|
||||
<p>LivekitAlias: {livekitRoom.livekitAlias}</p>
|
||||
<p>connectionState (wont hot reload): {livekitRoom.room.state}</p>
|
||||
{livekitRoom.isLocal && <p>ws-url: {localSfuUrl?.href}</p>}
|
||||
<p>
|
||||
{t("developer_mode.livekit_server_info")}(
|
||||
|
||||
@@ -359,6 +359,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
LivekitAlias:
|
||||
TestAlias
|
||||
</p>
|
||||
<p>
|
||||
connectionState (wont hot reload):
|
||||
</p>
|
||||
<p>
|
||||
ws-url:
|
||||
wss://local-sfu.example.org/
|
||||
@@ -401,6 +404,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
LivekitAlias:
|
||||
TestAlias2
|
||||
</p>
|
||||
<p>
|
||||
connectionState (wont hot reload):
|
||||
</p>
|
||||
<p>
|
||||
LiveKit Server Info
|
||||
(
|
||||
|
||||
@@ -529,7 +529,6 @@ export function createCallViewModel$(
|
||||
},
|
||||
createPublisherFactory: (connection: Connection) => {
|
||||
return new Publisher(
|
||||
scope,
|
||||
connection,
|
||||
mediaDevices,
|
||||
muteStates,
|
||||
|
||||
@@ -297,6 +297,9 @@ describe("LocalMembership", () => {
|
||||
seed += 1;
|
||||
logger.info(`creating [${a}]`);
|
||||
const p = {
|
||||
// It is enought to check if destroy is called. Destroy itself is tested in the publisher to make sure it does
|
||||
// all the cleanup we need.
|
||||
destroy: vi.fn(),
|
||||
stopPublishing: vi.fn().mockImplementation(() => {
|
||||
logger.info(`stopPublishing [${a}]`);
|
||||
}),
|
||||
@@ -325,13 +328,12 @@ describe("LocalMembership", () => {
|
||||
await flushPromises();
|
||||
localTransport$.next(bTransport);
|
||||
await flushPromises();
|
||||
|
||||
expect(publisherFactory).toHaveBeenCalledTimes(2);
|
||||
expect(publishers.length).toBe(2);
|
||||
// stop the first Publisher and let the second one life.
|
||||
expect(publishers[0].stopTracks).toHaveBeenCalled();
|
||||
expect(publishers[1].stopTracks).not.toHaveBeenCalled();
|
||||
expect(publishers[0].stopPublishing).toHaveBeenCalled();
|
||||
expect(publishers[1].stopPublishing).not.toHaveBeenCalled();
|
||||
expect(publishers[0].destroy).toHaveBeenCalled();
|
||||
expect(publishers[1].destroy).not.toHaveBeenCalled();
|
||||
expect(publisherFactory.mock.calls[0][0].transport).toBe(
|
||||
aTransport.transport,
|
||||
);
|
||||
@@ -341,7 +343,7 @@ describe("LocalMembership", () => {
|
||||
scope.end();
|
||||
await flushPromises();
|
||||
// stop all tracks after ending scopes
|
||||
expect(publishers[1].stopPublishing).toHaveBeenCalled();
|
||||
expect(publishers[1].destroy).toHaveBeenCalled();
|
||||
// expect(publishers[1].stopTracks).toHaveBeenCalled();
|
||||
|
||||
defaultCreateLocalMemberValues.createPublisherFactory.mockReset();
|
||||
@@ -359,8 +361,9 @@ describe("LocalMembership", () => {
|
||||
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
|
||||
() => {
|
||||
const p = {
|
||||
stopPublishing: vi.fn(),
|
||||
stopTracks: vi.fn(),
|
||||
// It is enought to check if destroy is called. Destroy itself is tested in the publisher to make sure it does
|
||||
// all the cleanup we need.
|
||||
destroy: vi.fn(),
|
||||
createAndSetupTracks: vi.fn().mockImplementation(async () => {
|
||||
tracks$.next([{}, {}] as LocalTrack[]);
|
||||
return Promise.resolve();
|
||||
@@ -395,11 +398,11 @@ describe("LocalMembership", () => {
|
||||
localMembership.startTracks();
|
||||
await flushPromises();
|
||||
expect(publishers[0].createAndSetupTracks).toHaveBeenCalled();
|
||||
// expect(localMembership.tracks$.value.length).toBe(2);
|
||||
|
||||
scope.end();
|
||||
await flushPromises();
|
||||
// stop all tracks after ending scopes
|
||||
expect(publishers[0].stopPublishing).toHaveBeenCalled();
|
||||
expect(publishers[0].destroy).toHaveBeenCalled();
|
||||
// expect(publishers[0].stopTracks).toHaveBeenCalled();
|
||||
publisherFactory.mockClear();
|
||||
});
|
||||
@@ -416,27 +419,22 @@ describe("LocalMembership", () => {
|
||||
);
|
||||
const publishers: Publisher[] = [];
|
||||
|
||||
const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
|
||||
const publishing$ = new BehaviorSubject<boolean>(false);
|
||||
const createTrackResolver = Promise.withResolvers<void>();
|
||||
const publishResolver = Promise.withResolvers<void>();
|
||||
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
|
||||
() => {
|
||||
const p = {
|
||||
stopPublishing: vi.fn(),
|
||||
stopTracks: vi.fn().mockImplementation(() => {
|
||||
logger.info("stopTracks");
|
||||
tracks$.next([]);
|
||||
}),
|
||||
// It is enought to check if destroy is called. Destroy itself is tested in the publisher to make sure it does
|
||||
// all the cleanup we need.
|
||||
destroy: vi.fn(),
|
||||
createAndSetupTracks: vi.fn().mockImplementation(async () => {
|
||||
await createTrackResolver.promise;
|
||||
tracks$.next([{}, {}] as LocalTrack[]);
|
||||
}),
|
||||
startPublishing: vi.fn().mockImplementation(async () => {
|
||||
await publishResolver.promise;
|
||||
publishing$.next(true);
|
||||
}),
|
||||
tracks$,
|
||||
publishing$,
|
||||
};
|
||||
publishers.push(p as unknown as Publisher);
|
||||
@@ -536,7 +534,7 @@ describe("LocalMembership", () => {
|
||||
(localMembership.localMemberState$.value as any).media,
|
||||
).toStrictEqual(PublishState.Publishing);
|
||||
|
||||
expect(publishers[0].stopPublishing).not.toHaveBeenCalled();
|
||||
expect(publishers[0].destroy).not.toHaveBeenCalled();
|
||||
|
||||
expect(localMembership.localMemberState$.isStopped).toBe(false);
|
||||
scope.end();
|
||||
@@ -547,7 +545,7 @@ describe("LocalMembership", () => {
|
||||
(localMembership.localMemberState$.value as any).media,
|
||||
).toStrictEqual(PublishState.Publishing);
|
||||
// stop all tracks after ending scopes
|
||||
expect(publishers[0].stopPublishing).toHaveBeenCalled();
|
||||
expect(publishers[0].destroy).toHaveBeenCalled();
|
||||
// expect(publishers[0].stopTracks).toHaveBeenCalled();
|
||||
});
|
||||
// TODO add tests for matrix local matrix participation.
|
||||
|
||||
@@ -310,13 +310,17 @@ export const createLocalMembership$ = ({
|
||||
// - destruct all current streams
|
||||
// - overwrite current publisher
|
||||
scope.reconcile(localConnection$, async (connection) => {
|
||||
logger.info(
|
||||
"reconcile based on new localConnection:",
|
||||
connection?.transport.livekit_service_url,
|
||||
);
|
||||
if (connection !== null) {
|
||||
const publisher = createPublisherFactory(connection);
|
||||
publisher$.next(publisher);
|
||||
|
||||
// Clean-up callback
|
||||
return Promise.resolve(async (): Promise<void> => {
|
||||
await publisher.stopPublishing();
|
||||
await publisher.stopTracks();
|
||||
await publisher.destroy();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,9 +16,9 @@ import { MatrixError, type MatrixClient } from "matrix-js-sdk";
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
first,
|
||||
from,
|
||||
map,
|
||||
of,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
@@ -126,36 +126,41 @@ export const createLocalTransport$ = ({
|
||||
* The transport over which we should be actively publishing our media.
|
||||
* undefined when not joined.
|
||||
*/
|
||||
const oldestMemberTransport$ = scope.behavior(
|
||||
combineLatest([memberships$]).pipe(
|
||||
map(([memberships]) => {
|
||||
const oldestMember = memberships.value[0];
|
||||
const transport = oldestMember?.getTransport(memberships.value[0]);
|
||||
if (!transport) return null;
|
||||
return transport;
|
||||
}),
|
||||
first((t) => t != null && isLivekitTransport(t)),
|
||||
switchMap((transport) => {
|
||||
// Get the open jwt token to connect to the sfu
|
||||
const computeLocalTransportWithSFUConfig =
|
||||
async (): Promise<LocalTransportWithSFUConfig> => {
|
||||
return {
|
||||
transport,
|
||||
sfuConfig: await getSFUConfigWithOpenID(
|
||||
client,
|
||||
ownMembershipIdentity,
|
||||
transport.livekit_service_url,
|
||||
roomId,
|
||||
{ forceJwtEndpoint: JwtEndpointVersion.Legacy },
|
||||
logger,
|
||||
),
|
||||
};
|
||||
};
|
||||
return from(computeLocalTransportWithSFUConfig());
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
const oldestMemberTransport$ =
|
||||
scope.behavior<LocalTransportWithSFUConfig | null>(
|
||||
combineLatest([memberships$, useOldestMember$]).pipe(
|
||||
map(([memberships, useOldestMember]) => {
|
||||
if (!useOldestMember) return null; // No need to do any prefetching if not using oldest member
|
||||
const oldestMember = memberships.value[0];
|
||||
const transport = oldestMember?.getTransport(oldestMember);
|
||||
if (!transport) return null;
|
||||
return transport;
|
||||
}),
|
||||
switchMap((transport) => {
|
||||
if (transport !== null && isLivekitTransport(transport)) {
|
||||
// Get the open jwt token to connect to the sfu
|
||||
const computeLocalTransportWithSFUConfig =
|
||||
async (): Promise<LocalTransportWithSFUConfig> => {
|
||||
// await sleep(1000);
|
||||
return {
|
||||
transport,
|
||||
sfuConfig: await getSFUConfigWithOpenID(
|
||||
client,
|
||||
ownMembershipIdentity,
|
||||
transport.livekit_service_url,
|
||||
roomId,
|
||||
{ forceJwtEndpoint: JwtEndpointVersion.Legacy },
|
||||
logger,
|
||||
),
|
||||
};
|
||||
};
|
||||
return from(computeLocalTransportWithSFUConfig());
|
||||
}
|
||||
return of(null);
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
/**
|
||||
* The transport that we would personally prefer to publish on (if not for the
|
||||
@@ -200,14 +205,23 @@ export const createLocalTransport$ = ({
|
||||
oldestMemberTransport$,
|
||||
preferredTransport$,
|
||||
]).pipe(
|
||||
map(([useOldestMember, oldestMemberTransport, preferredTransport]) =>
|
||||
useOldestMember
|
||||
map(([useOldestMember, oldestMemberTransport, preferredTransport]) => {
|
||||
return useOldestMember
|
||||
? (oldestMemberTransport ?? preferredTransport)
|
||||
: preferredTransport,
|
||||
),
|
||||
distinctUntilChanged((t1, t2) =>
|
||||
areLivekitTransportsEqual(t1?.transport ?? null, t2?.transport ?? null),
|
||||
),
|
||||
: preferredTransport;
|
||||
}),
|
||||
distinctUntilChanged((t1, t2) => {
|
||||
logger.info(
|
||||
"Local Transport Update from:",
|
||||
t1?.transport.livekit_service_url,
|
||||
" to ",
|
||||
t2?.transport.livekit_service_url,
|
||||
);
|
||||
return areLivekitTransportsEqual(
|
||||
t1?.transport ?? null,
|
||||
t2?.transport ?? null,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -183,7 +183,6 @@ describe("Publisher", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
publisher = new Publisher(
|
||||
scope,
|
||||
connection,
|
||||
mockMediaDevices({}),
|
||||
muteStates,
|
||||
@@ -192,7 +191,9 @@ describe("Publisher", () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {});
|
||||
afterEach(async () => {
|
||||
await publisher.destroy();
|
||||
});
|
||||
|
||||
it("Should not create tracks if started muted to avoid unneeded permission requests", async () => {
|
||||
const createTracksSpy = vi.spyOn(
|
||||
@@ -207,6 +208,38 @@ describe("Publisher", () => {
|
||||
expect(createTracksSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should unsetHandler and stop tracks on destroy", async () => {
|
||||
// setup all spies
|
||||
const unsetVideoSpy = vi.spyOn(
|
||||
(
|
||||
publisher as unknown as {
|
||||
muteStates: { video: { unsetHandler: () => void } };
|
||||
}
|
||||
).muteStates.video,
|
||||
"unsetHandler",
|
||||
);
|
||||
const unsetAudioSpy = vi.spyOn(
|
||||
(
|
||||
publisher as unknown as {
|
||||
muteStates: { audio: { unsetHandler: () => void } };
|
||||
}
|
||||
).muteStates.audio,
|
||||
"unsetHandler",
|
||||
);
|
||||
const scopeEndSpy = vi.spyOn(
|
||||
(publisher as unknown as { scope: { end: () => void } }).scope,
|
||||
"end",
|
||||
);
|
||||
const stopTracksSpy = vi.spyOn(publisher, "stopTracks");
|
||||
// destroy publisher
|
||||
await publisher.destroy();
|
||||
|
||||
expect(stopTracksSpy).toHaveBeenCalledOnce();
|
||||
expect(unsetVideoSpy).toHaveBeenCalledOnce();
|
||||
expect(unsetAudioSpy).toHaveBeenCalledOnce();
|
||||
expect(scopeEndSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Should minimize permission request by querying create at once", async () => {
|
||||
const enableCameraAndMicrophoneSpy = vi.spyOn(
|
||||
localParticipant,
|
||||
@@ -267,7 +300,6 @@ describe("Publisher", () => {
|
||||
let publisher: Publisher;
|
||||
beforeEach(() => {
|
||||
publisher = new Publisher(
|
||||
scope,
|
||||
connection,
|
||||
mockMediaDevices({}),
|
||||
muteStates,
|
||||
@@ -275,6 +307,9 @@ describe("Publisher", () => {
|
||||
logger,
|
||||
);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await publisher.destroy();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ mutes: { audioEnabled: true, videoEnabled: false } },
|
||||
@@ -320,7 +355,6 @@ describe("Bug fix", () => {
|
||||
it("wrongly publish tracks while muted", async () => {
|
||||
// setLogLevel(`debug`);
|
||||
const publisher = new Publisher(
|
||||
scope,
|
||||
connection,
|
||||
mockMediaDevices({}),
|
||||
muteStates,
|
||||
@@ -356,5 +390,6 @@ describe("Bug fix", () => {
|
||||
expect(track!.mute).toHaveBeenCalled();
|
||||
expect(track!.isMuted).toBe(true);
|
||||
}
|
||||
await publisher.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import { getUrlParams } from "../../../UrlParams.ts";
|
||||
import { observeTrackReference$ } from "../../MediaViewModel.ts";
|
||||
import { type Connection } from "../remoteMembers/Connection.ts";
|
||||
import { type ObservableScope } from "../../ObservableScope.ts";
|
||||
import { ObservableScope } from "../../ObservableScope.ts";
|
||||
|
||||
/**
|
||||
* A wrapper for a Connection object.
|
||||
@@ -47,9 +47,10 @@ export class Publisher {
|
||||
*/
|
||||
public shouldPublish = false;
|
||||
|
||||
private readonly scope = new ObservableScope();
|
||||
|
||||
/**
|
||||
* Creates a new Publisher.
|
||||
* @param scope - The observable scope to use for managing the publisher.
|
||||
* @param connection - The connection to use for publishing.
|
||||
* @param devices - The media devices to use for audio and video input.
|
||||
* @param muteStates - The mute states for audio and video.
|
||||
@@ -57,7 +58,6 @@ export class Publisher {
|
||||
* @param logger - The logger to use for logging :D.
|
||||
*/
|
||||
public constructor(
|
||||
private scope: ObservableScope,
|
||||
private connection: Pick<Connection, "livekitRoom" | "state$">, //setE2EEEnabled,
|
||||
devices: MediaDevices,
|
||||
private readonly muteStates: MuteStates,
|
||||
@@ -65,7 +65,6 @@ export class Publisher {
|
||||
private logger: Logger,
|
||||
) {
|
||||
const { controlledAudioDevices } = getUrlParams();
|
||||
|
||||
const room = connection.livekitRoom;
|
||||
|
||||
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
|
||||
@@ -73,17 +72,11 @@ export class Publisher {
|
||||
});
|
||||
|
||||
// Setup track processor syncing (blur)
|
||||
this.observeTrackProcessors(scope, room, trackerProcessorState$);
|
||||
this.observeTrackProcessors(this.scope, room, trackerProcessorState$);
|
||||
// Observe media device changes and update LiveKit active devices accordingly
|
||||
this.observeMediaDevices(scope, devices, controlledAudioDevices);
|
||||
this.observeMediaDevices(this.scope, devices, controlledAudioDevices);
|
||||
|
||||
this.workaroundRestartAudioInputTrackChrome(devices, scope);
|
||||
this.scope.onEnd(() => {
|
||||
this.logger.info("Scope ended -> stop publishing all tracks");
|
||||
void this.stopPublishing();
|
||||
muteStates.audio.unsetHandler();
|
||||
muteStates.video.unsetHandler();
|
||||
});
|
||||
this.workaroundRestartAudioInputTrackChrome(devices, this.scope);
|
||||
|
||||
this.connection.livekitRoom.localParticipant.on(
|
||||
ParticipantEvent.LocalTrackPublished,
|
||||
@@ -91,6 +84,21 @@ export class Publisher {
|
||||
);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
this.scope.end();
|
||||
this.logger.info("Scope ended -> unset handler");
|
||||
this.muteStates.audio.unsetHandler();
|
||||
this.muteStates.video.unsetHandler();
|
||||
|
||||
this.logger.info(`Start to stop tracks`);
|
||||
try {
|
||||
await this.stopTracks();
|
||||
this.logger.info(`Done to stop tracks`);
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to stop tracks: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// LiveKit will publish the tracks as soon as they are created
|
||||
// but we want to control when tracks are published.
|
||||
// We cannot just mute the tracks, even if this will effectively stop the publishing,
|
||||
|
||||
@@ -155,6 +155,16 @@ export class Connection {
|
||||
const { url, jwt, livekitAlias } =
|
||||
this.existingSFUConfig ??
|
||||
(await this.getSFUConfigForRemoteConnection());
|
||||
this.logger.debug(
|
||||
"Starting Connection to: ",
|
||||
this.transport.livekit_service_url,
|
||||
"jwt: ",
|
||||
jwt,
|
||||
"wss: ",
|
||||
url,
|
||||
"livekitAlias: ",
|
||||
livekitAlias,
|
||||
);
|
||||
this._livekitAlias = livekitAlias;
|
||||
// If we were stopped while fetching the config, don't proceed to connect
|
||||
if (this.stopped) return;
|
||||
@@ -171,8 +181,11 @@ export class Connection {
|
||||
});
|
||||
|
||||
try {
|
||||
this.logger.info(`livekitRoom.connect ${url}`);
|
||||
await this.livekitRoom.connect(url, jwt);
|
||||
this.logger.info(`livekitRoom.connect SUCCESS ${url}`);
|
||||
} catch (e) {
|
||||
this.logger.info(`livekitRoom.connect FAILED ${url}`, e);
|
||||
// LiveKit uses 503 to indicate that the server has hit its track limits.
|
||||
// https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
|
||||
// It also errors with a status code of 200 (yes, really) for room
|
||||
@@ -233,12 +246,15 @@ export class Connection {
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
this.logger.debug(
|
||||
`Stopping connection to ${this.transport.livekit_service_url}`,
|
||||
`stop: disconnecing from lk room ${this.transport.livekit_service_url}`,
|
||||
);
|
||||
if (this.stopped) return;
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
|
||||
private readonly client: OpenIDClientParts;
|
||||
@@ -255,9 +271,11 @@ export class Connection {
|
||||
public constructor(opts: ConnectionOpts, logger: Logger) {
|
||||
this.ownMembershipIdentity = opts.ownMembershipIdentity;
|
||||
this.existingSFUConfig = opts.existingSFUConfig;
|
||||
this.logger = logger.getChild("[Connection]");
|
||||
this.logger = logger.getChild(
|
||||
"[Connection " + opts.transport.livekit_service_url + "]",
|
||||
);
|
||||
this.logger.info(
|
||||
`Creating new connection to ${opts.transport.livekit_service_url} ${opts.transport.livekit_alias}`,
|
||||
`constructor: ${opts.transport.livekit_service_url} alias: ${opts.transport.livekit_alias} withSfuConfig?: ${opts.existingSFUConfig ? JSON.stringify(opts.existingSFUConfig) : "undefined"}`,
|
||||
);
|
||||
const { transport, client, scope } = opts;
|
||||
|
||||
|
||||
@@ -189,10 +189,6 @@ export function createConnectionManager$({
|
||||
}
|
||||
},
|
||||
(scope, _data$, serviceUrl, alias, sfuConfig) => {
|
||||
logger.debug(
|
||||
`Creating connection to ${serviceUrl} (${alias}, withSfuConfig (local connection?): ${JSON.stringify(sfuConfig) ?? "no config->remote connection"})`,
|
||||
);
|
||||
|
||||
const connection = connectionFactory.createConnection(
|
||||
scope,
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user