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,
|
2025-10-30 01:13:06 +01:00
|
|
|
private client: MatrixClient,
|
2025-10-30 00:09:07 +01:00
|
|
|
private devices: MediaDevices,
|
2025-10-30 01:13:06 +01:00
|
|
|
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,
|
2025-10-30 01:13:06 +01:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|