Add MatrixRTCMode and refactor local membership

Remove preferStickyEvents and multiSfu in favor of a MatrixRTCMode
enum/setting (Legacy, Compatibil, Matrix_2_0). Move session join/leave,
track pause/resume, and config error handling out of CallViewModel into
the localMembership module. Update developer settings UI, i18n strings,
and related RTC session helpers and wiring accordingly.
This commit is contained in:
Timo K
2025-11-05 12:56:58 +01:00
parent 57bf86fc4c
commit 107ef16d94
7 changed files with 277 additions and 329 deletions

View File

@@ -72,12 +72,21 @@
"livekit_server_info": "LiveKit Server Info", "livekit_server_info": "LiveKit Server Info",
"livekit_sfu": "LiveKit SFU: {{url}}", "livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}", "matrix_id": "Matrix ID: {{id}}",
"multi_sfu": "Multi-SFU media transport", "matrixRTCMode": {
"mute_all_audio": "Mute all audio (participants, reactions, join sounds)", "Comptibility": {
"prefer_sticky_events": { "label": "Compatibility: state events & multi SFU"
"description": "Improves reliability of calls (requires homeserver support)", "description": "Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)",
"label": "Prefer sticky events" },
"Legacy": {
"label": "Legacy: state events & oldest membership SFU"
"description": "Compatible with old versions of EC that do not support multi SFU",
},
"Matrix_2_0": {
"label": "Matrix 2.0: sticky events & multi SFU"
"description": "Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later",
}
}, },
"mute_all_audio": "Mute all audio (participants, reactions, join sounds)",
"show_connection_stats": "Show connection statistics", "show_connection_stats": "Show connection statistics",
"url_params": "URL parameters" "url_params": "URL parameters"
}, },

View File

