This commit is contained in:
Timo K
2025-12-01 14:42:15 +01:00
parent 24ed43ce13
commit 47c6a17d1e
5 changed files with 102 additions and 62 deletions

View File

@@ -101,7 +101,7 @@ import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts
import { import {
createLocalMembership$, createLocalMembership$,
enterRTCSession, enterRTCSession,
LivekitState, RTCBackendState,
} from "./localMember/LocalMembership.ts"; } from "./localMember/LocalMembership.ts";
import { createLocalTransport$ } from "./localMember/LocalTransport.ts"; import { createLocalTransport$ } from "./localMember/LocalTransport.ts";
import { import {
@@ -473,6 +473,9 @@ export function createCallViewModel$(
mediaDevices, mediaDevices,
muteStates, muteStates,
trackProcessorState$, trackProcessorState$,
logger.getChild(
"[Publisher" + connection.transport.livekit_service_url + "]",
),
); );
}, },
connectionManager: connectionManager, connectionManager: connectionManager,
@@ -664,7 +667,7 @@ export function createCallViewModel$(
{ value: matrixLivekitMembers }, { value: matrixLivekitMembers },
duplicateTiles, duplicateTiles,
]) { ]) {
let localParticipantId = undefined; let localParticipantId: string | undefined = undefined;
// add local member if available // add local member if available
if (localMatrixLivekitMember) { if (localMatrixLivekitMember) {
const { userId, participant$, connection$, membership$ } = const { userId, participant$, connection$, membership$ } =
@@ -1452,7 +1455,7 @@ export function createCallViewModel$(
fatalError$: scope.behavior( fatalError$: scope.behavior(
localMembership.connectionState.livekit$.pipe( localMembership.connectionState.livekit$.pipe(
filter((v) => v.state === LivekitState.Error), filter((v) => v.state === RTCBackendState.Error),
map((s) => s.error), map((s) => s.error),
), ),
null, null,

View File

@@ -27,7 +27,7 @@ import {
import { import {
createLocalMembership$, createLocalMembership$,
enterRTCSession, enterRTCSession,
LivekitState, RTCBackendState,
} from "./LocalMembership"; } from "./LocalMembership";
import { MatrixRTCTransportMissingError } from "../../../utils/errors"; import { MatrixRTCTransportMissingError } from "../../../utils/errors";
import { Epoch, ObservableScope } from "../../ObservableScope"; import { Epoch, ObservableScope } from "../../ObservableScope";
@@ -225,9 +225,9 @@ describe("LocalMembership", () => {
}); });
expectObservable(localMembership.connectionState.livekit$).toBe("ne", { expectObservable(localMembership.connectionState.livekit$).toBe("ne", {
n: { state: LivekitState.WaitingForConnection }, n: { state: RTCBackendState.WaitingForConnection },
e: { e: {
state: LivekitState.Error, state: RTCBackendState.Error,
error: expect.toSatisfy( error: expect.toSatisfy(
(e) => e instanceof MatrixRTCTransportMissingError, (e) => e instanceof MatrixRTCTransportMissingError,
), ),
@@ -428,17 +428,17 @@ describe("LocalMembership", () => {
await flushPromises(); await flushPromises();
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(localMembership.connectionState.livekit$.value).toStrictEqual({
state: LivekitState.WaitingForTransport, state: RTCBackendState.WaitingForTransport,
}); });
localTransport$.next(aTransport); localTransport$.next(aTransport);
await flushPromises(); await flushPromises();
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(localMembership.connectionState.livekit$.value).toStrictEqual({
state: LivekitState.WaitingForConnection, state: RTCBackendState.WaitingForConnection,
}); });
connectionManagerData$.next(new Epoch(connectionManagerData)); connectionManagerData$.next(new Epoch(connectionManagerData));
await flushPromises(); await flushPromises();
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(localMembership.connectionState.livekit$.value).toStrictEqual({
state: LivekitState.Initialized, state: RTCBackendState.Initialized,
}); });
expect(publisherFactory).toHaveBeenCalledOnce(); expect(publisherFactory).toHaveBeenCalledOnce();
expect(localMembership.tracks$.value.length).toBe(0); expect(localMembership.tracks$.value.length).toBe(0);
@@ -449,12 +449,12 @@ describe("LocalMembership", () => {
await flushPromises(); await flushPromises();
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(localMembership.connectionState.livekit$.value).toStrictEqual({
state: LivekitState.CreatingTracks, state: RTCBackendState.CreatingTracks,
}); });
createTrackResolver.resolve(); createTrackResolver.resolve();
await flushPromises(); await flushPromises();
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(localMembership.connectionState.livekit$.value).toStrictEqual({
state: LivekitState.ReadyToPublish, state: RTCBackendState.ReadyToPublish,
}); });
// ------- // -------
@@ -462,13 +462,13 @@ describe("LocalMembership", () => {
// ------- // -------
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(localMembership.connectionState.livekit$.value).toStrictEqual({
state: LivekitState.WaitingToPublish, state: RTCBackendState.WaitingToPublish,
}); });
publishResolver.resolve(); publishResolver.resolve();
await flushPromises(); await flushPromises();
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(localMembership.connectionState.livekit$.value).toStrictEqual({
state: LivekitState.Connected, state: RTCBackendState.Connected,
}); });
expect(publishers[0].stopPublishing).not.toHaveBeenCalled(); expect(publishers[0].stopPublishing).not.toHaveBeenCalled();
@@ -477,7 +477,7 @@ describe("LocalMembership", () => {
await flushPromises(); await flushPromises();
// stays in connected state because it is stopped before the update to tracks update the state. // stays in connected state because it is stopped before the update to tracks update the state.
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(localMembership.connectionState.livekit$.value).toStrictEqual({
state: LivekitState.Connected, state: RTCBackendState.Connected,
}); });
// stop all tracks after ending scopes // stop all tracks after ending scopes
expect(publishers[0].stopPublishing).toHaveBeenCalled(); expect(publishers[0].stopPublishing).toHaveBeenCalled();

View File

@@ -11,6 +11,7 @@ import {
ParticipantEvent, ParticipantEvent,
type LocalParticipant, type LocalParticipant,
type ScreenShareCaptureOptions, type ScreenShareCaptureOptions,
ConnectionState,
} from "livekit-client"; } from "livekit-client";
import { observeParticipantEvents } from "@livekit/components-core"; import { observeParticipantEvents } from "@livekit/components-core";
import { import {
@@ -52,7 +53,7 @@ import { MatrixRTCMode } from "../../../settings/settings.ts";
import { Config } from "../../../config/Config.ts"; import { Config } from "../../../config/Config.ts";
import { type Connection } from "../remoteMembers/Connection.ts"; import { type Connection } from "../remoteMembers/Connection.ts";
export enum LivekitState { export enum RTCBackendState {
Error = "error", Error = "error",
/** Not even a transport is available to the LocalMembership */ /** Not even a transport is available to the LocalMembership */
WaitingForTransport = "waiting_for_transport", WaitingForTransport = "waiting_for_transport",
@@ -68,17 +69,17 @@ export enum LivekitState {
Disconnecting = "disconnecting", Disconnecting = "disconnecting",
} }
type LocalMemberLivekitState = type LocalMemberRtcBackendState =
| { state: LivekitState.Error; error: ElementCallError } | { state: RTCBackendState.Error; error: ElementCallError }
| { state: LivekitState.WaitingForTransport } | { state: RTCBackendState.WaitingForTransport }
| { state: LivekitState.WaitingForConnection } | { state: RTCBackendState.WaitingForConnection }
| { state: LivekitState.Initialized } | { state: RTCBackendState.Initialized }
| { state: LivekitState.CreatingTracks } | { state: RTCBackendState.CreatingTracks }
| { state: LivekitState.ReadyToPublish } | { state: RTCBackendState.ReadyToPublish }
| { state: LivekitState.WaitingToPublish } | { state: RTCBackendState.WaitingToPublish }
| { state: LivekitState.Connected } | { state: RTCBackendState.Connected }
| { state: LivekitState.Disconnected } | { state: RTCBackendState.Disconnected }
| { state: LivekitState.Disconnecting }; | { state: RTCBackendState.Disconnecting };
export enum MatrixState { export enum MatrixState {
WaitingForTransport = "waiting_for_transport", WaitingForTransport = "waiting_for_transport",
@@ -98,7 +99,7 @@ type LocalMemberMatrixState =
| { state: MatrixState.Error; error: Error }; | { state: MatrixState.Error; error: Error };
export interface LocalMemberConnectionState { export interface LocalMemberConnectionState {
livekit$: Behavior<LocalMemberLivekitState>; livekit$: Behavior<LocalMemberRtcBackendState>;
matrix$: Behavior<LocalMemberMatrixState>; matrix$: Behavior<LocalMemberMatrixState>;
} }
@@ -155,8 +156,15 @@ export const createLocalMembership$ = ({
muteStates, muteStates,
matrixRTCSession, matrixRTCSession,
}: Props): { }: Props): {
requestConnect: () => void; /**
* This starts audio and video tracks. They will be reused when calling `requestConnect`.
*/
startTracks: () => Behavior<LocalTrack[]>; startTracks: () => Behavior<LocalTrack[]>;
/**
* This sets a inner state (shouldConnect) to true and instructs the js-sdk and livekit to keep the user
* connected to matrix and livekit.
*/
requestConnect: () => void;
requestDisconnect: () => void; requestDisconnect: () => void;
connectionState: LocalMemberConnectionState; connectionState: LocalMemberConnectionState;
sharingScreen$: Behavior<boolean>; sharingScreen$: Behavior<boolean>;
@@ -228,7 +236,15 @@ export const createLocalMembership$ = ({
and$( and$(
homeserverConnected$, homeserverConnected$,
localConnectionState$.pipe( localConnectionState$.pipe(
map((state) => (state ? state.state === "ConnectedToLkRoom" : false)), switchMap((state) => {
if (!state) return of(false);
if (state.state === "ConnectedToLkRoom") {
state.livekitConnectionState$.pipe(
map((lkState) => lkState === ConnectionState.Connected),
);
}
return of(false);
}),
), ),
), ),
); );
@@ -274,9 +290,7 @@ export const createLocalMembership$ = ({
publisher$.pipe(switchMap((p) => (p?.tracks$ ? p.tracks$ : constant([])))), publisher$.pipe(switchMap((p) => (p?.tracks$ ? p.tracks$ : constant([])))),
); );
const publishing$ = scope.behavior( const publishing$ = scope.behavior(
publisher$.pipe( publisher$.pipe(switchMap((p) => p?.publishing$ ?? constant(false))),
switchMap((p) => (p?.publishing$ ? p.publishing$ : constant(false))),
),
); );
const startTracks = (): Behavior<LocalTrack[]> => { const startTracks = (): Behavior<LocalTrack[]> => {
@@ -353,7 +367,7 @@ export const createLocalMembership$ = ({
logger.error("Multiple Livkit Errors:", e); logger.error("Multiple Livkit Errors:", e);
else fatalLivekitError$.next(e); else fatalLivekitError$.next(e);
}; };
const livekitState$: Behavior<LocalMemberLivekitState> = scope.behavior( const livekitState$: Behavior<LocalMemberRtcBackendState> = scope.behavior(
combineLatest([ combineLatest([
publisher$, publisher$,
localTransport$, localTransport$,
@@ -386,16 +400,17 @@ export const createLocalMembership$ = ({
// //
// as: // as:
// We do have <A> but not yet <B> so we are in <MyState> // We do have <A> but not yet <B> so we are in <MyState>
if (error !== null) return { state: LivekitState.Error, error }; if (error !== null) return { state: RTCBackendState.Error, error };
const hasTracks = tracks.length > 0; const hasTracks = tracks.length > 0;
if (!localTransport) if (!localTransport)
return { state: LivekitState.WaitingForTransport }; return { state: RTCBackendState.WaitingForTransport };
if (!publisher) return { state: LivekitState.WaitingForConnection }; if (!publisher)
if (!shouldStartTracks) return { state: LivekitState.Initialized }; return { state: RTCBackendState.WaitingForConnection };
if (!hasTracks) return { state: LivekitState.CreatingTracks }; if (!shouldStartTracks) return { state: RTCBackendState.Initialized };
if (!shouldConnect) return { state: LivekitState.ReadyToPublish }; if (!hasTracks) return { state: RTCBackendState.CreatingTracks };
if (!publishing) return { state: LivekitState.WaitingToPublish }; if (!shouldConnect) return { state: RTCBackendState.ReadyToPublish };
return { state: LivekitState.Connected }; if (!publishing) return { state: RTCBackendState.WaitingToPublish };
return { state: RTCBackendState.Connected };
}, },
), ),
distinctUntilChanged(deepCompare), distinctUntilChanged(deepCompare),
@@ -526,7 +541,7 @@ export const createLocalMembership$ = ({
), ),
); );
let toggleScreenSharing = null; let toggleScreenSharing: (() => void) | null = null;
if ( if (
"getDisplayMedia" in (navigator.mediaDevices ?? {}) && "getDisplayMedia" in (navigator.mediaDevices ?? {}) &&
!getUrlParams().hideScreensharing !getUrlParams().hideScreensharing

View File

@@ -16,6 +16,7 @@ import {
} from "vitest"; } from "vitest";
import { ConnectionState as LivekitConenctionState } from "livekit-client"; import { ConnectionState as LivekitConenctionState } from "livekit-client";
import { type BehaviorSubject } from "rxjs"; import { type BehaviorSubject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { ObservableScope } from "../../ObservableScope"; import { ObservableScope } from "../../ObservableScope";
import { constant } from "../../Behavior"; import { constant } from "../../Behavior";
@@ -70,6 +71,7 @@ describe("Publisher", () => {
mockMediaDevices({}), mockMediaDevices({}),
muteStates, muteStates,
constant({ supported: false, processor: undefined }), constant({ supported: false, processor: undefined }),
logger,
); );
// should do nothing if no tracks have been created yet. // should do nothing if no tracks have been created yet.

View File

@@ -60,15 +60,15 @@ export class Publisher {
devices: MediaDevices, devices: MediaDevices,
private readonly muteStates: MuteStates, private readonly muteStates: MuteStates,
trackerProcessorState$: Behavior<ProcessorState>, trackerProcessorState$: Behavior<ProcessorState>,
private logger?: Logger, private logger: Logger,
) { ) {
this.logger?.info("[PublishConnection] Create LiveKit room"); this.logger.info("Create LiveKit room");
const { controlledAudioDevices } = getUrlParams(); const { controlledAudioDevices } = getUrlParams();
const room = connection.livekitRoom; const room = connection.livekitRoom;
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => { room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
this.logger?.error("Failed to set E2EE enabled on room", e); this.logger.error("Failed to set E2EE enabled on room", e);
}); });
// Setup track processor syncing (blur) // Setup track processor syncing (blur)
@@ -78,9 +78,7 @@ export class Publisher {
this.workaroundRestartAudioInputTrackChrome(devices, scope); this.workaroundRestartAudioInputTrackChrome(devices, scope);
this.scope.onEnd(() => { this.scope.onEnd(() => {
this.logger?.info( this.logger.info("Scope ended -> stop publishing all tracks");
"[PublishConnection] Scope ended -> stop publishing all tracks",
);
void this.stopPublishing(); void this.stopPublishing();
}); });
@@ -119,6 +117,7 @@ export class Publisher {
* @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created. * @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created.
*/ */
public async createAndSetupTracks(): Promise<void> { public async createAndSetupTracks(): Promise<void> {
this.logger.debug("createAndSetupTracks called");
const lkRoom = this.connection.livekitRoom; const lkRoom = this.connection.livekitRoom;
// Observe mute state changes and update LiveKit microphone/camera states accordingly // Observe mute state changes and update LiveKit microphone/camera states accordingly
this.observeMuteStates(this.scope); this.observeMuteStates(this.scope);
@@ -135,7 +134,14 @@ export class Publisher {
video, video,
}) })
.then((tracks) => { .then((tracks) => {
this.logger.info(
"created track",
tracks.map((t) => t.kind + ", " + t.id),
);
this._tracks$.next(tracks); this._tracks$.next(tracks);
})
.catch((error) => {
this.logger.error("Failed to create tracks", error);
}); });
} }
throw Error("audio and video is false"); throw Error("audio and video is false");
@@ -149,6 +155,7 @@ export class Publisher {
* @throws ElementCallError * @throws ElementCallError
*/ */
public async startPublishing(): Promise<LocalTrack[]> { public async startPublishing(): Promise<LocalTrack[]> {
this.logger.debug("startPublishing called");
const lkRoom = this.connection.livekitRoom; const lkRoom = this.connection.livekitRoom;
const { promise, resolve, reject } = Promise.withResolvers<void>(); const { promise, resolve, reject } = Promise.withResolvers<void>();
const sub = this.connection.state$.subscribe((s) => { const sub = this.connection.state$.subscribe((s) => {
@@ -164,7 +171,7 @@ export class Publisher {
); );
break; break;
default: default:
this.logger?.info("waiting for connection: ", s.state); this.logger.info("waiting for connection: ", s.state);
} }
}); });
try { try {
@@ -176,20 +183,27 @@ export class Publisher {
} }
for (const track of this.tracks$.value) { for (const track of this.tracks$.value) {
this.logger.info("publish ", this.tracks$.value.length, "tracks");
// TODO: handle errors? Needs the signaling connection to be up, but it has some retries internally // TODO: handle errors? Needs the signaling connection to be up, but it has some retries internally
// with a timeout. // with a timeout.
await lkRoom.localParticipant.publishTrack(track).catch((error) => { await lkRoom.localParticipant.publishTrack(track).catch((error) => {
this.logger?.error("Failed to publish track", error); this.logger.error("Failed to publish track", error);
throw new FailToStartLivekitConnection( throw new FailToStartLivekitConnection(
error instanceof Error ? error.message : error, error instanceof Error ? error.message : error,
); );
}); });
this.logger.info("published track ", track.kind, track.id);
// TODO: check if the connection is still active? and break the loop if not?
} }
this._publishing$.next(true); this._publishing$.next(true);
return this.tracks$.value; return this.tracks$.value;
} }
public async stopPublishing(): Promise<void> { public async stopPublishing(): Promise<void> {
this.logger.debug("stopPublishing called");
// TODO-MULTI-SFU: Move these calls back to ObservableScope.onEnd once scope
// actually has the right lifetime
this.muteStates.audio.unsetHandler(); this.muteStates.audio.unsetHandler();
this.muteStates.video.unsetHandler(); this.muteStates.video.unsetHandler();
@@ -199,7 +213,19 @@ export class Publisher {
if (p.track !== undefined) tracks.push(p.track); if (p.track !== undefined) tracks.push(p.track);
}; };
localParticipant.trackPublications.forEach(addToTracksIfDefined); localParticipant.trackPublications.forEach(addToTracksIfDefined);
await localParticipant.unpublishTracks(tracks); this.logger.debug(
"list of tracks to unpublish:",
tracks.map((t) => t.kind + ", " + t.id),
"start unpublishing now",
);
await localParticipant.unpublishTracks(tracks).catch((error) => {
this.logger.error("Failed to unpublish tracks", error);
throw error;
});
this.logger.debug(
"unpublished tracks",
tracks.map((t) => t.kind + ", " + t.id),
);
this._publishing$.next(false); this._publishing$.next(false);
} }
@@ -256,7 +282,7 @@ export class Publisher {
.getTrackPublication(Track.Source.Microphone) .getTrackPublication(Track.Source.Microphone)
?.audioTrack?.restartTrack() ?.audioTrack?.restartTrack()
.catch((e) => { .catch((e) => {
this.logger?.error(`Failed to restart audio device track`, e); this.logger.error(`Failed to restart audio device track`, e);
}); });
} }
}); });
@@ -276,7 +302,7 @@ export class Publisher {
selected$.pipe(scope.bind()).subscribe((device) => { selected$.pipe(scope.bind()).subscribe((device) => {
if (lkRoom.state != LivekitConnectionState.Connected) return; if (lkRoom.state != LivekitConnectionState.Connected) return;
// if (this.connectionState$.value !== ConnectionState.Connected) return; // if (this.connectionState$.value !== ConnectionState.Connected) return;
this.logger?.info( this.logger.info(
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :", "[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
lkRoom.getActiveDevice(kind), lkRoom.getActiveDevice(kind),
" !== ", " !== ",
@@ -289,7 +315,7 @@ export class Publisher {
lkRoom lkRoom
.switchActiveDevice(kind, device.id) .switchActiveDevice(kind, device.id)
.catch((e: Error) => .catch((e: Error) =>
this.logger?.error( this.logger.error(
`Failed to sync ${kind} device with LiveKit`, `Failed to sync ${kind} device with LiveKit`,
e, e,
), ),
@@ -314,10 +340,7 @@ export class Publisher {
try { try {
await lkRoom.localParticipant.setMicrophoneEnabled(desired); await lkRoom.localParticipant.setMicrophoneEnabled(desired);
} catch (e) { } catch (e) {
this.logger?.error( this.logger.error("Failed to update LiveKit audio input mute state", e);
"Failed to update LiveKit audio input mute state",
e,
);
} }
return lkRoom.localParticipant.isMicrophoneEnabled; return lkRoom.localParticipant.isMicrophoneEnabled;
}); });
@@ -325,10 +348,7 @@ export class Publisher {
try { try {
await lkRoom.localParticipant.setCameraEnabled(desired); await lkRoom.localParticipant.setCameraEnabled(desired);
} catch (e) { } catch (e) {
this.logger?.error( this.logger.error("Failed to update LiveKit video input mute state", e);
"Failed to update LiveKit video input mute state",
e,
);
} }
return lkRoom.localParticipant.isCameraEnabled; return lkRoom.localParticipant.isCameraEnabled;
}); });