Compare commits

...

19 Commits

Author SHA1 Message Date
Valere
c2eea67b35 Merge branch 'livekit' into valere/playwright/spa_sticky_test 2026-01-16 17:41:39 +01:00
Valere
202e06a16d skip FF 2026-01-16 17:41:32 +01:00
Timo
e278752942 Merge pull request #3675 from element-hq/toger5/fixing-duble-publishing
Fix Publisher cleanup procedure
2026-01-16 17:37:46 +01:00
Valere
89c878cf3f playwright: Test call in SPA mode with matrix 2.0 2026-01-16 17:11:45 +01:00
Timo K
f41175444b revert changes unrelated to this PR 2026-01-16 16:23:05 +01:00
Timo K
102664482b Activate the hotswap test that was failing before. 2026-01-16 15:41:25 +01:00
Timo K
d5df9e65dd Merge branch 'livekit' into toger5/fixing-duble-publishing 2026-01-16 15:38:56 +01:00
Timo
f7255b3697 Merge pull request #3681 from element-hq/toger5/playwright-publisher-swap
Add playwright test for hotswatpping the transport (swap from `oldest_member` (singeSFU) to `multi_sfu`)
2026-01-16 15:34:15 +01:00
Timo K
3f8b3ba3f1 add test to check if publisher is properly destroyed 2026-01-16 14:41:11 +01:00
Timo K
a99a413b88 temp 2026-01-16 14:07:56 +01:00
Timo K
83df152608 review 2026-01-16 13:20:03 +01:00
Timo K
f5c31626a6 fix unit tests 2026-01-16 12:43:13 +01:00
Timo K
5a48820927 remove widget layout widget action for good. 2026-01-16 10:22:23 +01:00
Timo K
971d16d243 revert jwt to stable version 2026-01-16 10:21:26 +01:00
Timo K
9b40d7420a refactor to use destroy method 2026-01-15 19:04:32 +01:00
Timo K
f71e483080 logging updates 2026-01-15 18:13:34 +01:00
Timo K
f7590a33d7 Fix publisher clean up! This is the acutal bug we chased 2026-01-15 18:12:00 +01:00
Timo K
c74accb906 remove "fetching" state again (we have to be able to deal with those
kind of switches!) Optimize to not always fetch the oldest member jwt
(only do so in legacy mode)
2026-01-15 18:11:08 +01:00
Timo K
b49411abfa dont set localTransport while still fetching oldest member transport 2026-01-14 17:35:35 +01:00
15 changed files with 357 additions and 113 deletions

View File

@@ -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

View 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
View 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,
};

View File

@@ -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",

View File

@@ -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`,

View File

@@ -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")}(

View File

@@ -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
(

View File

@@ -529,7 +529,6 @@ export function createCallViewModel$(
},
createPublisherFactory: (connection: Connection) => {
return new Publisher(
scope,
connection,
mediaDevices,
muteStates,

View File

@@ -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.

View File

@@ -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();
});
}
});

View File

@@ -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,
);
}),
),
);
};

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,
{