@@ -21,6 +21,7 @@ import { ElementWidgetActions, widget } from "./widget";
import { MatrixRTCTransportMissingError } from "./utils/errors"; import { MatrixRTCTransportMissingError } from "./utils/errors";
import { getUrlParams } from "./UrlParams"; import { getUrlParams } from "./UrlParams";
import { getSFUConfigWithOpenID } from "./livekit/openIDSFU.ts"; import { getSFUConfigWithOpenID } from "./livekit/openIDSFU.ts";
import { MatrixRTCMode } from "./settings/settings.ts";
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci"; const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
@@ -98,9 +99,7 @@ export async function makeTransport(
export interface EnterRTCSessionOptions { export interface EnterRTCSessionOptions {
encryptMedia: boolean; encryptMedia: boolean;
/** EXPERIMENTAL: If true, will use the multi-sfu codepath where each member connects to its SFU instead of everyone connecting to an elected on. */ matrixRTCMode: MatrixRTCMode;
useMultiSfu: boolean;
preferStickyEvents: boolean;
} }
/** /**
@@ -112,7 +111,7 @@ export interface EnterRTCSessionOptions {
export async function enterRTCSession( export async function enterRTCSession(
rtcSession: MatrixRTCSession, rtcSession: MatrixRTCSession,
transport: LivekitTransport, transport: LivekitTransport,
{ encryptMedia, useMultiSfu, preferStickyEvents }: EnterRTCSessionOptions, { encryptMedia, matrixRTCMode }: EnterRTCSessionOptions,
): Promise<void> { ): Promise<void> {
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId); PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
@@ -125,10 +124,11 @@ export async function enterRTCSession(
const useDeviceSessionMemberEvents = const useDeviceSessionMemberEvents =
features?.feature_use_device_session_member_events; features?.feature_use_device_session_member_events;
const { sendNotificationType: notificationType, callIntent } = getUrlParams(); const { sendNotificationType: notificationType, callIntent } = getUrlParams();
const multiSFU = matrixRTCMode !== MatrixRTCMode.Legacy;
// Multi-sfu does not need a preferred foci list. just the focus that is actually used. // Multi-sfu does not need a preferred foci list. just the focus that is actually used.
rtcSession.joinRoomSession( rtcSession.joinRoomSession(
useMultiSfu ? [] : [transport], multiSFU ? [] : [transport],
useMultiSfu ? transport : undefined, multiSFU ? transport : undefined,
{ {
notificationType, notificationType,
callIntent, callIntent,
@@ -147,7 +147,7 @@ export async function enterRTCSession(
membershipEventExpiryMs: membershipEventExpiryMs:
matrixRtcSessionConfig?.membership_event_expiry_ms, matrixRtcSessionConfig?.membership_event_expiry_ms,
useExperimentalToDeviceTransport: true, useExperimentalToDeviceTransport: true,
unstableSendStickyEvents: preferStickyEvents, unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
}, },
); );
if (widget) { if (widget) {

View File

@@ -29,7 +29,8 @@ import {
multiSfu as multiSfuSetting, multiSfu as multiSfuSetting,
muteAllAudio as muteAllAudioSetting, muteAllAudio as muteAllAudioSetting,
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting, alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
preferStickyEvents as preferStickyEventsSetting, matrixRTCMode as matrixRTCModeSetting,
MatrixRTCMode,
} from "./settings"; } from "./settings";
import type { Room as LivekitRoom } from "livekit-client"; import type { Room as LivekitRoom } from "livekit-client";
import styles from "./DeveloperSettingsTab.module.css"; import styles from "./DeveloperSettingsTab.module.css";
@@ -59,9 +60,7 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRooms }) => {
}); });
}, [client]); }, [client]);
const [preferStickyEvents, setPreferStickyEvents] = useSetting( const [matrixRTCMode, setMatrixRTCMode] = useSetting(matrixRTCModeSetting);
preferStickyEventsSetting,
);
const [showConnectionStats, setShowConnectionStats] = useSetting( const [showConnectionStats, setShowConnectionStats] = useSetting(
showConnectionStatsSetting, showConnectionStatsSetting,
@@ -71,8 +70,6 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRooms }) => {
alwaysShowIphoneEarpieceSetting, alwaysShowIphoneEarpieceSetting,
); );
const [multiSfu, setMultiSfu] = useSetting(multiSfuSetting);
const [muteAllAudio, setMuteAllAudio] = useSetting(muteAllAudioSetting); const [muteAllAudio, setMuteAllAudio] = useSetting(muteAllAudioSetting);
const urlParams = useUrlParams(); const urlParams = useUrlParams();
@@ -148,17 +145,47 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRooms }) => {
</FieldRow> </FieldRow>
<FieldRow> <FieldRow>
<InputField <InputField
id="preferStickyEvents" id="matrixRTCMode"
type="checkbox" type="checkbox"
label={t("developer_mode.prefer_sticky_events.label")} label={t("developer_mode.matrixRTCMode.Legacy.label")}
disabled={!stickyEventsSupported} description={t("developer_mode.matrixRTCMode.Legacy.description")}
description={t("developer_mode.prefer_sticky_events.description")} checked={matrixRTCMode === MatrixRTCMode.Legacy}
checked={!!preferStickyEvents}
onChange={useCallback( onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => { (event: ChangeEvent<HTMLInputElement>) => {
setPreferStickyEvents(event.target.checked); if (event.target.checked) setMatrixRTCMode(MatrixRTCMode.Legacy);
}, },
[setPreferStickyEvents], [setMatrixRTCMode],
)}
/>
<InputField
id="matrixRTCMode"
type="checkbox"
label={t("developer_mode.matrixRTCMode.Comptibility.label")}
description={t(
"developer_mode.matrixRTCMode.Comptibility.description",
)}
checked={matrixRTCMode === MatrixRTCMode.Compatibil}
onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
if (event.target.checked)
setMatrixRTCMode(MatrixRTCMode.Compatibil);
},
[setMatrixRTCMode],
)}
/>
<InputField
id="matrixRTCMode"
type="checkbox"
label={t("developer_mode.matrixRTCMode.Matrix_2_0.label")}
description={t("developer_mode.matrixRTCMode.Matrix_2_0.description")}
disabled={!stickyEventsSupported}
checked={matrixRTCMode === MatrixRTCMode.Matrix_2_0}
onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
if (event.target.checked)
setMatrixRTCMode(MatrixRTCMode.Matrix_2_0);
},
[setMatrixRTCMode],
)} )}
/> />
</FieldRow> </FieldRow>
@@ -176,22 +203,6 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRooms }) => {
)} )}
/> />
</FieldRow> </FieldRow>
<FieldRow>
<InputField
id="multiSfu"
type="checkbox"
label={t("developer_mode.multi_sfu")}
// If using sticky events we implicitly prefer use multi-sfu
checked={multiSfu || preferStickyEvents}
disabled={preferStickyEvents}
onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
setMultiSfu(event.target.checked);
},
[setMultiSfu],
)}
/>
</FieldRow>
<FieldRow> <FieldRow>
<InputField <InputField
id="muteAllAudio" id="muteAllAudio"

View File

@@ -83,11 +83,6 @@ export const showConnectionStats = new Setting<boolean>(
false, false,
); );
export const preferStickyEvents = new Setting<boolean>(
"prefer-sticky-events",
false,
);
export const audioInput = new Setting<string | undefined>( export const audioInput = new Setting<string | undefined>(
"audio-input", "audio-input",
undefined, undefined,
@@ -120,8 +115,6 @@ export const soundEffectVolume = new Setting<number>(
0.5, 0.5,
); );
export const multiSfu = new Setting<boolean>("multi-sfu", false);
export const muteAllAudio = new Setting<boolean>("mute-all-audio", false); export const muteAllAudio = new Setting<boolean>("mute-all-audio", false);
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true); export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);
@@ -130,3 +123,14 @@ export const alwaysShowIphoneEarpiece = new Setting<boolean>(
"always-show-iphone-earpiece", "always-show-iphone-earpiece",
false, false,
); );
export enum MatrixRTCMode {
Legacy = "legacy",
Compatibil = "compatibil",
Matrix_2_0 = "matrix_2_0",
}
export const matrixRTCMode = new Setting<MatrixRTCMode>(
"matrix-rtc-mode",
MatrixRTCMode.Legacy,
);

View File

@@ -10,22 +10,16 @@ import {
ConnectionState, ConnectionState,
type E2EEOptions, type E2EEOptions,
ExternalE2EEKeyProvider, ExternalE2EEKeyProvider,
type LocalParticipant,
RemoteParticipant,
type Room as LivekitRoom, type Room as LivekitRoom,
type RoomOptions, type RoomOptions,
} from "livekit-client"; } from "livekit-client";
import E2EEWorker from "livekit-client/e2ee-worker?worker"; import E2EEWorker from "livekit-client/e2ee-worker?worker";
import { import {
ClientEvent,
type EventTimelineSetHandlerMap, type EventTimelineSetHandlerMap,
EventType, EventType,
type Room as MatrixRoom, type Room as MatrixRoom,
RoomEvent, RoomEvent,
type RoomMember,
SyncState,
} from "matrix-js-sdk"; } from "matrix-js-sdk";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest, combineLatest,
@@ -62,14 +56,9 @@ import {
} from "rxjs"; } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { import {
type CallMembership,
isLivekitTransport,
type LivekitTransport,
type MatrixRTCSession, type MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
type MatrixRTCSessionEventHandlerMap, type MatrixRTCSessionEventHandlerMap,
MembershipManagerEvent,
Status,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { type IWidgetApiRequest } from "matrix-widget-api"; import { type IWidgetApiRequest } from "matrix-widget-api";
@@ -80,17 +69,12 @@ import {
ScreenShareViewModel, ScreenShareViewModel,
type UserMediaViewModel, type UserMediaViewModel,
} from "./MediaViewModel"; } from "./MediaViewModel";
import { import { accumulate, generateKeyed$, pauseWhen } from "../utils/observable";
accumulate,
and$,
generateKeyed$,
pauseWhen,
} from "../utils/observable";
import { import {
duplicateTiles, duplicateTiles,
multiSfu, MatrixRTCMode,
matrixRTCMode,
playReactionsSound, playReactionsSound,
preferStickyEvents,
showReactions, showReactions,
} from "../settings/settings"; } from "../settings/settings";
import { isFirefox } from "../Platform"; import { isFirefox } from "../Platform";
@@ -109,20 +93,13 @@ import {
import { shallowEquals } from "../utils/array"; import { shallowEquals } from "../utils/array";
import { type MediaDevices } from "./MediaDevices"; import { type MediaDevices } from "./MediaDevices";
import { type Behavior, constant } from "./Behavior"; import { type Behavior, constant } from "./Behavior";
import { import { enterRTCSession } from "../rtcSessionHelpers";
enterRTCSession,
getLivekitAlias,
makeTransport,
} from "../rtcSessionHelpers";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider"; import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider";
import { type Connection } from "./remoteMembers/Connection.ts";
import { type MuteStates } from "./MuteStates"; import { type MuteStates } from "./MuteStates";
import { getUrlParams } from "../UrlParams"; import { getUrlParams } from "../UrlParams";
import { type ProcessorState } from "../livekit/TrackProcessorContext"; import { type ProcessorState } from "../livekit/TrackProcessorContext";
import { ElementWidgetActions, widget } from "../widget"; import { ElementWidgetActions, widget } from "../widget";
import { PublishConnection } from "./localMember/Publisher.ts";
import { type Async, async$, mapAsync, ready } from "./Async";
import { sharingScreen$, UserMedia } from "./UserMedia.ts"; import { sharingScreen$, UserMedia } from "./UserMedia.ts";
import { ScreenShare } from "./ScreenShare.ts"; import { ScreenShare } from "./ScreenShare.ts";
import { import {
@@ -134,12 +111,14 @@ import {
type SpotlightLandscapeLayoutMedia, type SpotlightLandscapeLayoutMedia,
type SpotlightPortraitLayoutMedia, type SpotlightPortraitLayoutMedia,
} from "./layout-types.ts"; } from "./layout-types.ts";
import { type ElementCallError, UnknownCallError } from "../utils/errors.ts"; import { type ElementCallError } from "../utils/errors.ts";
import { ObservableScope } from "./ObservableScope.ts"; import { ObservableScope } from "./ObservableScope.ts";
import { memberDisplaynames$ } from "./remoteMembers/displayname.ts";
import { ConnectionManager } from "./remoteMembers/ConnectionManager.ts"; import { ConnectionManager } from "./remoteMembers/ConnectionManager.ts";
import { MatrixLivekitMerger } from "./remoteMembers/matrixLivekitMerger.ts"; import { MatrixLivekitMerger } from "./remoteMembers/matrixLivekitMerger.ts";
import { ownMembership$ } from "./localMember/LocalMembership.ts"; import {
localMembership$,
LocalMemberState,
} from "./localMember/LocalMembership.ts";
import { localTransport$ as computeLocalTransport$ } from "./localMember/LocalTransport.ts"; import { localTransport$ as computeLocalTransport$ } from "./localMember/LocalTransport.ts";
import { sessionBehaviors$ } from "./SessionBehaviors.ts"; import { sessionBehaviors$ } from "./SessionBehaviors.ts";
import { ECConnectionFactory } from "./remoteMembers/ConnectionFactory.ts"; import { ECConnectionFactory } from "./remoteMembers/ConnectionFactory.ts";
@@ -202,23 +181,20 @@ export class CallViewModel {
private readonly userId = this.matrixRoom.client.getUserId()!; private readonly userId = this.matrixRoom.client.getUserId()!;
private readonly deviceId = this.matrixRoom.client.getDeviceId()!; private readonly deviceId = this.matrixRoom.client.getDeviceId()!;
private readonly livekitAlias = getLivekitAlias(this.matrixRTCSession);
private readonly livekitE2EEKeyProvider = getE2eeKeyProvider( private readonly livekitE2EEKeyProvider = getE2eeKeyProvider(
this.options.encryptionSystem, this.options.encryptionSystem,
this.matrixRTCSession, this.matrixRTCSession,
); );
private readonly e2eeLivekitOptions = (): E2EEOptions | undefined =>
this.livekitE2EEKeyProvider
? {
keyProvider: this.livekitE2EEKeyProvider,
worker: new E2EEWorker(),
}
: undefined;
private readonly _configError$ = new BehaviorSubject<ElementCallError | null>( private readonly e2eeLivekitOptions: E2EEOptions | undefined = this
null, .livekitE2EEKeyProvider
); ? {
keyProvider: this.livekitE2EEKeyProvider,
worker: new E2EEWorker(),
}
: undefined;
private sessionBehaviors = sessionBehaviors$( private sessionBehaviors = sessionBehaviors$(
this.scope, this.scope,
this.matrixRTCSession, this.matrixRTCSession,
@@ -230,14 +206,16 @@ export class CallViewModel {
memberships$: this.memberships$, memberships$: this.memberships$,
client: this.matrixRoom.client, client: this.matrixRoom.client,
roomId: this.matrixRoom.roomId, roomId: this.matrixRoom.roomId,
useOldestMember$: multiSfu.value$, useOldestMember$: this.scope.behavior(
matrixRTCMode.value$.pipe(map((v) => v === MatrixRTCMode.Legacy)),
),
}); });
private connectionFactory = new ECConnectionFactory( private connectionFactory = new ECConnectionFactory(
this.matrixRoom.client, this.matrixRoom.client,
this.mediaDevices, this.mediaDevices,
this.trackProcessorState$, this.trackProcessorState$,
this.e2eeLivekitOptions(), this.e2eeLivekitOptions,
getUrlParams().controlledAudioDevices, getUrlParams().controlledAudioDevices,
); );
@@ -252,7 +230,6 @@ export class CallViewModel {
this.scope, this.scope,
this.connectionFactory, this.connectionFactory,
this.allTransports$, this.allTransports$,
logger,
); );
private matrixLivekitMerger = new MatrixLivekitMerger( private matrixLivekitMerger = new MatrixLivekitMerger(
@@ -263,31 +240,36 @@ export class CallViewModel {
this.userId, this.userId,
this.deviceId, this.deviceId,
); );
private matrixLivekitItems$ = this.matrixLivekitMerger.matrixLivekitItems$;
private localMembership = this.localMembership$({ private localMembership = localMembership$({
scope: this.scope, scope: this.scope,
muteStates: this.muteStates, muteStates: this.muteStates,
multiSfu: this.multiSfu,
mediaDevices: this.mediaDevices, mediaDevices: this.mediaDevices,
trackProcessorState$: this.trackProcessorState$, connectionManager: this.connectionManager,
matrixRTCSession: this.matrixRTCSession,
matrixRoom: this.matrixRoom,
localTransport$: this.localTransport$,
e2eeLivekitOptions: this.e2eeLivekitOptions, e2eeLivekitOptions: this.e2eeLivekitOptions,
trackProcessorState$: this.trackProcessorState$,
widget,
}); });
private matrixLivekitItems$ = this.matrixLivekitMerger.matrixLivekitItems$;
/** /**
* If there is a configuration error with the call (e.g. misconfigured E2EE). * If there is a configuration error with the call (e.g. misconfigured E2EE).
* This is a fatal error that prevents the call from being created/joined. * This is a fatal error that prevents the call from being created/joined.
* Should render a blocking error screen. * Should render a blocking error screen.
*/ */
public get configError$(): Behavior<ElementCallError | null> { public get configError$(): Behavior<ElementCallError | null> {
return this._configError$; return this.localMembership.configError$;
} }
private readonly join$ = new Subject<void>(); public join(): LocalMemberState {
return this.localMembership.requestConnect({
// DISCUSS BAD ? encryptMedia: this.e2eeLivekitOptions !== undefined,
public join(): void { // TODO. This might need to get called again on each cahnge of matrixRTCMode...
this.join$.next(); matrixRTCMode: matrixRTCMode.getValue(),
});
} }
// CODESMELL? // CODESMELL?
@@ -304,62 +286,7 @@ export class CallViewModel {
* than whether all connections are truly up and running. * than whether all connections are truly up and running.
*/ */
// DISCUSS ? lets think why we need joined and how to do it better // DISCUSS ? lets think why we need joined and how to do it better
private readonly joined$ = this.scope.behavior( private readonly joined$ = this.localMembership.connected$;
this.join$.pipe(
map(() => true),
// Using takeUntil with the repeat operator is perfectly valid.
// eslint-disable-next-line rxjs/no-unsafe-takeuntil
takeUntil(this.leaveHoisted$),
endWith(false),
repeat(),
startWith(false),
),
);
// /**
// * The transport that we would personally prefer to publish on (if not for the
// * transport preferences of others, perhaps).
// */
// // DISCUSS move to ownMembership
// private readonly preferredTransport$ = this.scope.behavior(
// async$(makeTransport(this.matrixRTCSession)),
// );
// /**
// * The transport over which we should be actively publishing our media.
// * null when not joined.
// */
// // DISCUSSION ownMembershipManager
// private readonly localTransport$: Behavior<Async<LivekitTransport> | null> =
// this.scope.behavior(
// this.transports$.pipe(
// map((transports) => transports?.local ?? null),
// distinctUntilChanged<Async<LivekitTransport> | null>(deepCompare),
// ),
// );
// // DISCUSSION move to ConnectionManager
// public readonly livekitConnectionState$ =
// // TODO: This options.connectionState$ behavior is a small hack inserted
// // here to facilitate testing. This would likely be better served by
// // breaking CallViewModel down into more naturally testable components.
// this.options.connectionState$ ??
// this.scope.behavior<ConnectionState>(
// this.localConnection$.pipe(
// switchMap((c) =>
// c?.state === "ready"
// ? // TODO mapping to ConnectionState for compatibility, but we should use the full state?
// c.value.state$.pipe(
// switchMap((s) => {
// if (s.state === "ConnectedToLkRoom")
// return s.connectionState$;
// return of(ConnectionState.Disconnected);
// }),
// )
// : of(ConnectionState.Disconnected),
// ),
// ),
// );
/** /**
* Whether various media/event sources should pretend to be disconnected from * Whether various media/event sources should pretend to be disconnected from
@@ -370,9 +297,14 @@ export class CallViewModel {
// down, for example, and we want to avoid making people worry that the app is // down, for example, and we want to avoid making people worry that the app is
// in a split-brained state. // in a split-brained state.
// DISCUSSION own membership manager ALSO this probably can be simplifis // DISCUSSION own membership manager ALSO this probably can be simplifis
private readonly pretendToBeDisconnected$ = this.reconnecting$; private readonly pretendToBeDisconnected$ =
this.localMembership.reconnecting$;
public readonly audioParticipants$; // now will be created based on the connectionmanager public readonly audioParticipants$ = this.scope.behavior(
this.matrixLivekitItems$.pipe(
map((items) => items.map((item) => item.participant)),
),
);
public readonly handsRaised$ = this.scope.behavior( public readonly handsRaised$ = this.scope.behavior(
this.handsRaisedSubject$.pipe(pauseWhen(this.pretendToBeDisconnected$)), this.handsRaisedSubject$.pipe(pauseWhen(this.pretendToBeDisconnected$)),
@@ -392,15 +324,6 @@ export class CallViewModel {
), ),
); );
// Now will be added to the matricLivekitMerger
// memberDisplaynames$ = memberDisplaynames$(
// this.matrixRoom,
// this.memberships$,
// this.scope,
// this.userId,
// this.deviceId,
// );
/** /**
* List of MediaItems that we want to have tiles for. * List of MediaItems that we want to have tiles for.
*/ */
@@ -1352,6 +1275,7 @@ export class CallViewModel {
/** /**
* Whether we are sharing our screen. * Whether we are sharing our screen.
*/ */
// TODO move to LocalMembership
public readonly sharingScreen$ = this.scope.behavior( public readonly sharingScreen$ = this.scope.behavior(
from(this.localConnection$).pipe( from(this.localConnection$).pipe(
switchMap((c) => switchMap((c) =>
@@ -1366,6 +1290,7 @@ export class CallViewModel {
* Callback for toggling screen sharing. If null, screen sharing is not * Callback for toggling screen sharing. If null, screen sharing is not
* available. * available.
*/ */
// TODO move to LocalMembership
public readonly toggleScreenSharing = public readonly toggleScreenSharing =
"getDisplayMedia" in (navigator.mediaDevices ?? {}) && "getDisplayMedia" in (navigator.mediaDevices ?? {}) &&
!this.urlParams.hideScreensharing !this.urlParams.hideScreensharing
@@ -1408,101 +1333,6 @@ export class CallViewModel {
>, >,
private readonly trackProcessorState$: Behavior<ProcessorState>, private readonly trackProcessorState$: Behavior<ProcessorState>,
) { ) {
// Start and stop session membership as needed
this.scope.reconcile(this.advertisedTransport$, async (advertised) => {
if (advertised !== null) {
try {
this._configError$.next(null);
await enterRTCSession(this.matrixRTCSession, advertised.transport, {
encryptMedia: this.options.encryptionSystem.kind !== E2eeType.NONE,
useMultiSfu: advertised.multiSfu,
preferStickyEvents: advertised.preferStickyEvents,
});
} catch (e) {
logger.error("Error entering RTC session", e);
}
// Update our member event when our mute state changes.
const intentScope = new ObservableScope();
intentScope.reconcile(
this.muteStates.video.enabled$,
async (videoEnabled) =>
this.matrixRTCSession.updateCallIntent(
videoEnabled ? "video" : "audio",
),
);
return async (): Promise<void> => {
intentScope.end();
// Only sends Matrix leave event. The LiveKit session will disconnect
// as soon as either the stopConnection$ handler above gets to it or
// the view model is destroyed.
try {
await this.matrixRTCSession.leaveRoomSession();
} catch (e) {
logger.error("Error leaving RTC session", e);
}
try {
await widget?.api.transport.send(
ElementWidgetActions.HangupCall,
{},
);
} catch (e) {
logger.error("Failed to send hangup action", e);
}
};
}
});
// Pause upstream of all local media tracks when we're disconnected from
// MatrixRTC, because it can be an unpleasant surprise for the app to say
// 'reconnecting' and yet still be transmitting your media to others.
// We use matrixConnected$ rather than reconnecting$ because we want to
// pause tracks during the initial joining sequence too until we're sure
// that our own media is displayed on screen.
combineLatest([this.localConnection$, this.matrixConnected$])
.pipe(this.scope.bind())
.subscribe(([connection, connected]) => {
if (connection?.state !== "ready") return;
const publications =
connection.value.livekitRoom.localParticipant.trackPublications.values();
if (connected) {
for (const p of publications) {
if (p.track?.isUpstreamPaused === true) {
const kind = p.track.kind;
logger.log(
`Resuming ${kind} track (MatrixRTC connection present)`,
);
p.track
.resumeUpstream()
.catch((e) =>
logger.error(
`Failed to resume ${kind} track after MatrixRTC reconnection`,
e,
),
);
}
}
} else {
for (const p of publications) {
if (p.track?.isUpstreamPaused === false) {
const kind = p.track.kind;
logger.log(
`Pausing ${kind} track (uncertain MatrixRTC connection)`,
);
p.track
.pauseUpstream()
.catch((e) =>
logger.error(
`Failed to pause ${kind} track after entering uncertain MatrixRTC connection`,
e,
),
);
}
}
}
});
// Join automatically // Join automatically
this.join(); // TODO-MULTI-SFU: Use this view model for the lobby as well, and only call this once 'join' is clicked? this.join(); // TODO-MULTI-SFU: Use this view model for the lobby as well, and only call this once 'join' is clicked?
} }

View File

@@ -12,12 +12,7 @@ import {
MembershipManagerEvent, MembershipManagerEvent,
Status, Status,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { import { ClientEvent, SyncState, type Room as MatrixRoom } from "matrix-js-sdk";
ClientEvent,
type MatrixClient,
SyncState,
type Room as MatrixRoom,
} from "matrix-js-sdk";
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest, combineLatest,
@@ -25,6 +20,7 @@ import {
map, map,
type Observable, type Observable,
of, of,
scan,
startWith, startWith,
switchMap, switchMap,
tap, tap,
@@ -33,7 +29,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../Behavior"; import { type Behavior } from "../Behavior";
import { type ConnectionManager } from "../remoteMembers/ConnectionManager"; import { type ConnectionManager } from "../remoteMembers/ConnectionManager";
import { type ObservableScope } from "../ObservableScope"; import { ObservableScope } from "../ObservableScope";
import { Publisher } from "./Publisher"; import { Publisher } from "./Publisher";
import { type MuteStates } from "../MuteStates"; import { type MuteStates } from "../MuteStates";
import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { type ProcessorState } from "../../livekit/TrackProcessorContext";
@@ -44,31 +40,10 @@ import {
enterRTCSession, enterRTCSession,
type EnterRTCSessionOptions, type EnterRTCSessionOptions,
} from "../../rtcSessionHelpers"; } from "../../rtcSessionHelpers";
import { ElementCallError } from "../../utils/errors";
import { Widget } from "matrix-widget-api";
import { ElementWidgetActions, WidgetHelpers } from "../../widget";
/*
* - get well known
* - get oldest membership
* - get transport to use
* - get openId + jwt token
* - wait for createTrack() call
* - create tracks
* - wait for join() call
* - Publisher.publishTracks()
* - send join state/sticky event
*/
interface Props {
scope: ObservableScope;
mediaDevices: MediaDevices;
muteStates: MuteStates;
connectionManager: ConnectionManager;
matrixRTCSession: MatrixRTCSession;
matrixRoom: MatrixRoom;
localTransport$: Behavior<LivekitTransport>;
client: MatrixClient;
roomId: string;
e2eeLivekitOptions: E2EEOptions | undefined;
trackerProcessorState$: Behavior<ProcessorState>;
}
enum LivekitState { enum LivekitState {
UNINITIALIZED = "uninitialized", UNINITIALIZED = "uninitialized",
CONNECTING = "connecting", CONNECTING = "connecting",
@@ -95,10 +70,35 @@ type LocalMemberMatrixState =
| { state: MatrixState.CONNECTING } | { state: MatrixState.CONNECTING }
| { state: MatrixState.DISCONNECTED }; | { state: MatrixState.DISCONNECTED };
interface LocalMemberState { export interface LocalMemberState {
livekit$: BehaviorSubject<LocalMemberLivekitState>; livekit$: BehaviorSubject<LocalMemberLivekitState>;
matrix$: BehaviorSubject<LocalMemberMatrixState>; matrix$: BehaviorSubject<LocalMemberMatrixState>;
} }
/*
* - get well known
* - get oldest membership
* - get transport to use
* - get openId + jwt token
* - wait for createTrack() call
* - create tracks
* - wait for join() call
* - Publisher.publishTracks()
* - send join state/sticky event
*/
interface Props {
scope: ObservableScope;
mediaDevices: MediaDevices;
muteStates: MuteStates;
connectionManager: ConnectionManager;
matrixRTCSession: MatrixRTCSession;
matrixRoom: MatrixRoom;
localTransport$: Behavior<LivekitTransport | undefined>;
e2eeLivekitOptions: E2EEOptions | undefined;
trackProcessorState$: Behavior<ProcessorState>;
widget: WidgetHelpers | null;
}
/** /**
* This class is responsible for managing the own membership in a room. * This class is responsible for managing the own membership in a room.
* We want * We want
@@ -120,7 +120,8 @@ export const localMembership$ = ({
localTransport$, localTransport$,
matrixRoom, matrixRoom,
e2eeLivekitOptions, e2eeLivekitOptions,
trackerProcessorState$, trackProcessorState$,
widget,
}: Props): { }: Props): {
// publisher: Publisher // publisher: Publisher
requestConnect: (options: EnterRTCSessionOptions) => LocalMemberState; requestConnect: (options: EnterRTCSessionOptions) => LocalMemberState;
@@ -129,6 +130,8 @@ export const localMembership$ = ({
state: LocalMemberState; // TODO this is probably superseeded by joinState$ state: LocalMemberState; // TODO this is probably superseeded by joinState$
homeserverConnected$: Behavior<boolean>; homeserverConnected$: Behavior<boolean>;
connected$: Behavior<boolean>; connected$: Behavior<boolean>;
reconnecting$: Behavior<boolean>;
configError$: Behavior<ElementCallError | null>;
} => { } => {
const state = { const state = {
livekit$: new BehaviorSubject<LocalMemberLivekitState>({ livekit$: new BehaviorSubject<LocalMemberLivekitState>({
@@ -148,11 +151,12 @@ export const localMembership$ = ({
const connection$ = scope.behavior( const connection$ = scope.behavior(
combineLatest([connectionManager.connections$, localTransport$]).pipe( combineLatest([connectionManager.connections$, localTransport$]).pipe(
map(([connections, transport]) => map(([connections, transport]) => {
connections.find((connection) => if (transport === undefined) return undefined;
return connections.find((connection) =>
areLivekitTransportsEqual(connection.transport, transport), areLivekitTransportsEqual(connection.transport, transport),
), );
), }),
), ),
); );
/** /**
@@ -214,7 +218,7 @@ export const localMembership$ = ({
mediaDevices, mediaDevices,
muteStates, muteStates,
e2eeLivekitOptions, e2eeLivekitOptions,
trackerProcessorState$, trackProcessorState$,
) )
: null, : null,
), ),
@@ -242,31 +246,28 @@ export const localMembership$ = ({
// /** // /**
// * Whether we should tell the user that we're reconnecting to the call. // * Whether we should tell the user that we're reconnecting to the call.
// */ // */
// // DISCUSSION own membership manager // DISCUSSION is there a better way to do this?
// const reconnecting$ = scope.behavior( // sth that is more deriectly implied from the membership manager of the js sdk. (fromEvent(matrixRTCSession, Reconnecting)) ??? or similar
// connected$.pipe( const reconnecting$ = scope.behavior(
// // We are reconnecting if we previously had some successful initial connected$.pipe(
// // connection but are now disconnected // We are reconnecting if we previously had some successful initial
// scan( // connection but are now disconnected
// ({ connectedPreviously }, connectedNow) => ({ scan(
// connectedPreviously: connectedPreviously || connectedNow, ({ connectedPreviously }, connectedNow) => ({
// reconnecting: connectedPreviously && !connectedNow, connectedPreviously: connectedPreviously || connectedNow,
// }), reconnecting: connectedPreviously && !connectedNow,
// { connectedPreviously: false, reconnecting: false }, }),
// ), { connectedPreviously: false, reconnecting: false },
// map(({ reconnecting }) => reconnecting), ),
// ), map(({ reconnecting }) => reconnecting),
// ); ),
);
const startTracks = (): Behavior<LocalTrack[]> => { const startTracks = (): Behavior<LocalTrack[]> => {
shouldStartTracks$.next(true); shouldStartTracks$.next(true);
return tracks$; return tracks$;
}; };
// const joinState$ = new BehaviorSubject<LocalMemberLivekitState>({
// state: LivekitState.UNINITIALIZED,
// });
const requestConnect = ( const requestConnect = (
options: EnterRTCSessionOptions, options: EnterRTCSessionOptions,
): LocalMemberState => { ): LocalMemberState => {
@@ -288,11 +289,15 @@ export const localMembership$ = ({
state.matrix$.next({ state: MatrixState.CONNECTING }); state.matrix$.next({ state: MatrixState.CONNECTING });
localTransport$.pipe( localTransport$.pipe(
tap((transport) => { tap((transport) => {
enterRTCSession(matrixRTCSession, transport, options).catch( if (transport !== undefined) {
(error) => { enterRTCSession(matrixRTCSession, transport, options).catch(
logger.error(error); (error) => {
}, logger.error(error);
); },
);
} else {
logger.info("Waiting for transport to enter rtc session");
}
}), }),
); );
} }
@@ -317,6 +322,93 @@ export const localMembership$ = ({
return state.livekit$; return state.livekit$;
}; };
// Pause upstream of all local media tracks when we're disconnected from
// MatrixRTC, because it can be an unpleasant surprise for the app to say
// 'reconnecting' and yet still be transmitting your media to others.
// We use matrixConnected$ rather than reconnecting$ because we want to
// pause tracks during the initial joining sequence too until we're sure
// that our own media is displayed on screen.
combineLatest([connection$, homeserverConnected$])
.pipe(scope.bind())
.subscribe(([connection, connected]) => {
if (connection?.state$.value.state !== "ConnectedToLkRoom") return;
const publications =
connection.livekitRoom.localParticipant.trackPublications.values();
if (connected) {
for (const p of publications) {
if (p.track?.isUpstreamPaused === true) {
const kind = p.track.kind;
logger.log(`Resuming ${kind} track (MatrixRTC connection present)`);
p.track
.resumeUpstream()
.catch((e) =>
logger.error(
`Failed to resume ${kind} track after MatrixRTC reconnection`,
e,
),
);
}
}
} else {
for (const p of publications) {
if (p.track?.isUpstreamPaused === false) {
const kind = p.track.kind;
logger.log(
`Pausing ${kind} track (uncertain MatrixRTC connection)`,
);
p.track
.pauseUpstream()
.catch((e) =>
logger.error(
`Failed to pause ${kind} track after entering uncertain MatrixRTC connection`,
e,
),
);
}
}
}
});
const configError$ = new BehaviorSubject<ElementCallError | null>(null);
// TODO I do not fully understand what this does.
// Is it needed?
// Is this at the right place?
// Can this be simplified?
// Start and stop session membership as needed
scope.reconcile(localTransport$, async (advertised) => {
if (advertised !== null && advertised !== undefined) {
try {
configError$.next(null);
await enterRTCSession(matrixRTCSession, advertised, options);
} catch (e) {
logger.error("Error entering RTC session", e);
}
// Update our member event when our mute state changes.
const intentScope = new ObservableScope();
intentScope.reconcile(muteStates.video.enabled$, async (videoEnabled) =>
matrixRTCSession.updateCallIntent(videoEnabled ? "video" : "audio"),
);
return async (): Promise<void> => {
intentScope.end();
// Only sends Matrix leave event. The LiveKit session will disconnect
// as soon as either the stopConnection$ handler above gets to it or
// the view model is destroyed.
try {
await matrixRTCSession.leaveRoomSession();
} catch (e) {
logger.error("Error leaving RTC session", e);
}
try {
await widget?.api.transport.send(ElementWidgetActions.HangupCall, {});
} catch (e) {
logger.error("Failed to send hangup action", e);
}
};
}
});
return { return {
startTracks, startTracks,
requestConnect, requestConnect,
@@ -324,5 +416,7 @@ export const localMembership$ = ({
state, state,
homeserverConnected$, homeserverConnected$,
connected$, connected$,
reconnecting$,
configError$,
}; };
}; };

View File

@@ -14,7 +14,7 @@ import {
type ParticipantId, type ParticipantId,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, combineLatest, map, switchMap } from "rxjs"; import { BehaviorSubject, combineLatest, map, switchMap } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger"; import { logger, type Logger } from "matrix-js-sdk/lib/logger";
import { type Participant as LivekitParticipant } from "livekit-client"; import { type Participant as LivekitParticipant } from "livekit-client";
import { type Behavior } from "../Behavior"; import { type Behavior } from "../Behavior";
@@ -106,8 +106,8 @@ export class ConnectionManager {
private readonly scope: ObservableScope, private readonly scope: ObservableScope,
private readonly connectionFactory: ConnectionFactory, private readonly connectionFactory: ConnectionFactory,
private readonly inputTransports$: Behavior<LivekitTransport[]>, private readonly inputTransports$: Behavior<LivekitTransport[]>,
logger: Logger,
) { ) {
// TODO logger: only construct one logger from the client and make it compatible via a EC specific singleton.
this.logger = logger.getChild("ConnectionManager"); this.logger = logger.getChild("ConnectionManager");
scope.onEnd(() => this.running$.next(false)); scope.onEnd(() => this.running$.next(false));
} }