@@ -78,6 +78,8 @@ export function MatrixAudioRenderer({
|
|||||||
loggedInvalidIdentities.current.add(identity);
|
loggedInvalidIdentities.current.add(identity);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO-MULTI-SFU this uses the livekit room form the context. We need to change it so it uses the
|
||||||
|
// livekit room explicitly so we can pass a list of rooms into the audio renderer and call useTracks for each room.
|
||||||
const tracks = useTracks(
|
const tracks = useTracks(
|
||||||
[
|
[
|
||||||
Track.Source.Microphone,
|
Track.Source.Microphone,
|
||||||
|
|||||||
123
src/livekit/livekitSubscriptionRoom.ts
Normal file
123
src/livekit/livekitSubscriptionRoom.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023, 2024 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 {
|
||||||
|
ConnectionState,
|
||||||
|
type E2EEManagerOptions,
|
||||||
|
ExternalE2EEKeyProvider,
|
||||||
|
LocalVideoTrack,
|
||||||
|
Room,
|
||||||
|
type RoomOptions,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import E2EEWorker from "livekit-client/e2ee-worker?worker";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
|
import { defaultLiveKitOptions } from "./options";
|
||||||
|
import { type SFUConfig } from "./openIDSFU";
|
||||||
|
import { type MuteStates } from "../room/MuteStates";
|
||||||
|
import { useMediaDevices } from "../MediaDevicesContext";
|
||||||
|
import {
|
||||||
|
type ECConnectionState,
|
||||||
|
useECConnectionState,
|
||||||
|
} from "./useECConnectionState";
|
||||||
|
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider";
|
||||||
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
|
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
|
import {
|
||||||
|
useTrackProcessor,
|
||||||
|
useTrackProcessorSync,
|
||||||
|
} from "./TrackProcessorContext";
|
||||||
|
import { observeTrackReference$ } from "../state/MediaViewModel";
|
||||||
|
import { useUrlParams } from "../UrlParams";
|
||||||
|
import { useInitial } from "../useInitial";
|
||||||
|
import { getValue } from "../utils/observable";
|
||||||
|
import { type SelectedDevice } from "../state/MediaDevices";
|
||||||
|
|
||||||
|
interface UseLivekitResult {
|
||||||
|
livekitPublicationRoom?: Room;
|
||||||
|
connState: ECConnectionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO-MULTI-SFU This is all the logic we need in the subscription connection logic (sync output devices)
|
||||||
|
// This is not used! (but summarizes what we need)
|
||||||
|
export function livekitSubscriptionRoom(
|
||||||
|
rtcSession: MatrixRTCSession,
|
||||||
|
muteStates: MuteStates,
|
||||||
|
sfuConfig: SFUConfig | undefined,
|
||||||
|
e2eeSystem: EncryptionSystem,
|
||||||
|
): UseLivekitResult {
|
||||||
|
// Only ever create the room once via useInitial.
|
||||||
|
// The call can end up with multiple livekit rooms. This is the particular room in
|
||||||
|
// which this participant publishes their media.
|
||||||
|
const publicationRoom = useInitial(() => {
|
||||||
|
logger.info("[LivekitRoom] Create LiveKit room");
|
||||||
|
|
||||||
|
let e2ee: E2EEManagerOptions | undefined;
|
||||||
|
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||||
|
logger.info("Created MatrixKeyProvider (per participant)");
|
||||||
|
e2ee = {
|
||||||
|
keyProvider: new MatrixKeyProvider(),
|
||||||
|
worker: new E2EEWorker(),
|
||||||
|
};
|
||||||
|
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
|
||||||
|
logger.info("Created ExternalE2EEKeyProvider (shared key)");
|
||||||
|
e2ee = {
|
||||||
|
keyProvider: new ExternalE2EEKeyProvider(),
|
||||||
|
worker: new E2EEWorker(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomOptions: RoomOptions = {
|
||||||
|
...defaultLiveKitOptions,
|
||||||
|
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
|
||||||
|
: getValue(devices.audioOutput.selected$)?.id,
|
||||||
|
},
|
||||||
|
e2ee,
|
||||||
|
};
|
||||||
|
// We have to create the room manually here due to a bug inside
|
||||||
|
// @livekit/components-react. JSON.stringify() is used in deps of a
|
||||||
|
// useEffect() with an argument that references itself, if E2EE is enabled
|
||||||
|
const room = new Room(roomOptions);
|
||||||
|
room.setE2EEEnabled(e2eeSystem.kind !== E2eeType.NONE).catch((e) => {
|
||||||
|
logger.error("Failed to set E2EE enabled on room", e);
|
||||||
|
});
|
||||||
|
|
||||||
|
return room;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup and update the keyProvider which was create by `createRoom`
|
||||||
|
useEffect(() => {
|
||||||
|
const e2eeOptions = publicationRoom.options.e2ee;
|
||||||
|
if (
|
||||||
|
e2eeSystem.kind === E2eeType.NONE ||
|
||||||
|
!(e2eeOptions && "keyProvider" in e2eeOptions)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||||
|
(e2eeOptions.keyProvider as MatrixKeyProvider).setRTCSession(rtcSession);
|
||||||
|
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
|
||||||
|
(e2eeOptions.keyProvider as ExternalE2EEKeyProvider)
|
||||||
|
.setKey(e2eeSystem.secret)
|
||||||
|
.catch((e) => {
|
||||||
|
logger.error("Failed to set shared key for E2EE", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [publicationRoom.options.e2ee, e2eeSystem, rtcSession]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
connState: connectionState,
|
||||||
|
livekitPublicationRoom: publicationRoom,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,12 +7,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { type IOpenIDToken, type MatrixClient } from "matrix-js-sdk";
|
import { type IOpenIDToken, type MatrixClient } from "matrix-js-sdk";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { type LivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
|
|
||||||
|
|
||||||
import { useActiveLivekitFocus } from "../room/useActiveFocus";
|
|
||||||
import { useErrorBoundary } from "../useErrorBoundary";
|
|
||||||
import { FailToGetOpenIdToken } from "../utils/errors";
|
import { FailToGetOpenIdToken } from "../utils/errors";
|
||||||
import { doNetworkOperationWithRetry } from "../utils/matrix";
|
import { doNetworkOperationWithRetry } from "../utils/matrix";
|
||||||
|
|
||||||
@@ -34,38 +29,11 @@ export type OpenIDClientParts = Pick<
|
|||||||
"getOpenIdToken" | "getDeviceId"
|
"getOpenIdToken" | "getDeviceId"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export function useOpenIDSFU(
|
|
||||||
client: OpenIDClientParts,
|
|
||||||
rtcSession: MatrixRTCSession,
|
|
||||||
): SFUConfig | undefined {
|
|
||||||
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined);
|
|
||||||
|
|
||||||
const activeFocus = useActiveLivekitFocus(rtcSession);
|
|
||||||
const { showErrorBoundary } = useErrorBoundary();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeFocus) {
|
|
||||||
getSFUConfigWithOpenID(client, activeFocus).then(
|
|
||||||
(sfuConfig) => {
|
|
||||||
setSFUConfig(sfuConfig);
|
|
||||||
},
|
|
||||||
(e) => {
|
|
||||||
showErrorBoundary(new FailToGetOpenIdToken(e));
|
|
||||||
logger.error("Failed to get SFU config", e);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setSFUConfig(undefined);
|
|
||||||
}
|
|
||||||
}, [client, activeFocus, showErrorBoundary]);
|
|
||||||
|
|
||||||
return sfuConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSFUConfigWithOpenID(
|
export async function getSFUConfigWithOpenID(
|
||||||
client: OpenIDClientParts,
|
client: OpenIDClientParts,
|
||||||
activeFocus: LivekitFocus,
|
serviceUrl: string,
|
||||||
): Promise<SFUConfig | undefined> {
|
livekitAlias: string,
|
||||||
|
): Promise<SFUConfig> {
|
||||||
let openIdToken: IOpenIDToken;
|
let openIdToken: IOpenIDToken;
|
||||||
try {
|
try {
|
||||||
openIdToken = await doNetworkOperationWithRetry(async () =>
|
openIdToken = await doNetworkOperationWithRetry(async () =>
|
||||||
@@ -78,26 +46,16 @@ export async function getSFUConfigWithOpenID(
|
|||||||
}
|
}
|
||||||
logger.debug("Got openID token", openIdToken);
|
logger.debug("Got openID token", openIdToken);
|
||||||
|
|
||||||
try {
|
logger.info(`Trying to get JWT for focus ${serviceUrl}...`);
|
||||||
logger.info(
|
const sfuConfig = await getLiveKitJWT(
|
||||||
`Trying to get JWT from call's active focus URL of ${activeFocus.livekit_service_url}...`,
|
client,
|
||||||
);
|
serviceUrl,
|
||||||
const sfuConfig = await getLiveKitJWT(
|
livekitAlias,
|
||||||
client,
|
openIdToken,
|
||||||
activeFocus.livekit_service_url,
|
);
|
||||||
activeFocus.livekit_alias,
|
logger.info(`Got JWT from call's active focus URL.`);
|
||||||
openIdToken,
|
|
||||||
);
|
|
||||||
logger.info(`Got JWT from call's active focus URL.`);
|
|
||||||
|
|
||||||
return sfuConfig;
|
return sfuConfig;
|
||||||
} catch (e) {
|
|
||||||
logger.warn(
|
|
||||||
`Failed to get JWT from RTC session's active focus URL of ${activeFocus.livekit_service_url}.`,
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLiveKitJWT(
|
async function getLiveKitJWT(
|
||||||
|
|||||||
@@ -50,11 +50,12 @@ import { getValue } from "../utils/observable";
|
|||||||
import { type SelectedDevice } from "../state/MediaDevices";
|
import { type SelectedDevice } from "../state/MediaDevices";
|
||||||
|
|
||||||
interface UseLivekitResult {
|
interface UseLivekitResult {
|
||||||
livekitRoom?: Room;
|
livekitPublicationRoom?: Room;
|
||||||
connState: ECConnectionState;
|
connState: ECConnectionState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLivekit(
|
// TODO-MULTI-SFU This is not used anymore but the device syncing logic needs to be moved into the connection object.
|
||||||
|
export function useLivekitPublicationRoom(
|
||||||
rtcSession: MatrixRTCSession,
|
rtcSession: MatrixRTCSession,
|
||||||
muteStates: MuteStates,
|
muteStates: MuteStates,
|
||||||
sfuConfig: SFUConfig | undefined,
|
sfuConfig: SFUConfig | undefined,
|
||||||
@@ -83,7 +84,9 @@ export function useLivekit(
|
|||||||
const { processor } = useTrackProcessor();
|
const { processor } = useTrackProcessor();
|
||||||
|
|
||||||
// Only ever create the room once via useInitial.
|
// Only ever create the room once via useInitial.
|
||||||
const room = useInitial(() => {
|
// The call can end up with multiple livekit rooms. This is the particular room in
|
||||||
|
// which this participant publishes their media.
|
||||||
|
const publicationRoom = useInitial(() => {
|
||||||
logger.info("[LivekitRoom] Create LiveKit room");
|
logger.info("[LivekitRoom] Create LiveKit room");
|
||||||
|
|
||||||
let e2ee: E2EEManagerOptions | undefined;
|
let e2ee: E2EEManagerOptions | undefined;
|
||||||
@@ -135,7 +138,7 @@ export function useLivekit(
|
|||||||
|
|
||||||
// Setup and update the keyProvider which was create by `createRoom`
|
// Setup and update the keyProvider which was create by `createRoom`
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const e2eeOptions = room.options.e2ee;
|
const e2eeOptions = publicationRoom.options.e2ee;
|
||||||
if (
|
if (
|
||||||
e2eeSystem.kind === E2eeType.NONE ||
|
e2eeSystem.kind === E2eeType.NONE ||
|
||||||
!(e2eeOptions && "keyProvider" in e2eeOptions)
|
!(e2eeOptions && "keyProvider" in e2eeOptions)
|
||||||
@@ -151,7 +154,7 @@ export function useLivekit(
|
|||||||
logger.error("Failed to set shared key for E2EE", e);
|
logger.error("Failed to set shared key for E2EE", e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [room.options.e2ee, e2eeSystem, rtcSession]);
|
}, [publicationRoom.options.e2ee, e2eeSystem, rtcSession]);
|
||||||
|
|
||||||
// Sync the requested track processors with LiveKit
|
// Sync the requested track processors with LiveKit
|
||||||
useTrackProcessorSync(
|
useTrackProcessorSync(
|
||||||
@@ -170,7 +173,7 @@ export function useLivekit(
|
|||||||
return track instanceof LocalVideoTrack ? track : null;
|
return track instanceof LocalVideoTrack ? track : null;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
[room],
|
[publicationRoom],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -178,7 +181,7 @@ export function useLivekit(
|
|||||||
const connectionState = useECConnectionState(
|
const connectionState = useECConnectionState(
|
||||||
initialAudioInputId,
|
initialAudioInputId,
|
||||||
initialMuteStates.audio.enabled,
|
initialMuteStates.audio.enabled,
|
||||||
room,
|
publicationRoom,
|
||||||
sfuConfig,
|
sfuConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -216,8 +219,11 @@ export function useLivekit(
|
|||||||
// It's important that we only do this in the connected state, because
|
// It's important that we only do this in the connected state, because
|
||||||
// LiveKit's internal mute states aren't consistent during connection setup,
|
// LiveKit's internal mute states aren't consistent during connection setup,
|
||||||
// and setting tracks to be enabled during this time causes errors.
|
// and setting tracks to be enabled during this time causes errors.
|
||||||
if (room !== undefined && connectionState === ConnectionState.Connected) {
|
if (
|
||||||
const participant = room.localParticipant;
|
publicationRoom !== undefined &&
|
||||||
|
connectionState === ConnectionState.Connected
|
||||||
|
) {
|
||||||
|
const participant = publicationRoom.localParticipant;
|
||||||
// Always update the muteButtonState Ref so that we can read the current
|
// Always update the muteButtonState Ref so that we can read the current
|
||||||
// state in awaited blocks.
|
// state in awaited blocks.
|
||||||
buttonEnabled.current = {
|
buttonEnabled.current = {
|
||||||
@@ -275,7 +281,7 @@ export function useLivekit(
|
|||||||
audioMuteUpdating.current = true;
|
audioMuteUpdating.current = true;
|
||||||
trackPublication = await participant.setMicrophoneEnabled(
|
trackPublication = await participant.setMicrophoneEnabled(
|
||||||
buttonEnabled.current.audio,
|
buttonEnabled.current.audio,
|
||||||
room.options.audioCaptureDefaults,
|
publicationRoom.options.audioCaptureDefaults,
|
||||||
);
|
);
|
||||||
audioMuteUpdating.current = false;
|
audioMuteUpdating.current = false;
|
||||||
break;
|
break;
|
||||||
@@ -283,7 +289,7 @@ export function useLivekit(
|
|||||||
videoMuteUpdating.current = true;
|
videoMuteUpdating.current = true;
|
||||||
trackPublication = await participant.setCameraEnabled(
|
trackPublication = await participant.setCameraEnabled(
|
||||||
buttonEnabled.current.video,
|
buttonEnabled.current.video,
|
||||||
room.options.videoCaptureDefaults,
|
publicationRoom.options.videoCaptureDefaults,
|
||||||
);
|
);
|
||||||
videoMuteUpdating.current = false;
|
videoMuteUpdating.current = false;
|
||||||
break;
|
break;
|
||||||
@@ -347,11 +353,14 @@ export function useLivekit(
|
|||||||
logger.error("Failed to sync video mute state with LiveKit", e);
|
logger.error("Failed to sync video mute state with LiveKit", e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [room, muteStates, connectionState]);
|
}, [publicationRoom, muteStates, connectionState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Sync the requested devices with LiveKit's devices
|
// Sync the requested devices with LiveKit's devices
|
||||||
if (room !== undefined && connectionState === ConnectionState.Connected) {
|
if (
|
||||||
|
publicationRoom !== undefined &&
|
||||||
|
connectionState === ConnectionState.Connected
|
||||||
|
) {
|
||||||
const syncDevice = (
|
const syncDevice = (
|
||||||
kind: MediaDeviceKind,
|
kind: MediaDeviceKind,
|
||||||
selected$: Observable<SelectedDevice | undefined>,
|
selected$: Observable<SelectedDevice | undefined>,
|
||||||
@@ -359,15 +368,15 @@ export function useLivekit(
|
|||||||
selected$.subscribe((device) => {
|
selected$.subscribe((device) => {
|
||||||
logger.info(
|
logger.info(
|
||||||
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
|
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
|
||||||
room.getActiveDevice(kind),
|
publicationRoom.getActiveDevice(kind),
|
||||||
" !== ",
|
" !== ",
|
||||||
device?.id,
|
device?.id,
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
device !== undefined &&
|
device !== undefined &&
|
||||||
room.getActiveDevice(kind) !== device.id
|
publicationRoom.getActiveDevice(kind) !== device.id
|
||||||
) {
|
) {
|
||||||
room
|
publicationRoom
|
||||||
.switchActiveDevice(kind, device.id)
|
.switchActiveDevice(kind, device.id)
|
||||||
.catch((e) =>
|
.catch((e) =>
|
||||||
logger.error(`Failed to sync ${kind} device with LiveKit`, e),
|
logger.error(`Failed to sync ${kind} device with LiveKit`, e),
|
||||||
@@ -393,7 +402,7 @@ export function useLivekit(
|
|||||||
.pipe(switchMap((device) => device?.hardwareDeviceChange$ ?? NEVER))
|
.pipe(switchMap((device) => device?.hardwareDeviceChange$ ?? NEVER))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
const activeMicTrack = Array.from(
|
const activeMicTrack = Array.from(
|
||||||
room.localParticipant.audioTrackPublications.values(),
|
publicationRoom.localParticipant.audioTrackPublications.values(),
|
||||||
).find((d) => d.source === Track.Source.Microphone)?.track;
|
).find((d) => d.source === Track.Source.Microphone)?.track;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -408,7 +417,7 @@ export function useLivekit(
|
|||||||
// getUserMedia() call with deviceId: default to get the *new* default device.
|
// getUserMedia() call with deviceId: default to get the *new* default device.
|
||||||
// Note that room.switchActiveDevice() won't work: Livekit will ignore it because
|
// Note that room.switchActiveDevice() won't work: Livekit will ignore it because
|
||||||
// the deviceId hasn't changed (was & still is default).
|
// the deviceId hasn't changed (was & still is default).
|
||||||
room.localParticipant
|
publicationRoom.localParticipant
|
||||||
.getTrackPublication(Track.Source.Microphone)
|
.getTrackPublication(Track.Source.Microphone)
|
||||||
?.audioTrack?.restartTrack()
|
?.audioTrack?.restartTrack()
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -422,10 +431,10 @@ export function useLivekit(
|
|||||||
for (const s of subscriptions) s?.unsubscribe();
|
for (const s of subscriptions) s?.unsubscribe();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [room, devices, connectionState, controlledAudioDevices]);
|
}, [publicationRoom, devices, connectionState, controlledAudioDevices]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connState: connectionState,
|
connState: connectionState,
|
||||||
livekitRoom: room,
|
livekitPublicationRoom: publicationRoom,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
|
|
||||||
import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
|
import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
|
||||||
import { ConnectionState, type Room as LivekitRoom } from "livekit-client";
|
|
||||||
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
type FC,
|
type FC,
|
||||||
@@ -37,6 +35,7 @@ import {
|
|||||||
VolumeOnSolidIcon,
|
VolumeOnSolidIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ConnectionState } from "livekit-client";
|
||||||
|
|
||||||
import LogoMark from "../icons/LogoMark.svg?react";
|
import LogoMark from "../icons/LogoMark.svg?react";
|
||||||
import LogoType from "../icons/LogoType.svg?react";
|
import LogoType from "../icons/LogoType.svg?react";
|
||||||
@@ -59,14 +58,12 @@ import { type OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
|||||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||||
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
||||||
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
||||||
import { useLivekit } from "../livekit/useLivekit.ts";
|
|
||||||
import { useWakeLock } from "../useWakeLock";
|
import { useWakeLock } from "../useWakeLock";
|
||||||
import { useMergedRefs } from "../useMergedRefs";
|
import { useMergedRefs } from "../useMergedRefs";
|
||||||
import { type MuteStates } from "./MuteStates";
|
import { type MuteStates } from "./MuteStates";
|
||||||
import { type MatrixInfo } from "./VideoPreview";
|
import { type MatrixInfo } from "./VideoPreview";
|
||||||
import { InviteButton } from "../button/InviteButton";
|
import { InviteButton } from "../button/InviteButton";
|
||||||
import { LayoutToggle } from "./LayoutToggle";
|
import { LayoutToggle } from "./LayoutToggle";
|
||||||
import { useOpenIDSFU } from "../livekit/openIDSFU";
|
|
||||||
import {
|
import {
|
||||||
CallViewModel,
|
CallViewModel,
|
||||||
type GridMode,
|
type GridMode,
|
||||||
@@ -108,9 +105,7 @@ import {
|
|||||||
useSetting,
|
useSetting,
|
||||||
} from "../settings/settings";
|
} from "../settings/settings";
|
||||||
import { ReactionsReader } from "../reactions/ReactionsReader";
|
import { ReactionsReader } from "../reactions/ReactionsReader";
|
||||||
import { ConnectionLostError } from "../utils/errors.ts";
|
|
||||||
import { useTypedEventEmitter } from "../useEvents.ts";
|
import { useTypedEventEmitter } from "../useEvents.ts";
|
||||||
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
|
|
||||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
|
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
|
||||||
import { useMediaDevices } from "../MediaDevicesContext.ts";
|
import { useMediaDevices } from "../MediaDevicesContext.ts";
|
||||||
@@ -125,7 +120,7 @@ import { prefetchSounds } from "../soundUtils";
|
|||||||
import { useAudioContext } from "../useAudioContext";
|
import { useAudioContext } from "../useAudioContext";
|
||||||
import ringtoneMp3 from "../sound/ringtone.mp3?url";
|
import ringtoneMp3 from "../sound/ringtone.mp3?url";
|
||||||
import ringtoneOgg from "../sound/ringtone.ogg?url";
|
import ringtoneOgg from "../sound/ringtone.ogg?url";
|
||||||
import { ObservableScope } from "../state/ObservableScope.ts";
|
import { ConnectionLostError } from "../utils/errors.ts";
|
||||||
|
|
||||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||||
|
|
||||||
@@ -138,92 +133,47 @@ export interface ActiveCallProps
|
|||||||
|
|
||||||
export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||||
const mediaDevices = useMediaDevices();
|
const mediaDevices = useMediaDevices();
|
||||||
const sfuConfig = useOpenIDSFU(props.client, props.rtcSession);
|
|
||||||
const { livekitRoom, connState } = useLivekit(
|
|
||||||
props.rtcSession,
|
|
||||||
props.muteStates,
|
|
||||||
sfuConfig,
|
|
||||||
props.e2eeSystem,
|
|
||||||
);
|
|
||||||
const observableScope = useInitial(() => new ObservableScope());
|
|
||||||
const connStateBehavior$ = useObservable(
|
|
||||||
(inputs$) =>
|
|
||||||
observableScope.behavior(
|
|
||||||
inputs$.pipe(map(([connState]) => connState)),
|
|
||||||
connState,
|
|
||||||
),
|
|
||||||
[connState],
|
|
||||||
);
|
|
||||||
const [vm, setVm] = useState<CallViewModel | null>(null);
|
const [vm, setVm] = useState<CallViewModel | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
|
||||||
logger.info(
|
|
||||||
`[Lifecycle] InCallView Component mounted, livekit room state ${livekitRoom?.state}`,
|
|
||||||
);
|
|
||||||
return (): void => {
|
|
||||||
logger.info(
|
|
||||||
`[Lifecycle] InCallView Component unmounted, livekit room state ${livekitRoom?.state}`,
|
|
||||||
);
|
|
||||||
livekitRoom
|
|
||||||
?.disconnect()
|
|
||||||
.then(() => {
|
|
||||||
logger.info(
|
|
||||||
`[Lifecycle] Disconnected from livekit room, state:${livekitRoom?.state}`,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
logger.error("[Lifecycle] Failed to disconnect from livekit room", e);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}, [livekitRoom]);
|
|
||||||
|
|
||||||
const { autoLeaveWhenOthersLeft, sendNotificationType, waitForCallPickup } =
|
|
||||||
useUrlParams();
|
useUrlParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (livekitRoom !== undefined) {
|
const reactionsReader = new ReactionsReader(props.rtcSession);
|
||||||
const reactionsReader = new ReactionsReader(props.rtcSession);
|
const vm = new CallViewModel(
|
||||||
const vm = new CallViewModel(
|
props.rtcSession,
|
||||||
props.rtcSession,
|
props.matrixRoom,
|
||||||
props.matrixRoom,
|
mediaDevices,
|
||||||
livekitRoom,
|
{
|
||||||
mediaDevices,
|
encryptionSystem: props.e2eeSystem,
|
||||||
{
|
autoLeaveWhenOthersLeft,
|
||||||
encryptionSystem: props.e2eeSystem,
|
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
||||||
autoLeaveWhenOthersLeft,
|
},
|
||||||
waitForCallPickup:
|
reactionsReader.raisedHands$,
|
||||||
waitForCallPickup && sendNotificationType === "ring",
|
reactionsReader.reactions$,
|
||||||
},
|
props.e2eeSystem,
|
||||||
connStateBehavior$,
|
);
|
||||||
reactionsReader.raisedHands$,
|
setVm(vm);
|
||||||
reactionsReader.reactions$,
|
return (): void => {
|
||||||
);
|
vm.destroy();
|
||||||
setVm(vm);
|
reactionsReader.destroy();
|
||||||
return (): void => {
|
};
|
||||||
vm.destroy();
|
|
||||||
reactionsReader.destroy();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [
|
}, [
|
||||||
props.rtcSession,
|
props.rtcSession,
|
||||||
props.matrixRoom,
|
props.matrixRoom,
|
||||||
livekitRoom,
|
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
props.e2eeSystem,
|
props.e2eeSystem,
|
||||||
connStateBehavior$,
|
|
||||||
autoLeaveWhenOthersLeft,
|
autoLeaveWhenOthersLeft,
|
||||||
sendNotificationType,
|
sendNotificationType,
|
||||||
waitForCallPickup,
|
waitForCallPickup,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (livekitRoom === undefined || vm === null) return null;
|
if (vm === null) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RoomContext value={livekitRoom}>
|
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
|
||||||
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
|
<InCallView {...props} vm={vm} />
|
||||||
<InCallView {...props} vm={vm} livekitRoom={livekitRoom} />
|
</ReactionsSenderProvider>
|
||||||
</ReactionsSenderProvider>
|
|
||||||
</RoomContext>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -233,7 +183,6 @@ export interface InCallViewProps {
|
|||||||
matrixInfo: MatrixInfo;
|
matrixInfo: MatrixInfo;
|
||||||
rtcSession: MatrixRTCSession;
|
rtcSession: MatrixRTCSession;
|
||||||
matrixRoom: MatrixRoom;
|
matrixRoom: MatrixRoom;
|
||||||
livekitRoom: LivekitRoom;
|
|
||||||
muteStates: MuteStates;
|
muteStates: MuteStates;
|
||||||
/** Function to call when the user explicitly ends the call */
|
/** Function to call when the user explicitly ends the call */
|
||||||
onLeave: (cause: "user", soundFile?: CallEventSounds) => void;
|
onLeave: (cause: "user", soundFile?: CallEventSounds) => void;
|
||||||
@@ -248,7 +197,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
matrixInfo,
|
matrixInfo,
|
||||||
rtcSession,
|
rtcSession,
|
||||||
matrixRoom,
|
matrixRoom,
|
||||||
livekitRoom,
|
|
||||||
muteStates,
|
muteStates,
|
||||||
onLeave,
|
onLeave,
|
||||||
header: headerStyle,
|
header: headerStyle,
|
||||||
@@ -273,10 +221,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
|
|
||||||
const { hideScreensharing, showControls } = useUrlParams();
|
const { hideScreensharing, showControls } = useUrlParams();
|
||||||
|
|
||||||
const { isScreenShareEnabled, localParticipant } = useLocalParticipant({
|
|
||||||
room: livekitRoom,
|
|
||||||
});
|
|
||||||
|
|
||||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||||
// Call pickup state and display names are needed for waiting overlay/sounds
|
// Call pickup state and display names are needed for waiting overlay/sounds
|
||||||
const callPickupState = useBehavior(vm.callPickupState$);
|
const callPickupState = useBehavior(vm.callPickupState$);
|
||||||
@@ -806,15 +750,16 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const toggleScreensharing = useCallback(() => {
|
const toggleScreensharing = useCallback(() => {
|
||||||
localParticipant
|
throw new Error("TODO-MULTI-SFU");
|
||||||
.setScreenShareEnabled(!isScreenShareEnabled, {
|
// localParticipant
|
||||||
audio: true,
|
// .setScreenShareEnabled(!isScreenShareEnabled, {
|
||||||
selfBrowserSurface: "include",
|
// audio: true,
|
||||||
surfaceSwitching: "include",
|
// selfBrowserSurface: "include",
|
||||||
systemAudio: "include",
|
// surfaceSwitching: "include",
|
||||||
})
|
// systemAudio: "include",
|
||||||
.catch(logger.error);
|
// })
|
||||||
}, [localParticipant, isScreenShareEnabled]);
|
// .catch(logger.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const buttons: JSX.Element[] = [];
|
const buttons: JSX.Element[] = [];
|
||||||
|
|
||||||
@@ -841,7 +786,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
<ShareScreenButton
|
<ShareScreenButton
|
||||||
key="share_screen"
|
key="share_screen"
|
||||||
className={styles.shareScreen}
|
className={styles.shareScreen}
|
||||||
enabled={isScreenShareEnabled}
|
enabled={false} // TODO-MULTI-SFU
|
||||||
onClick={toggleScreensharing}
|
onClick={toggleScreensharing}
|
||||||
onTouchEnd={onControlsTouchEnd}
|
onTouchEnd={onControlsTouchEnd}
|
||||||
data-testid="incall_screenshare"
|
data-testid="incall_screenshare"
|
||||||
@@ -936,7 +881,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
<MatrixAudioRenderer members={memberships} muted={muteAllAudio} />
|
{/* TODO-MULTI-SFU: <MatrixAudioRenderer members={memberships} muted={muteAllAudio} /> */}
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||||
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||||
@@ -955,7 +900,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
onDismiss={closeSettings}
|
onDismiss={closeSettings}
|
||||||
tab={settingsTab}
|
tab={settingsTab}
|
||||||
onTabChange={setSettingsTab}
|
onTabChange={setSettingsTab}
|
||||||
livekitRoom={livekitRoom}
|
livekitRoom={undefined} // TODO-MULTI-SFU
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import {
|
import {
|
||||||
isLivekitFocus,
|
isLivekitFocus,
|
||||||
isLivekitFocusConfig,
|
isLivekitFocusConfig,
|
||||||
|
LivekitFocusConfig,
|
||||||
type LivekitFocus,
|
type LivekitFocus,
|
||||||
type LivekitFocusActive,
|
type LivekitFocusActive,
|
||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
@@ -31,24 +32,16 @@ export function makeActiveFocus(): LivekitFocusActive {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function makePreferredLivekitFoci(
|
export function getLivekitAlias(rtcSession: MatrixRTCSession): string {
|
||||||
|
// For now we assume everything is a room-scoped call
|
||||||
|
return rtcSession.room.roomId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeFocusInternal(
|
||||||
rtcSession: MatrixRTCSession,
|
rtcSession: MatrixRTCSession,
|
||||||
livekitAlias: string,
|
): Promise<LivekitFocus> {
|
||||||
): Promise<LivekitFocus[]> {
|
logger.log("Searching for a preferred focus");
|
||||||
logger.log("Start building foci_preferred list: ", rtcSession.room.roomId);
|
const livekitAlias = getLivekitAlias(rtcSession);
|
||||||
|
|
||||||
const preferredFoci: LivekitFocus[] = [];
|
|
||||||
|
|
||||||
// Make the Focus from the running rtc session the highest priority one
|
|
||||||
// This minimizes how often we need to switch foci during a call.
|
|
||||||
const focusInUse = rtcSession.getFocusInUse();
|
|
||||||
if (focusInUse && isLivekitFocus(focusInUse)) {
|
|
||||||
logger.log("Adding livekit focus from oldest member: ", focusInUse);
|
|
||||||
preferredFoci.push(focusInUse);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warm up the first focus we owned, to ensure livekit room is created before any state event sent.
|
|
||||||
let toWarmUp: LivekitFocus | undefined;
|
|
||||||
|
|
||||||
// Prioritize the .well-known/matrix/client, if available, over the configured SFU
|
// Prioritize the .well-known/matrix/client, if available, over the configured SFU
|
||||||
const domain = rtcSession.room.client.getDomain();
|
const domain = rtcSession.room.client.getDomain();
|
||||||
@@ -59,51 +52,42 @@ async function makePreferredLivekitFoci(
|
|||||||
FOCI_WK_KEY
|
FOCI_WK_KEY
|
||||||
];
|
];
|
||||||
if (Array.isArray(wellKnownFoci)) {
|
if (Array.isArray(wellKnownFoci)) {
|
||||||
const validWellKnownFoci = wellKnownFoci
|
const focus: LivekitFocusConfig | undefined = wellKnownFoci.find(
|
||||||
.filter((f) => !!f)
|
(f) => f && isLivekitFocusConfig(f),
|
||||||
.filter(isLivekitFocusConfig)
|
);
|
||||||
.map((wellKnownFocus) => {
|
if (focus !== undefined) {
|
||||||
logger.log("Adding livekit focus from well known: ", wellKnownFocus);
|
logger.log("Using LiveKit focus from .well-known: ", focus);
|
||||||
return { ...wellKnownFocus, livekit_alias: livekitAlias };
|
return { ...focus, livekit_alias: livekitAlias };
|
||||||
});
|
|
||||||
if (validWellKnownFoci.length > 0) {
|
|
||||||
toWarmUp = validWellKnownFoci[0];
|
|
||||||
}
|
}
|
||||||
preferredFoci.push(...validWellKnownFoci);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const urlFromConf = Config.get().livekit?.livekit_service_url;
|
const urlFromConf = Config.get().livekit?.livekit_service_url;
|
||||||
if (urlFromConf) {
|
if (urlFromConf) {
|
||||||
const focusFormConf: LivekitFocus = {
|
const focusFromConf: LivekitFocus = {
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
livekit_service_url: urlFromConf,
|
livekit_service_url: urlFromConf,
|
||||||
livekit_alias: livekitAlias,
|
livekit_alias: livekitAlias,
|
||||||
};
|
};
|
||||||
toWarmUp = toWarmUp ?? focusFormConf;
|
logger.log("Using LiveKit focus from config: ", focusFromConf);
|
||||||
logger.log("Adding livekit focus from config: ", focusFormConf);
|
return focusFromConf;
|
||||||
preferredFoci.push(focusFormConf);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toWarmUp) {
|
throw new MatrixRTCFocusMissingError(domain ?? "");
|
||||||
// this will call the jwt/sfu/get endpoint to pre create the livekit room.
|
}
|
||||||
await getSFUConfigWithOpenID(rtcSession.room.client, toWarmUp);
|
|
||||||
}
|
|
||||||
if (preferredFoci.length === 0)
|
|
||||||
throw new MatrixRTCFocusMissingError(domain ?? "");
|
|
||||||
return Promise.resolve(preferredFoci);
|
|
||||||
|
|
||||||
// TODO: we want to do something like this:
|
export async function makeFocus(
|
||||||
//
|
rtcSession: MatrixRTCSession,
|
||||||
// const focusOtherMembers = await focusFromOtherMembers(
|
): Promise<LivekitFocus> {
|
||||||
// rtcSession,
|
const focus = await makeFocusInternal(rtcSession);
|
||||||
// livekitAlias,
|
// this will call the jwt/sfu/get endpoint to pre create the livekit room.
|
||||||
// );
|
await getSFUConfigWithOpenID(rtcSession.room.client, focus);
|
||||||
// if (focusOtherMembers) preferredFoci.push(focusOtherMembers);
|
return focus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enterRTCSession(
|
export async function enterRTCSession(
|
||||||
rtcSession: MatrixRTCSession,
|
rtcSession: MatrixRTCSession,
|
||||||
|
focus: LivekitFocus,
|
||||||
encryptMedia: boolean,
|
encryptMedia: boolean,
|
||||||
useNewMembershipManager = true,
|
useNewMembershipManager = true,
|
||||||
useExperimentalToDeviceTransport = false,
|
useExperimentalToDeviceTransport = false,
|
||||||
@@ -115,34 +99,27 @@ export async function enterRTCSession(
|
|||||||
// have started tracking by the time calls start getting created.
|
// have started tracking by the time calls start getting created.
|
||||||
// groupCallOTelMembership?.onJoinCall();
|
// groupCallOTelMembership?.onJoinCall();
|
||||||
|
|
||||||
// right now we assume everything is a room-scoped call
|
|
||||||
const livekitAlias = rtcSession.room.roomId;
|
|
||||||
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
|
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
|
||||||
const useDeviceSessionMemberEvents =
|
const useDeviceSessionMemberEvents =
|
||||||
features?.feature_use_device_session_member_events;
|
features?.feature_use_device_session_member_events;
|
||||||
rtcSession.joinRoomSession(
|
rtcSession.joinRoomSession([focus], focus, {
|
||||||
await makePreferredLivekitFoci(rtcSession, livekitAlias),
|
notificationType: getUrlParams().sendNotificationType,
|
||||||
makeActiveFocus(),
|
useNewMembershipManager,
|
||||||
{
|
manageMediaKeys: encryptMedia,
|
||||||
notificationType: getUrlParams().sendNotificationType,
|
...(useDeviceSessionMemberEvents !== undefined && {
|
||||||
useNewMembershipManager,
|
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
|
||||||
manageMediaKeys: encryptMedia,
|
}),
|
||||||
...(useDeviceSessionMemberEvents !== undefined && {
|
delayedLeaveEventRestartMs:
|
||||||
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
|
matrixRtcSessionConfig?.delayed_leave_event_restart_ms,
|
||||||
}),
|
delayedLeaveEventDelayMs:
|
||||||
delayedLeaveEventRestartMs:
|
matrixRtcSessionConfig?.delayed_leave_event_delay_ms,
|
||||||
matrixRtcSessionConfig?.delayed_leave_event_restart_ms,
|
delayedLeaveEventRestartLocalTimeoutMs:
|
||||||
delayedLeaveEventDelayMs:
|
matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms,
|
||||||
matrixRtcSessionConfig?.delayed_leave_event_delay_ms,
|
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
|
||||||
delayedLeaveEventRestartLocalTimeoutMs:
|
makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms,
|
||||||
matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms,
|
membershipEventExpiryMs: matrixRtcSessionConfig?.membership_event_expiry_ms,
|
||||||
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
|
useExperimentalToDeviceTransport,
|
||||||
makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms,
|
});
|
||||||
membershipEventExpiryMs:
|
|
||||||
matrixRtcSessionConfig?.membership_event_expiry_ms,
|
|
||||||
useExperimentalToDeviceTransport,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (widget) {
|
if (widget) {
|
||||||
try {
|
try {
|
||||||
await widget.api.transport.send(ElementWidgetActions.JoinCall, {});
|
await widget.api.transport.send(ElementWidgetActions.JoinCall, {});
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
} from "@livekit/components-core";
|
} from "@livekit/components-core";
|
||||||
import {
|
import {
|
||||||
ConnectionState,
|
ConnectionState,
|
||||||
type Room as LivekitRoom,
|
E2EEOptions,
|
||||||
|
ExternalE2EEKeyProvider,
|
||||||
|
Room as LivekitRoom,
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
type EventTimelineSetHandlerMap,
|
type EventTimelineSetHandlerMap,
|
||||||
EventType,
|
EventType,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
|
MatrixClient,
|
||||||
RoomStateEvent,
|
RoomStateEvent,
|
||||||
SyncState,
|
SyncState,
|
||||||
type Room as MatrixRoom,
|
type Room as MatrixRoom,
|
||||||
@@ -63,6 +66,7 @@ import {
|
|||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
import {
|
||||||
type CallMembership,
|
type CallMembership,
|
||||||
|
isLivekitFocusConfig,
|
||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
MatrixRTCSessionEvent,
|
MatrixRTCSessionEvent,
|
||||||
type MatrixRTCSessionEventHandlerMap,
|
type MatrixRTCSessionEventHandlerMap,
|
||||||
@@ -116,7 +120,16 @@ import { observeSpeaker$ } from "./observeSpeaker";
|
|||||||
import { shallowEquals } from "../utils/array";
|
import { shallowEquals } from "../utils/array";
|
||||||
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
|
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
|
||||||
import { type MediaDevices } from "./MediaDevices";
|
import { type MediaDevices } from "./MediaDevices";
|
||||||
import { constant, type Behavior } from "./Behavior";
|
import { type Behavior } from "./Behavior";
|
||||||
|
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
|
||||||
|
import { defaultLiveKitOptions } from "../livekit/options";
|
||||||
|
import {
|
||||||
|
enterRTCSession,
|
||||||
|
getLivekitAlias,
|
||||||
|
makeFocus,
|
||||||
|
} from "../rtcSessionHelpers";
|
||||||
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
|
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider";
|
||||||
|
|
||||||
export interface CallViewModelOptions {
|
export interface CallViewModelOptions {
|
||||||
encryptionSystem: EncryptionSystem;
|
encryptionSystem: EncryptionSystem;
|
||||||
@@ -405,6 +418,31 @@ class ScreenShare {
|
|||||||
|
|
||||||
type MediaItem = UserMedia | ScreenShare;
|
type MediaItem = UserMedia | ScreenShare;
|
||||||
|
|
||||||
|
function getE2eeOptions(
|
||||||
|
e2eeSystem: EncryptionSystem,
|
||||||
|
rtcSession: MatrixRTCSession,
|
||||||
|
): E2EEOptions | undefined {
|
||||||
|
if (e2eeSystem.kind === E2eeType.NONE) return undefined;
|
||||||
|
|
||||||
|
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||||
|
const keyProvider = new MatrixKeyProvider();
|
||||||
|
keyProvider.setRTCSession(rtcSession);
|
||||||
|
return {
|
||||||
|
keyProvider,
|
||||||
|
worker: new E2EEWorker(),
|
||||||
|
};
|
||||||
|
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
|
||||||
|
const keyProvider = new ExternalE2EEKeyProvider();
|
||||||
|
keyProvider
|
||||||
|
.setKey(e2eeSystem.secret)
|
||||||
|
.catch((e) => logger.error("Failed to set shared key for E2EE", e));
|
||||||
|
return {
|
||||||
|
keyProvider,
|
||||||
|
worker: new E2EEWorker(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getRoomMemberFromRtcMember(
|
function getRoomMemberFromRtcMember(
|
||||||
rtcMember: CallMembership,
|
rtcMember: CallMembership,
|
||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
@@ -427,8 +465,151 @@ function getRoomMemberFromRtcMember(
|
|||||||
return { id, member };
|
return { id, member };
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
class Connection {
|
||||||
|
// TODO-MULTI-SFU Add all device syncing logic from useLivekit
|
||||||
|
private readonly sfuConfig = getSFUConfigWithOpenID(
|
||||||
|
this.client,
|
||||||
|
this.serviceUrl,
|
||||||
|
this.livekitAlias,
|
||||||
|
);
|
||||||
|
|
||||||
|
public async startSubscribing(): Promise<void> {
|
||||||
|
this.stopped = false;
|
||||||
|
const { url, jwt } = await this.sfuConfig;
|
||||||
|
if (!this.stopped) await this.livekitRoom.connect(url, jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async startPublishing(): Promise<void> {
|
||||||
|
this.stopped = false;
|
||||||
|
const { url, jwt } = await this.sfuConfig;
|
||||||
|
if (!this.stopped)
|
||||||
|
// TODO-MULTI-SFU this should not create a track?
|
||||||
|
await this.livekitRoom.localParticipant.createTracks({
|
||||||
|
audio: { deviceId: "default" },
|
||||||
|
});
|
||||||
|
if (!this.stopped) await this.livekitRoom.connect(url, jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopped = false;
|
||||||
|
|
||||||
|
public stop(): void {
|
||||||
|
void this.livekitRoom.disconnect();
|
||||||
|
this.stopped = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly participants$ = connectedParticipantsObserver(
|
||||||
|
this.livekitRoom,
|
||||||
|
).pipe(this.scope.state());
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly livekitRoom: LivekitRoom,
|
||||||
|
private readonly serviceUrl: string,
|
||||||
|
private readonly livekitAlias: string,
|
||||||
|
private readonly client: MatrixClient,
|
||||||
|
private readonly scope: ObservableScope,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
export class CallViewModel extends ViewModel {
|
export class CallViewModel extends ViewModel {
|
||||||
|
private readonly e2eeOptions = getE2eeOptions(
|
||||||
|
this.encryptionSystem,
|
||||||
|
this.matrixRTCSession,
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly livekitAlias = getLivekitAlias(this.matrixRTCSession);
|
||||||
|
|
||||||
|
private readonly livekitRoom = new LivekitRoom({
|
||||||
|
...defaultLiveKitOptions,
|
||||||
|
e2ee: this.e2eeOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly localFocus = makeFocus(this.matrixRTCSession);
|
||||||
|
|
||||||
|
private readonly localConnection = this.localFocus.then(
|
||||||
|
(focus) =>
|
||||||
|
new Connection(
|
||||||
|
this.livekitRoom,
|
||||||
|
focus.livekit_service_url,
|
||||||
|
this.livekitAlias,
|
||||||
|
this.matrixRTCSession.room.client,
|
||||||
|
this.scope,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly memberships$ = fromEvent(
|
||||||
|
this.matrixRTCSession,
|
||||||
|
MatrixRTCSessionEvent.MembershipsChanged,
|
||||||
|
).pipe(map(() => this.matrixRTCSession.memberships));
|
||||||
|
|
||||||
|
private readonly foci$ = this.memberships$.pipe(
|
||||||
|
map(
|
||||||
|
(memberships) =>
|
||||||
|
new Set(
|
||||||
|
memberships
|
||||||
|
.map((m) => this.matrixRTCSession.resolveActiveFocus(m))
|
||||||
|
.filter((f) => f !== undefined && isLivekitFocusConfig(f))
|
||||||
|
.map((f) => f.livekit_service_url),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly remoteConnections$ = combineLatest([
|
||||||
|
this.localFocus,
|
||||||
|
this.foci$,
|
||||||
|
]).pipe(
|
||||||
|
accumulate(new Map<string, Connection>(), (prev, [localFocus, foci]) => {
|
||||||
|
const stopped = new Map(prev);
|
||||||
|
const next = new Map<string, Connection>();
|
||||||
|
|
||||||
|
for (const focus of foci) {
|
||||||
|
if (focus !== localFocus.livekit_service_url) {
|
||||||
|
stopped.delete(focus);
|
||||||
|
next.set(
|
||||||
|
focus,
|
||||||
|
prev.get(focus) ??
|
||||||
|
new Connection(
|
||||||
|
new LivekitRoom({
|
||||||
|
...defaultLiveKitOptions,
|
||||||
|
e2ee: this.e2eeOptions,
|
||||||
|
}),
|
||||||
|
focus,
|
||||||
|
this.livekitAlias,
|
||||||
|
this.matrixRTCSession.room.client,
|
||||||
|
this.scope,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const connection of stopped.values()) connection.stop();
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly joined$ = new Subject<void>();
|
||||||
|
|
||||||
|
public join(): void {
|
||||||
|
this.joined$.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
public leave(): void {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly connectionInstructions$ = this.joined$.pipe(
|
||||||
|
switchMap(() => this.remoteConnections$),
|
||||||
|
startWith(new Map<string, Connection>()),
|
||||||
|
pairwise(),
|
||||||
|
map(([prev, next]) => {
|
||||||
|
const start = new Set(next.values());
|
||||||
|
for (const connection of prev.values()) start.delete(connection);
|
||||||
|
const stop = new Set(prev.values());
|
||||||
|
for (const connection of next.values()) stop.delete(connection);
|
||||||
|
|
||||||
|
return { start, stop };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
private readonly userId = this.matrixRoom.client.getUserId();
|
private readonly userId = this.matrixRoom.client.getUserId();
|
||||||
|
|
||||||
private readonly matrixConnected$ = this.scope.behavior(
|
private readonly matrixConnected$ = this.scope.behavior(
|
||||||
@@ -502,79 +683,13 @@ export class CallViewModel extends ViewModel {
|
|||||||
// in a split-brained state.
|
// in a split-brained state.
|
||||||
private readonly pretendToBeDisconnected$ = this.reconnecting$;
|
private readonly pretendToBeDisconnected$ = this.reconnecting$;
|
||||||
|
|
||||||
/**
|
|
||||||
* The raw list of RemoteParticipants as reported by LiveKit
|
|
||||||
*/
|
|
||||||
private readonly rawRemoteParticipants$ = this.scope.behavior<
|
|
||||||
RemoteParticipant[]
|
|
||||||
>(connectedParticipantsObserver(this.livekitRoom), []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that
|
|
||||||
* they've left
|
|
||||||
*/
|
|
||||||
private readonly remoteParticipantHolds$ = this.scope.behavior<
|
|
||||||
RemoteParticipant[][]
|
|
||||||
>(
|
|
||||||
this.livekitConnectionState$.pipe(
|
|
||||||
withLatestFrom(this.rawRemoteParticipants$),
|
|
||||||
mergeMap(([s, ps]) => {
|
|
||||||
// Whenever we switch focuses, we should retain all the previous
|
|
||||||
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
|
|
||||||
// give their clients time to switch over and avoid jarring layout shifts
|
|
||||||
if (s === ECAddonConnectionState.ECSwitchingFocus) {
|
|
||||||
return concat(
|
|
||||||
// Hold these participants
|
|
||||||
of({ hold: ps }),
|
|
||||||
// Wait for time to pass and the connection state to have changed
|
|
||||||
forkJoin([
|
|
||||||
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
|
||||||
this.livekitConnectionState$.pipe(
|
|
||||||
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
|
|
||||||
take(1),
|
|
||||||
),
|
|
||||||
// Then unhold them
|
|
||||||
]).pipe(map(() => ({ unhold: ps }))),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return EMPTY;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
// Accumulate the hold instructions into a single list showing which
|
|
||||||
// participants are being held
|
|
||||||
accumulate([] as RemoteParticipant[][], (holds, instruction) =>
|
|
||||||
"hold" in instruction
|
|
||||||
? [instruction.hold, ...holds]
|
|
||||||
: holds.filter((h) => h !== instruction.unhold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The RemoteParticipants including those that are being "held" on the screen
|
* The RemoteParticipants including those that are being "held" on the screen
|
||||||
*/
|
*/
|
||||||
private readonly remoteParticipants$ = this.scope
|
private readonly remoteParticipants$ = this.scope
|
||||||
.behavior<RemoteParticipant[]>(
|
.behavior<
|
||||||
combineLatest(
|
RemoteParticipant[]
|
||||||
[this.rawRemoteParticipants$, this.remoteParticipantHolds$],
|
>(combineLatest([this.localConnection, this.remoteConnections$], (localConnection, remoteConnections) => combineLatest([localConnection.participants$, ...[...remoteConnections.values()].map((c) => c.participants$)], (...ps) => ps.flat(1))).pipe(switchAll(), startWith([])))
|
||||||
(raw, holds) => {
|
|
||||||
const result = [...raw];
|
|
||||||
const resultIds = new Set(result.map((p) => p.identity));
|
|
||||||
|
|
||||||
// Incorporate the held participants into the list
|
|
||||||
for (const hold of holds) {
|
|
||||||
for (const p of hold) {
|
|
||||||
if (!resultIds.has(p.identity)) {
|
|
||||||
result.push(p);
|
|
||||||
resultIds.add(p.identity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.pipe(pauseWhen(this.pretendToBeDisconnected$));
|
.pipe(pauseWhen(this.pretendToBeDisconnected$));
|
||||||
|
|
||||||
private readonly memberships$ = this.scope.behavior(
|
private readonly memberships$ = this.scope.behavior(
|
||||||
@@ -1685,24 +1800,42 @@ export class CallViewModel extends ViewModel {
|
|||||||
),
|
),
|
||||||
filter((v) => v.playSounds),
|
filter((v) => v.playSounds),
|
||||||
);
|
);
|
||||||
|
// TODO-REBASE: expose connection state observable
|
||||||
|
public readonly livekitConnectionState$: Observable<ECConnectionState>;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
// A call is permanently tied to a single Matrix room and LiveKit room
|
// A call is permanently tied to a single Matrix room
|
||||||
private readonly matrixRTCSession: MatrixRTCSession,
|
private readonly matrixRTCSession: MatrixRTCSession,
|
||||||
private readonly matrixRoom: MatrixRoom,
|
private readonly matrixRoom: MatrixRoom,
|
||||||
private readonly livekitRoom: LivekitRoom,
|
|
||||||
private readonly mediaDevices: MediaDevices,
|
private readonly mediaDevices: MediaDevices,
|
||||||
private readonly options: CallViewModelOptions,
|
private readonly options: CallViewModelOptions,
|
||||||
public readonly livekitConnectionState$: Behavior<ECConnectionState>,
|
|
||||||
private readonly handsRaisedSubject$: Observable<
|
private readonly handsRaisedSubject$: Observable<
|
||||||
Record<string, RaisedHandInfo>
|
Record<string, RaisedHandInfo>
|
||||||
>,
|
>,
|
||||||
private readonly reactionsSubject$: Observable<
|
private readonly reactionsSubject$: Observable<
|
||||||
Record<string, ReactionInfo>
|
Record<string, ReactionInfo>
|
||||||
>,
|
>,
|
||||||
|
private readonly encryptionSystem: EncryptionSystem,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
void this.localConnection.then((c) => c.startPublishing());
|
||||||
|
this.connectionInstructions$
|
||||||
|
.pipe(this.scope.bind())
|
||||||
|
.subscribe(({ start, stop }) => {
|
||||||
|
for (const connection of start) connection.startSubscribing();
|
||||||
|
for (const connection of stop) connection.stop();
|
||||||
|
});
|
||||||
|
combineLatest([this.localFocus, this.joined$])
|
||||||
|
.pipe(this.scope.bind())
|
||||||
|
.subscribe(([localFocus]) => {
|
||||||
|
enterRTCSession(
|
||||||
|
this.matrixRTCSession,
|
||||||
|
localFocus,
|
||||||
|
this.encryptionSystem.kind !== E2eeType.PER_PARTICIPANT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Pause upstream of all local media tracks when we're disconnected from
|
// 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
|
// MatrixRTC, because it can be an unpleasant surprise for the app to say
|
||||||
// 'reconnecting' and yet still be transmitting your media to others.
|
// 'reconnecting' and yet still be transmitting your media to others.
|
||||||
|
|||||||
@@ -10317,7 +10317,7 @@ __metadata:
|
|||||||
uuid: "npm:11"
|
uuid: "npm:11"
|
||||||
checksum: 10c0/ecd019c677c272c5598617dcde407dbe4b1b11460863b2a577e33f3fd8732c9d9073ec0221b471ec1eb24e2839eec20728db7f92c9348be83126547286e50805
|
checksum: 10c0/ecd019c677c272c5598617dcde407dbe4b1b11460863b2a577e33f3fd8732c9d9073ec0221b471ec1eb24e2839eec20728db7f92c9348be83126547286e50805
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: soft
|
||||||
|
|
||||||
"matrix-widget-api@npm:^1.10.0, matrix-widget-api@npm:^1.13.0":
|
"matrix-widget-api@npm:^1.10.0, matrix-widget-api@npm:^1.13.0":
|
||||||
version: 1.13.1
|
version: 1.13.1
|
||||||
|
|||||||
Reference in New Issue
Block a user