Files
element-call/src/state/remoteMembers/ConnectionManager.ts

293 lines
10 KiB
TypeScript
Raw Normal View History

2025-10-29 18:31:58 +01:00
// TODOs:
// - make ConnectionManager its own actual class
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type LivekitTransport,
type ParticipantId,
} from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, combineLatest, map, switchMap } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import {
type E2EEOptions,
2025-10-30 00:09:07 +01:00
Room as LivekitRoom,
2025-10-29 18:31:58 +01:00
type Participant as LivekitParticipant,
2025-10-30 00:09:07 +01:00
type RoomOptions,
2025-10-29 18:31:58 +01:00
} from "livekit-client";
import { type MatrixClient } from "matrix-js-sdk";
import { type Behavior } from "../Behavior";
2025-10-30 00:09:07 +01:00
import { Connection } from "./Connection";
2025-10-29 18:31:58 +01:00
import { type ObservableScope } from "../ObservableScope";
import { generateKeyed$ } from "../../utils/observable";
import { areLivekitTransportsEqual } from "./matrixLivekitMerger";
2025-10-30 00:09:07 +01:00
import { getUrlParams } from "../../UrlParams";
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { type MediaDevices } from "../MediaDevices";
import { defaultLiveKitOptions } from "../../livekit/options";
2025-10-29 18:31:58 +01:00
export type ParticipantByMemberIdMap = Map<
ParticipantId,
// It can be an array because a bad behaving client could be publishingParticipants$
// multiple times to several livekit rooms.
{ participant: LivekitParticipant; connection: Connection }[]
>;
2025-10-30 00:09:07 +01:00
// TODO - write test for scopes (do we really need to bind scope)
2025-10-29 18:31:58 +01:00
export class ConnectionManager {
2025-10-30 00:09:07 +01:00
private livekitRoomFactory: () => LivekitRoom;
public constructor(
private scope: ObservableScope,
private client: MatrixClient,
2025-10-30 00:09:07 +01:00
private devices: MediaDevices,
private processorState$: Behavior<ProcessorState>,
private e2eeLivekitOptions: E2EEOptions | undefined,
2025-10-30 00:09:07 +01:00
private logger?: Logger,
livekitRoomFactory?: () => LivekitRoom,
) {
this.scope = scope;
const defaultFactory = (): LivekitRoom =>
new LivekitRoom(
generateRoomOption(
this.devices,
this.processorState$.value,
this.e2eeLivekitOptions,
2025-10-30 00:09:07 +01:00
),
);
this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory;
}
2025-10-29 18:31:58 +01:00
/**
2025-10-30 00:09:07 +01:00
* A list of Behaviors each containing a LIST of LivekitTransport.
* Each of these behaviors can be interpreted as subscribed list of transports.
*
* Using `registerTransports` independent external modules can control what connections
* are created by the ConnectionManager.
*
* The connection manager will remove all duplicate transports in each subscibed list.
*
* See `unregisterAllTransports` and `unregisterTransport` for details on how to unsubscribe.
2025-10-29 18:31:58 +01:00
*/
2025-10-30 00:09:07 +01:00
private readonly transportsSubscriptions$ = new BehaviorSubject<
2025-10-29 18:31:58 +01:00
Behavior<LivekitTransport[]>[]
>([]);
2025-10-30 00:09:07 +01:00
/**
* All transports currently managed by the ConnectionManager.
*
* This list does not include duplicate transports.
*
* It is build based on the list of subscribed transports (`transportsSubscriptions$`).
* externally this is modified via `registerTransports()`.
*/
private readonly transports$ = this.scope.behavior(
this.transportsSubscriptions$.pipe(
2025-10-29 18:31:58 +01:00
switchMap((subscriptions) =>
2025-10-30 00:09:07 +01:00
combineLatest(subscriptions).pipe(
2025-10-29 18:31:58 +01:00
map((transportsNested) => transportsNested.flat()),
map(removeDuplicateTransports),
),
),
),
);
/**
* Connections for each transport in use by one or more session members.
*/
2025-10-30 22:15:35 +01:00
public readonly connections$ = this.scope.behavior(
2025-10-29 18:31:58 +01:00
generateKeyed$<LivekitTransport[], Connection, Connection[]>(
this.transports$,
(transports, createOrGet) => {
const createConnection =
(
transport: LivekitTransport,
2025-10-30 00:09:07 +01:00
): ((scope: ObservableScope) => Connection) =>
2025-10-29 18:31:58 +01:00
(scope) => {
2025-10-30 00:09:07 +01:00
const connection = new Connection(
2025-10-29 18:31:58 +01:00
{
transport,
client: this.client,
scope: scope,
livekitRoomFactory: this.livekitRoomFactory,
},
2025-10-30 00:09:07 +01:00
this.logger,
2025-10-29 18:31:58 +01:00
);
void connection.start();
return connection;
};
const connections = transports.map((transport) => {
const key =
transport.livekit_service_url + "|" + transport.livekit_alias;
return createOrGet(key, createConnection(transport));
});
return connections;
},
),
);
/**
2025-10-30 00:09:07 +01:00
* Add an a Behavior containing a list of transports to this ConnectionManager.
2025-10-29 18:31:58 +01:00
*
2025-10-30 00:09:07 +01:00
* The intended usage is:
* - create a ConnectionManager
* - register one `transports$` behavior using registerTransports
* - add new connections to the `ConnectionManager` by updating the `transports$` behavior
* - remove a single connection by removing the transport.
* - remove this subscription by calling `unregisterTransports` and passing
* the same `transports$` behavior reference.
* @param transports$ The Behavior containing a list of transports to subscribe to.
2025-10-29 18:31:58 +01:00
*/
2025-10-30 22:15:35 +01:00
public registerTransports(transports$: Behavior<LivekitTransport[]>): void {
2025-10-30 00:09:07 +01:00
if (!this.transportsSubscriptions$.value.some((t$) => t$ === transports$)) {
this.transportsSubscriptions$.next(
this.transportsSubscriptions$.value.concat(transports$),
2025-10-29 18:31:58 +01:00
);
}
2025-10-30 22:15:35 +01:00
// // After updating the subscriptions our connection list is also updated.
// return transports$.value
// .map((transport) => {
// const isConnectionForTransport = (connection: Connection): boolean =>
// areLivekitTransportsEqual(connection.transport, transport);
// return this.connections$.value.find(isConnectionForTransport);
// })
// .filter((c) => c !== undefined);
2025-10-29 18:31:58 +01:00
}
2025-10-30 00:09:07 +01:00
/**
* Unsubscribe from the given transports.
* @param transports$ The behavior to unsubscribe from
* @returns
*/
2025-10-29 18:31:58 +01:00
public unregisterTransports(
transports$: Behavior<LivekitTransport[]>,
): boolean {
2025-10-30 00:09:07 +01:00
const subscriptions = this.transportsSubscriptions$.value;
2025-10-29 18:31:58 +01:00
const subscriptionsUnregistered = subscriptions.filter(
(t$) => t$ !== transports$,
);
const canUnregister =
subscriptions.length !== subscriptionsUnregistered.length;
if (canUnregister)
2025-10-30 00:09:07 +01:00
this.transportsSubscriptions$.next(subscriptionsUnregistered);
2025-10-29 18:31:58 +01:00
return canUnregister;
}
2025-10-30 00:09:07 +01:00
/**
* Unsubscribe from all transports.
*/
2025-10-29 18:31:58 +01:00
public unregisterAllTransports(): void {
2025-10-30 00:09:07 +01:00
this.transportsSubscriptions$.next([]);
2025-10-29 18:31:58 +01:00
}
// We have a lost of connections, for each of these these
// connection we create a stream of (participant, connection) tuples.
// Then we combine the several streams (1 per Connection) into a single stream of tuples.
private allParticipantsWithConnection$ = this.scope.behavior(
this.connections$.pipe(
switchMap((connections) => {
const listsOfParticipantWithConnection = connections.map(
(connection) => {
2025-10-30 00:09:07 +01:00
return connection.participantsWithTrack$.pipe(
2025-10-29 18:31:58 +01:00
map((participants) =>
participants.map((p) => ({
participant: p,
connection,
})),
),
);
},
);
return combineLatest(listsOfParticipantWithConnection).pipe(
map((lists) => lists.flatMap((list) => list)),
);
}),
),
);
2025-10-30 00:09:07 +01:00
/**
* This field makes the connection manager to behave as close to a single SFU as possible.
* Each participant that is found on all connections managed by the manager will be listed.
*
* They are stored an a map keyed by `participant.identity`
2025-10-30 22:15:35 +01:00
* TODO (which is equivalent to the `member.id` field in the `m.rtc.member` event) right now its userId:deviceId
2025-10-30 00:09:07 +01:00
*/
2025-10-29 18:31:58 +01:00
public allParticipantsByMemberId$ = this.scope.behavior(
this.allParticipantsWithConnection$.pipe(
map((participantsWithConnections) => {
const participantsByMemberId = participantsWithConnections.reduce(
(acc, test) => {
const { participant, connection } = test;
if (participant.getTrackPublications().length > 0) {
const currentVal = acc.get(participant.identity);
if (!currentVal) {
acc.set(participant.identity, [{ connection, participant }]);
} else {
// already known
2025-10-30 00:09:07 +01:00
// This is for users publishing on several SFUs
2025-10-29 18:31:58 +01:00
currentVal.push({ connection, participant });
this.logger?.info(
2025-10-30 00:09:07 +01:00
`Participant ${participant.identity} is publishing on several SFUs ${currentVal.map((v) => v.connection.transport.livekit_service_url).join(", ")}`,
2025-10-29 18:31:58 +01:00
);
}
}
return acc;
},
new Map() as ParticipantByMemberIdMap,
);
return participantsByMemberId;
}),
),
);
}
function removeDuplicateTransports(
transports: LivekitTransport[],
): LivekitTransport[] {
return transports.reduce((acc, transport) => {
if (!acc.some((t) => areLivekitTransportsEqual(t, transport)))
acc.push(transport);
return acc;
}, [] as LivekitTransport[]);
}
2025-10-30 00:09:07 +01:00
/**
* Generate the initial LiveKit RoomOptions based on the current media devices and processor state.
*/
function generateRoomOption(
devices: MediaDevices,
processorState: ProcessorState,
e2eeLivekitOptions: E2EEOptions | undefined,
): RoomOptions {
const { controlledAudioDevices } = getUrlParams();
return {
...defaultLiveKitOptions,
videoCaptureDefaults: {
...defaultLiveKitOptions.videoCaptureDefaults,
deviceId: devices.videoInput.selected$.value?.id,
processor: processorState.processor,
},
audioCaptureDefaults: {
...defaultLiveKitOptions.audioCaptureDefaults,
deviceId: devices.audioInput.selected$.value?.id,
},
audioOutput: {
// When using controlled audio devices, we don't want to set the
// deviceId here, because it will be set by the native app.
// (also the id does not need to match a browser device id)
deviceId: controlledAudioDevices
? undefined
: devices.audioOutput.selected$.value?.id,
},
e2ee: e2eeLivekitOptions,
// TODO test and consider this:
// webAudioMix: true,
};
}