update EC based on sdk media refactor

No more call specifics in the sdk layer
This commit is contained in:
Timo K.
2026-10-14 10:23:29 +02:00
committed by Timo
parent 5f3db07b76
commit 52aaf84c3e
13 changed files with 361 additions and 152 deletions
+7 -7
View File
@@ -34,7 +34,6 @@ import {
of, of,
switchMap, switchMap,
} from "rxjs"; } from "rxjs";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { import {
type Behavior, type Behavior,
@@ -43,11 +42,13 @@ import {
E2eeType, E2eeType,
ObservableScope, ObservableScope,
type RTCMember, type RTCMember,
type RTCMembership,
} from "@element-hq/matrixrtc-sdk"; } from "@element-hq/matrixrtc-sdk";
import { import {
callViewModelOptionsFromParams, callViewModelOptionsFromParams,
createCallViewModel$, createCallViewModel$,
initialPublishRequests,
} from "../src/state/CallViewModel/CallViewModel"; } from "../src/state/CallViewModel/CallViewModel";
import { getUrlParams } from "../src/UrlParams"; import { getUrlParams } from "../src/UrlParams";
import { MuteStates } from "../src/state/MuteStates"; import { MuteStates } from "../src/state/MuteStates";
@@ -57,6 +58,7 @@ import { initializeWidget } from "../src/widget";
import { createWidgetHostBridge } from "../src/HostBridge"; import { createWidgetHostBridge } from "../src/HostBridge";
import { observeElementSize$ } from "../src/utils/elementSize"; import { observeElementSize$ } from "../src/utils/elementSize";
import { import {
captureSettings,
matrixRTCClientOptions, matrixRTCClientOptions,
selectedDeviceId$, selectedDeviceId$,
} from "../src/room/InCallView"; } from "../src/room/InCallView";
@@ -70,7 +72,7 @@ import {
interface MatrixRTCSdkMember { interface MatrixRTCSdkMember {
/** @deprecated Always null: the SDK owns the transport connection. */ /** @deprecated Always null: the SDK owns the transport connection. */
connection: null; connection: null;
membership: CallMembership; membership: RTCMembership;
/** @deprecated Always null: the SDK owns the transport participant. */ /** @deprecated Always null: the SDK owns the transport participant. */
participant: null; participant: null;
} }
@@ -140,19 +142,16 @@ export async function createMatrixRTCSdk(
// rtc client: the session, the transport and the media, as in the app // rtc client: the session, the transport and the media, as in the app
const encryptionSystem = { kind: E2eeType.PER_PARTICIPANT } as const; const encryptionSystem = { kind: E2eeType.PER_PARTICIPANT } as const;
const capture = captureSettings();
const rtcClient = createMatrixRTCClient( const rtcClient = createMatrixRTCClient(
scope, scope,
client, client,
room, room,
{ {
microphoneEnabled: muteStates.audio.enabled$.value, publish: initialPublishRequests(muteStates, mediaDevices, capture),
cameraEnabled: muteStates.video.enabled$.value,
audioInputDeviceId$: selectedDeviceId$(scope, mediaDevices.audioInput),
videoInputDeviceId$: selectedDeviceId$(scope, mediaDevices.videoInput),
audioOutputDeviceId$: controlledAudioDevices audioOutputDeviceId$: controlledAudioDevices
? constant(undefined) ? constant(undefined)
: selectedDeviceId$(scope, mediaDevices.audioOutput), : selectedDeviceId$(scope, mediaDevices.audioOutput),
videoProcessor$: constant(undefined),
}, },
{ {
...matrixRTCClientOptions(urlParams, encryptionSystem), ...matrixRTCClientOptions(urlParams, encryptionSystem),
@@ -171,6 +170,7 @@ export async function createMatrixRTCSdk(
{ {
...callViewModelOptionsFromParams(urlParams), ...callViewModelOptionsFromParams(urlParams),
encryptionSystem, encryptionSystem,
capture,
hostBridge, hostBridge,
// The SDK owns its page, so the body is the space it has // The SDK owns its page, so the body is the space it has
windowSize$: scope.behavior(observeElementSize$(document.body)), windowSize$: scope.behavior(observeElementSize$(document.body)),
+38 -24
View File
@@ -42,7 +42,9 @@ import { InviteButton } from "../button/InviteButton";
import { import {
type CallViewModel, type CallViewModel,
callViewModelOptionsFromParams, callViewModelOptionsFromParams,
type CaptureSettings,
createCallViewModel$, createCallViewModel$,
initialPublishRequests,
} from "../state/CallViewModel/CallViewModel.ts"; } from "../state/CallViewModel/CallViewModel.ts";
import { Grid, type TileProps } from "../grid/Grid"; import { Grid, type TileProps } from "../grid/Grid";
import { SpotlightTile } from "../tile/SpotlightTile"; import { SpotlightTile } from "../tile/SpotlightTile";
@@ -160,22 +162,21 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } = const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
urlParams; urlParams;
const capture = captureSettings();
const rtcClient = createMatrixRTCClient( const rtcClient = createMatrixRTCClient(
scope, scope,
props.client, props.client,
props.matrixRoom, props.matrixRoom,
{ {
microphoneEnabled: props.muteStates.audio.enabled$.value, publish: initialPublishRequests(
cameraEnabled: props.muteStates.video.enabled$.value, props.muteStates,
audioInputDeviceId$: selectedDeviceId$(scope, mediaDevices.audioInput), mediaDevices,
videoInputDeviceId$: selectedDeviceId$(scope, mediaDevices.videoInput), capture,
),
// A host that routes audio itself leaves the browser's output alone // A host that routes audio itself leaves the browser's output alone
audioOutputDeviceId$: urlParams.controlledAudioDevices audioOutputDeviceId$: urlParams.controlledAudioDevices
? constant(undefined) ? constant(undefined)
: selectedDeviceId$(scope, mediaDevices.audioOutput), : selectedDeviceId$(scope, mediaDevices.audioOutput),
videoProcessor$: scope.behavior(
trackProcessorState$.pipe(map((state) => state.processor)),
),
}, },
matrixRTCClientOptions(urlParams, props.e2eeSystem), matrixRTCClientOptions(urlParams, props.e2eeSystem),
); );
@@ -188,6 +189,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
{ {
...callViewModelOptionsFromParams(urlParams), ...callViewModelOptionsFromParams(urlParams),
encryptionSystem: props.e2eeSystem, encryptionSystem: props.e2eeSystem,
capture,
videoProcessor$: scope.behavior(
trackProcessorState$.pipe(map((state) => state.processor)),
),
hostBridge, hostBridge,
autoLeaveWhenOthersLeft, autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring", waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
@@ -748,16 +753,12 @@ export function selectedDeviceId$(
} }
/** /**
* What the client needs from Element Call's configuration, settings and URL: * How the tracks are captured and encoded, from Element Call's settings and
* the SDK reads none of them itself. Shared with the legacy SDK bundle so that * configuration. Shared with the legacy SDK bundle so that a widget built from
* a widget built from it behaves like the app. * it behaves like the app.
*/ */
export function matrixRTCClientOptions( export function captureSettings(): CaptureSettings {
urlParams: ReturnType<typeof useUrlParams>,
encryptionSystem: EncryptionSystem,
): MatrixRTCClientOptions {
const config = Config.get(); const config = Config.get();
const session = config.matrix_rtc_session;
const cameraSettings = advancedCamera.getValue() const cameraSettings = advancedCamera.getValue()
? { ? {
resolution: { resolution: {
@@ -789,6 +790,28 @@ export function matrixRTCClientOptions(
}, },
} }
: undefined; : undefined;
return {
audio: {
echoCancellation: echoCancellationSetting.getValue(),
noiseSuppression: noiseSuppressionSetting.getValue(),
autoGainControl: autoGainControlSetting.getValue(),
},
camera: cameraSettings,
screenShare: screenShareSettings,
};
}
/**
* What the client needs from Element Call's configuration, settings and URL:
* the SDK reads none of them itself. Shared with the legacy SDK bundle so that
* a widget built from it behaves like the app.
*/
export function matrixRTCClientOptions(
urlParams: ReturnType<typeof useUrlParams>,
encryptionSystem: EncryptionSystem,
): MatrixRTCClientOptions {
const config = Config.get();
const session = config.matrix_rtc_session;
return { return {
encryptionSystem, encryptionSystem,
// matrix_rtc_mode in config.json overrides the user's Developer Settings // matrix_rtc_mode in config.json overrides the user's Developer Settings
@@ -811,15 +834,6 @@ export function matrixRTCClientOptions(
delegatedDelayedLeave: session.delegated_delayed_leave, delegatedDelayedLeave: session.delegated_delayed_leave,
}, },
mediaQuality: config.media_quality, mediaQuality: config.media_quality,
capture: {
audio: {
echoCancellation: echoCancellationSetting.getValue(),
noiseSuppression: noiseSuppressionSetting.getValue(),
autoGainControl: autoGainControlSetting.getValue(),
},
camera: cameraSettings,
screenShare: screenShareSettings,
},
transportUrl: customLivekitUrl.value$.value ?? undefined, transportUrl: customLivekitUrl.value$.value ?? undefined,
fallbackTransportUrl: config.livekit?.livekit_service_url, fallbackTransportUrl: config.livekit?.livekit_service_url,
}; };
+189 -19
View File
@@ -7,19 +7,27 @@ Please see LICENSE in the repository root for full details.
*/ */
import { import {
type AudioCaptureSettings,
type Behavior, type Behavior,
constant, constant,
type DisconnectReason, type DisconnectReason,
type EncryptionSystem, type EncryptionSystem,
generateItems, generateItems,
type LocalAudioMediaTrack,
type LocalMediaTrack,
type LocalRTCMember, type LocalRTCMember,
type LocalVideoMediaTrack,
type MatrixRTCClient, type MatrixRTCClient,
type ObservableScope, type ObservableScope,
pauseWhen, pauseWhen,
type PublishRequest,
type RemoteRTCMember, type RemoteRTCMember,
type RTCMember, type RTCMember,
trackBySource$,
type TransportMetadata, type TransportMetadata,
type VideoCaptureSettings,
} from "@element-hq/matrixrtc-sdk"; } from "@element-hq/matrixrtc-sdk";
import { type Track, type TrackProcessor } from "livekit-client";
import { import {
KnownMembership, KnownMembership,
type Room as MatrixRoom, type Room as MatrixRoom,
@@ -73,8 +81,8 @@ import {
} from "../../reactions"; } from "../../reactions";
import { shallowEquals as shallowArrayEquals } from "../../utils/array"; import { shallowEquals as shallowArrayEquals } from "../../utils/array";
import { shallowEquals as shallowObjectEquals } from "../../utils/object"; import { shallowEquals as shallowObjectEquals } from "../../utils/object";
import { type MediaDevices } from "../MediaDevices"; import { type MediaDevice, type MediaDevices } from "../MediaDevices";
import { type MuteStates } from "../MuteStates"; import { type Handler, type MuteStates } from "../MuteStates";
import { HeaderStyle, type UrlParams } from "../../UrlParams"; import { HeaderStyle, type UrlParams } from "../../UrlParams";
import { type HostBridge, nullHostBridge } from "../../HostBridge"; import { type HostBridge, nullHostBridge } from "../../HostBridge";
import { import {
@@ -121,8 +129,59 @@ import {
import { type GridTileViewModel } from "../TileViewModel.ts"; import { type GridTileViewModel } from "../TileViewModel.ts";
import { type TransportLocator } from "../../livekit/auth"; import { type TransportLocator } from "../../livekit/auth";
/** How a call captures and encodes the tracks it publishes. */
export interface CaptureSettings {
audio?: AudioCaptureSettings;
camera?: VideoCaptureSettings;
screenShare?: VideoCaptureSettings;
}
/**
* What to publish for a microphone or camera: the device the user picked and
* the host's capture settings. The video processor is not in here; the call
* attaches it to the camera track once that exists.
*/
export function publishRequest(
source: "microphone" | "camera",
mediaDevices: MediaDevices,
capture: CaptureSettings,
): PublishRequest {
return source === "microphone"
? {
source,
deviceId: mediaDevices.audioInput.selected$.value?.id,
capture: capture.audio,
}
: {
source,
deviceId: mediaDevices.videoInput.selected$.value?.id,
capture: capture.camera,
};
}
/** What the client publishes at the join: the sources whose mute switch is on. */
export function initialPublishRequests(
muteStates: MuteStates,
mediaDevices: MediaDevices,
capture: CaptureSettings,
): PublishRequest[] {
const requests: PublishRequest[] = [];
if (muteStates.audio.enabled$.value)
requests.push(publishRequest("microphone", mediaDevices, capture));
if (muteStates.video.enabled$.value)
requests.push(publishRequest("camera", mediaDevices, capture));
return requests;
}
export interface CallViewModelOptions { export interface CallViewModelOptions {
encryptionSystem: EncryptionSystem; encryptionSystem: EncryptionSystem;
/**
* How our tracks are captured and encoded; the browser's and LiveKit's
* defaults otherwise.
*/
capture?: CaptureSettings;
/** Background blur and the like, applied to the camera as it changes. */
videoProcessor$?: Behavior<TrackProcessor<Track.Kind.Video> | undefined>;
/** /**
* The application hosting Element Call, which can ask it to hang up and wants * The application hosting Element Call, which can ask it to hang up and wants
* to know when the user joins or leaves. Defaults to no host. * to know when the user joins or leaves. Defaults to no host.
@@ -420,15 +479,107 @@ export function createCallViewModel$(
sentCallNotification$ = of(null), sentCallNotification$ = of(null),
} = options; } = options;
// The mute switches drive the client, which reports back what the device const { capture = {}, videoProcessor$ = constant(undefined) } = options;
// allowed, so a denied permission flips the switch back const localTracks$ = scope.behavior<
muteStates.audio.setHandler(rtcClient.setMicrophoneEnabled); (LocalAudioMediaTrack | LocalVideoMediaTrack)[]
muteStates.video.setHandler(rtcClient.setCameraEnabled); >(
rtcClient.localMember$.pipe(
switchMap((member) => member?.media$ ?? of(null)),
switchMap((media) => media?.tracks$ ?? of([])),
),
);
// Our own tracks, which carry the controls remote ones lack
const microphone$ = trackBySource$(
scope,
localTracks$,
"microphone",
) as Behavior<LocalAudioMediaTrack | undefined>;
const camera$ = trackBySource$(scope, localTracks$, "camera") as Behavior<
LocalVideoMediaTrack | undefined
>;
const screenShare$ = trackBySource$(
scope,
localTracks$,
"screenShare",
) as Behavior<LocalVideoMediaTrack | undefined>;
// The mute switches drive our tracks, which report back what the device
// allowed, so a denied permission flips the switch back. A source is muted
// and unmuted while its track exists and published when it does not.
const followMuteState = (
muteState: {
setHandler: (handler: Handler) => void;
enabled$: Behavior<boolean>;
},
track$: Behavior<LocalMediaTrack | undefined>,
source: "microphone" | "camera",
): void => {
// What the switch asks for, ahead of what it shows: the switch follows
// only once the handler has resolved
let desired = muteState.enabled$.value;
muteState.setHandler(async (enabled) => {
desired = enabled;
const track = track$.value;
if (track !== undefined) {
desired = await track.setEnabled(enabled);
return desired;
}
if (!enabled) return false;
const member = rtcClient.localMember$.value;
if (member === null) return false;
try {
await member.publish(publishRequest(source, mediaDevices, capture));
return true;
} catch (e) {
logger.error(`Failed to publish the ${source}`, e);
desired = false;
return false;
}
});
// A track that arrives after its switch was flipped off catches up with it
track$.pipe(scope.bind()).subscribe((track) => {
if (track !== undefined && !desired)
track.setEnabled(false).catch((e) => {
logger.error(`Failed to mute the ${source}`, e);
});
});
};
followMuteState(muteStates.audio, microphone$, "microphone");
followMuteState(muteStates.video, camera$, "camera");
scope.onEnd(() => { scope.onEnd(() => {
muteStates.audio.unsetHandler(); muteStates.audio.unsetHandler();
muteStates.video.unsetHandler(); muteStates.video.unsetHandler();
}); });
// The device the user picked follows onto the track as it is made and as the
// choice changes; the SDK does not watch the settings itself
const followDevice = (
track$: Behavior<LocalMediaTrack | undefined>,
device: MediaDevice<unknown, { id: string }>,
source: "microphone" | "camera",
): void => {
combineLatest([track$, device.selected$])
.pipe(scope.bind())
.subscribe(([track, selected]) => {
if (track === undefined || selected === undefined) return;
track.setDevice(selected.id).catch((e) => {
logger.error(`Failed to switch the ${source} device`, e);
});
});
};
followDevice(microphone$, mediaDevices.audioInput, "microphone");
followDevice(camera$, mediaDevices.videoInput, "camera");
// Attached once the camera track exists rather than carried in the publish
// request, so that a new track and a change of processor take the same path
combineLatest([camera$, videoProcessor$])
.pipe(scope.bind())
.subscribe(([camera, processor]) => {
camera?.setProcessor(processor).catch((e) => {
logger.error("Failed to set the video processor", e);
});
});
const members$ = scope.behavior<RTCMember[]>( const members$ = scope.behavior<RTCMember[]>(
combineLatest( combineLatest(
[rtcClient.localMember$, rtcClient.remoteMembers$], [rtcClient.localMember$, rtcClient.remoteMembers$],
@@ -1286,14 +1437,13 @@ export function createCallViewModel$(
); );
/** /**
* Whether we are sharing our screen. * Whether we are sharing our screen: one of our tracks is a screen share.
*/ */
const sharingScreen$ = scope.behavior( const sharingScreen$ = scope.behavior(
rtcClient.localMember$.pipe( screenShare$.pipe(map((track) => track !== undefined)),
switchMap((member) => member?.sharingScreen$ ?? of(false)),
),
); );
const screenShareError$ = new BehaviorSubject<Error | null>(null);
/** /**
* Callback to toggle screen sharing. If null, screen sharing is not possible: * Callback to toggle screen sharing. If null, screen sharing is not possible:
* the platform cannot capture a screen, or the host hides the button. * the platform cannot capture a screen, or the host hides the button.
@@ -1302,13 +1452,28 @@ export function createCallViewModel$(
options.toggleScreensharing ?? options.toggleScreensharing ??
(hideScreensharing || !("getDisplayMedia" in (navigator.mediaDevices ?? {})) (hideScreensharing || !("getDisplayMedia" in (navigator.mediaDevices ?? {}))
? null ? null
: (): void => rtcClient.localMember$.value?.toggleScreenSharing?.()); : (): void => {
const member = rtcClient.localMember$.value;
const screenShareError$ = scope.behavior( if (member === null) return;
rtcClient.localMember$.pipe( const track = screenShare$.value;
switchMap((member) => member?.screenShareError$ ?? of(null)), (track === undefined
), ? member.publish({
); source: "screenShare",
capture: capture.screenShare,
})
: member.unpublish(track.id)
).catch((e: unknown) => {
logger.error(
`Screen share ${track === undefined ? "start" : "stop"} failed`,
e,
);
// The user closing the picker is not an error worth showing
if (isPermissionDenied(e)) return;
screenShareError$.next(
e instanceof Error ? e : new Error(String(e)),
);
});
});
// Tell the host and the analytics about the user's joins and leaves // Tell the host and the analytics about the user's joins and leaves
const join = (): void => { const join = (): void => {
@@ -1404,11 +1569,16 @@ export function createCallViewModel$(
reconnecting$: rtcClient.reconnecting$, reconnecting$: rtcClient.reconnecting$,
connected$: rtcClient.connected$, connected$: rtcClient.connected$,
screenShareError$, screenShareError$,
dismissScreenShareError: (): void => dismissScreenShareError: (): void => screenShareError$.next(null),
rtcClient.localMember$.value?.dismissScreenShareError(),
}; };
} }
/** Whether an error, or the browser error the SDK wrapped, is the user saying no. */
function isPermissionDenied(e: unknown): boolean {
const cause = e instanceof Error && e.cause !== undefined ? e.cause : e;
return cause instanceof DOMException && cause.name === "NotAllowedError";
}
/** The members a call can be with: those in the room and those invited. */ /** The members a call can be with: those in the room and those invited. */
function roomMembers(room: MatrixRoom): RoomMember[] { function roomMembers(room: MatrixRoom): RoomMember[] {
return room return room
@@ -34,6 +34,7 @@ import {
mockMatrixRoom, mockMatrixRoom,
mockMatrixRoomMember, mockMatrixRoomMember,
mockMatrixRTCClient, mockMatrixRTCClient,
mockAudioTrack,
mockMediaDevices, mockMediaDevices,
mockVideoTrack, mockVideoTrack,
mockMemberMedia, mockMemberMedia,
@@ -154,26 +155,34 @@ export function withCallViewModel(mode: MatrixRTCMode) {
scope: ObservableScope, scope: ObservableScope,
participant: Participant, participant: Participant,
local: boolean, local: boolean,
): LocalMemberMedia => ): LocalMemberMedia => {
mockMemberMedia({ // A microphone and a camera are always published; the screen share
local, // comes and goes
speaking$: speaking.get(participant) ?? constant(false), const microphone = mockAudioTrack({
screenShareEnabled$: sharingScreen.get(participant) ?? constant(false), isActive$: speaking.get(participant) ?? constant(false),
camera$: scope.behavior( });
const camera = mockVideoTrack({
source: "camera",
muted$: scope.behavior(
(videoEnabled.get(participant) ?? constant(false)).pipe( (videoEnabled.get(participant) ?? constant(false)).pipe(
map((enabled) => map((enabled) => !enabled),
mockVideoTrack({ source: "camera", muted$: constant(!enabled) }),
),
), ),
), ),
screenShare$: scope.behavior( });
const screenShare = mockVideoTrack({ source: "screenShare" });
return mockMemberMedia({
local,
tracks$: scope.behavior(
(sharingScreen.get(participant) ?? constant(false)).pipe( (sharingScreen.get(participant) ?? constant(false)).pipe(
map((sharing) => map((sharing) =>
sharing ? mockVideoTrack({ source: "screenShare" }) : undefined, sharing
? [microphone, camera, screenShare]
: [microphone, camera],
), ),
), ),
), ),
}); });
};
const participantOf = ( const participantOf = (
scope: ObservableScope, scope: ObservableScope,
membership: CallMembership, membership: CallMembership,
+11 -5
View File
@@ -9,6 +9,7 @@ Please see LICENSE in the repository root for full details.
import { import {
type Behavior, type Behavior,
type LocalMemberMedia, type LocalMemberMedia,
type LocalVideoMediaTrack,
type ObservableScope, type ObservableScope,
} from "@element-hq/matrixrtc-sdk"; } from "@element-hq/matrixrtc-sdk";
import { map, of, switchMap } from "rxjs"; import { map, of, switchMap } from "rxjs";
@@ -22,6 +23,7 @@ import {
import { alwaysShowSelf } from "../../settings/settings"; import { alwaysShowSelf } from "../../settings/settings";
import { platform } from "../../Platform"; import { platform } from "../../Platform";
import { type MediaDevices } from "../MediaDevices"; import { type MediaDevices } from "../MediaDevices";
import { memberTrack$ } from "./MemberMediaViewModel";
export interface LocalUserMediaViewModel extends BaseUserMediaViewModel { export interface LocalUserMediaViewModel extends BaseUserMediaViewModel {
local: true; local: true;
@@ -51,10 +53,14 @@ export function createLocalUserMedia(
{ mediaDevices, ...inputs }: LocalUserMediaInputs, { mediaDevices, ...inputs }: LocalUserMediaInputs,
): LocalUserMediaViewModel { ): LocalUserMediaViewModel {
const baseUserMedia = createBaseUserMedia(scope, inputs); const baseUserMedia = createBaseUserMedia(scope, inputs);
// Our own camera track, which carries the controls a remote one lacks
const camera$ = memberTrack$<LocalVideoMediaTrack>(
scope,
inputs.media$,
"camera",
);
const facingMode$ = scope.behavior( const facingMode$ = scope.behavior(
baseUserMedia.video$.pipe( camera$.pipe(switchMap((camera) => camera?.facingMode$ ?? of(undefined))),
switchMap((video) => video?.facingMode$ ?? of(undefined)),
),
); );
return { return {
@@ -76,8 +82,8 @@ export function createLocalUserMedia(
facingMode === undefined facingMode === undefined
? null ? null
: (): void => : (): void =>
void inputs.media$.value void camera$.value
?.switchCamera() ?.switchFacingMode()
.then((deviceId) => { .then((deviceId) => {
// Inform the MediaDevices which camera was chosen // Inform the MediaDevices which camera was chosen
if (deviceId !== undefined) if (deviceId !== undefined)
+14 -13
View File
@@ -42,7 +42,7 @@ test("control a participant's volume", () => {
rtcMembership, rtcMembership,
{}, {},
mockMemberMedia({ mockMemberMedia({
microphone$: constant(mockAudioTrack({ setVolume })), tracks$: constant([mockAudioTrack({ setVolume })]),
}), }),
); );
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
@@ -93,9 +93,9 @@ test("control a participant's screen share volume", () => {
rtcMembership, rtcMembership,
{}, {},
mockMemberMedia({ mockMemberMedia({
screenShareAudio$: constant( tracks$: constant([
mockAudioTrack({ source: "screenShareAudio", setVolume }), mockAudioTrack({ source: "screenShareAudio", setVolume }),
), ]),
}), }),
); );
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
@@ -169,7 +169,7 @@ test("switch cameras", async () => {
); );
// The SDK restarts the camera the other way round and says which device it // The SDK restarts the camera the other way round and says which device it
// ended up on // ended up on
const switchCamera = vi.fn(async (): Promise<string> => { const switchFacingMode = vi.fn(async (): Promise<string> => {
const back = facingMode$.value === "user"; const back = facingMode$.value === "user";
facingMode$.next(back ? "environment" : "user"); facingMode$.next(back ? "environment" : "user");
return Promise.resolve(back ? "back camera" : "front camera"); return Promise.resolve(back ? "back camera" : "front camera");
@@ -180,8 +180,7 @@ test("switch cameras", async () => {
{}, {},
mockMemberMedia({ mockMemberMedia({
local: true, local: true,
camera$: constant(mockVideoTrack({ facingMode$ })), tracks$: constant([mockVideoTrack({ facingMode$, switchFacingMode })]),
switchCamera,
}), }),
mockMediaDevices({ mockMediaDevices({
videoInput: { videoInput: {
@@ -195,7 +194,7 @@ test("switch cameras", async () => {
// Switch to back camera // Switch to back camera
vm.switchCamera$.value!(); vm.switchCamera$.value!();
expect(switchCamera).toHaveBeenCalledTimes(1); expect(switchFacingMode).toHaveBeenCalledTimes(1);
await waitFor(() => { await waitFor(() => {
expect(selectVideoInput).toHaveBeenCalledWith("back camera"); expect(selectVideoInput).toHaveBeenCalledWith("back camera");
}); });
@@ -203,7 +202,7 @@ test("switch cameras", async () => {
// Switch to front camera // Switch to front camera
vm.switchCamera$.value!(); vm.switchCamera$.value!();
expect(switchCamera).toHaveBeenCalledTimes(2); expect(switchFacingMode).toHaveBeenCalledTimes(2);
await waitFor(() => { await waitFor(() => {
expect(selectVideoInput).toHaveBeenLastCalledWith("front camera"); expect(selectVideoInput).toHaveBeenLastCalledWith("front camera");
}); });
@@ -216,7 +215,7 @@ test("no camera switch where the facing mode is unknown", () => {
const vm = mockLocalMedia( const vm = mockLocalMedia(
rtcMembership, rtcMembership,
{}, {},
mockMemberMedia({ local: true, camera$: constant(mockVideoTrack()) }), mockMemberMedia({ local: true, tracks$: constant([mockVideoTrack()]) }),
mockMediaDevices({}), mockMediaDevices({}),
); );
expect(vm.switchCamera$.value).toBeNull(); expect(vm.switchCamera$.value).toBeNull();
@@ -242,8 +241,10 @@ test("audio and video follow the tracks' mute state", () => {
rtcMembership, rtcMembership,
{}, {},
mockMemberMedia({ mockMemberMedia({
microphone$: constant(mockAudioTrack({ muted$: constant(false) })), tracks$: constant([
camera$: constant(mockVideoTrack({ muted$: constant(true) })), mockAudioTrack({ muted$: constant(false) }),
mockVideoTrack({ muted$: constant(true) }),
]),
}), }),
); );
expect(vm.audioEnabled$.value).toBe(true); expect(vm.audioEnabled$.value).toBe(true);
@@ -259,13 +260,13 @@ test("user media polls stream stats only while the setting is on", () => {
rtcMembership, rtcMembership,
{}, {},
mockMemberMedia({ mockMemberMedia({
microphone$: constant( tracks$: constant([
mockAudioTrack({ mockAudioTrack({
stats$: cold("-s", { stats$: cold("-s", {
s: stats, s: stats,
}) as unknown as Behavior<MediaStreamStats>, }) as unknown as Behavior<MediaStreamStats>,
}), }),
), ]),
}), }),
); );
schedule("-a-b", { schedule("-a-b", {
+14 -15
View File
@@ -12,9 +12,11 @@ import {
E2eeType, E2eeType,
type EncryptionError, type EncryptionError,
type EncryptionSystem, type EncryptionSystem,
type MediaSource,
type MediaTrack, type MediaTrack,
type MemberMedia, type MemberMedia,
type ObservableScope, type ObservableScope,
trackBySource$,
type VideoMediaTrack, type VideoMediaTrack,
} from "@element-hq/matrixrtc-sdk"; } from "@element-hq/matrixrtc-sdk";
import { import {
@@ -71,18 +73,19 @@ export interface MemberMediaInputs extends BaseMediaViewModel {
encryptionSystem: EncryptionSystem; encryptionSystem: EncryptionSystem;
} }
/** The member's track for a source, undefined while there is none. */ /**
* The member's first track of a source: `trackBySource$` over media that may
* not have arrived yet. `T` narrows to a local track where the media is ours.
*/
export function memberTrack$<T extends MediaTrack>( export function memberTrack$<T extends MediaTrack>(
scope: ObservableScope,
media$: Behavior<MemberMedia | null>, media$: Behavior<MemberMedia | null>,
source: "microphone" | "camera" | "screenShare" | "screenShareAudio", source: MediaSource,
): Observable<T | undefined> { ): Behavior<T | undefined> {
return media$.pipe( const tracks$ = scope.behavior<MediaTrack[]>(
switchMap((media) => media$.pipe(switchMap((media) => media?.tracks$ ?? of([]))),
media === null
? of(undefined)
: (media[`${source}$`] as Behavior<T | undefined>),
),
); );
return trackBySource$(scope, tracks$, source) as Behavior<T | undefined>;
} }
export function createMemberMedia( export function createMemberMedia(
@@ -96,12 +99,8 @@ export function createMemberMedia(
...inputs ...inputs
}: MemberMediaInputs, }: MemberMediaInputs,
): BaseMemberMediaViewModel { ): BaseMemberMediaViewModel {
const audio$ = scope.behavior( const audio$ = memberTrack$<AudioMediaTrack>(scope, media$, audioSource);
memberTrack$<AudioMediaTrack>(media$, audioSource), const video$ = memberTrack$<VideoMediaTrack>(scope, media$, videoSource);
);
const video$ = scope.behavior(
memberTrack$<VideoMediaTrack>(media$, videoSource),
);
const unencrypted$ = ( const unencrypted$ = (
track$: Behavior<MediaTrack | undefined>, track$: Behavior<MediaTrack | undefined>,
): Observable<boolean> => ): Observable<boolean> =>
@@ -42,8 +42,10 @@ export function createRemoteScreenShare(
scope: ObservableScope, scope: ObservableScope,
{ pretendToBeDisconnected$, ...inputs }: RemoteScreenShareInputs, { pretendToBeDisconnected$, ...inputs }: RemoteScreenShareInputs,
): RemoteScreenShareViewModel { ): RemoteScreenShareViewModel {
const audio$ = scope.behavior( const audio$ = memberTrack$<AudioMediaTrack>(
memberTrack$<AudioMediaTrack>(inputs.media$, "screenShareAudio"), scope,
inputs.media$,
"screenShareAudio",
); );
return { return {
...createBaseScreenShare(scope, inputs), ...createBaseScreenShare(scope, inputs),
+1 -1
View File
@@ -60,7 +60,7 @@ export function createRemoteUserMedia(
...createVolumeControls(scope, { ...createVolumeControls(scope, {
pretendToBeDisconnected$, pretendToBeDisconnected$,
sink$: scope.behavior( sink$: scope.behavior(
memberTrack$<AudioMediaTrack>(inputs.media$, "microphone").pipe( memberTrack$<AudioMediaTrack>(scope, inputs.media$, "microphone").pipe(
map((track) => (volume: number) => track?.setVolume(volume)), map((track) => (volume: number) => track?.setVolume(volume)),
), ),
), ),
+6 -4
View File
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { import {
type AudioMediaTrack,
type Behavior, type Behavior,
type MediaStreamStats, type MediaStreamStats,
type MediaTrack, type MediaTrack,
@@ -86,7 +87,7 @@ export function createBaseUserMedia(
source: "microphone" | "camera", source: "microphone" | "camera",
): Behavior<boolean> => ): Behavior<boolean> =>
scope.behavior( scope.behavior(
memberTrack$<MediaTrack>(media$, source).pipe( memberTrack$<MediaTrack>(scope, media$, source).pipe(
switchMap((track) => switchMap((track) =>
track === undefined track === undefined
? of(false) ? of(false)
@@ -101,7 +102,7 @@ export function createBaseUserMedia(
): Behavior<MediaStreamStats> => ): Behavior<MediaStreamStats> =>
scope.behavior( scope.behavior(
combineLatest([ combineLatest([
memberTrack$<MediaTrack>(media$, source), memberTrack$<MediaTrack>(scope, media$, source),
showConnectionStats.value$, showConnectionStats.value$,
]).pipe( ]).pipe(
switchMap(([track, show]) => switchMap(([track, show]) =>
@@ -117,9 +118,10 @@ export function createBaseUserMedia(
videoSource: "camera", videoSource: "camera",
}), }),
type: "user", type: "user",
// What a call calls speaking is the microphone carrying sound
speaking$: scope.behavior( speaking$: scope.behavior(
media$.pipe( memberTrack$<AudioMediaTrack>(scope, media$, "microphone").pipe(
switchMap((media) => (media === null ? of(false) : media.speaking$)), switchMap((track) => track?.isActive$ ?? of(false)),
), ),
), ),
audioEnabled$: enabled$(scope, "microphone"), audioEnabled$: enabled$(scope, "microphone"),
+5 -5
View File
@@ -12,7 +12,7 @@ import {
type MemberMedia, type MemberMedia,
type ObservableScope, type ObservableScope,
} from "@element-hq/matrixrtc-sdk"; } from "@element-hq/matrixrtc-sdk";
import { combineLatest, map, of, switchMap } from "rxjs"; import { combineLatest, distinctUntilChanged, map, of } from "rxjs";
import { observeSpeaker$ } from "./observeSpeaker.ts"; import { observeSpeaker$ } from "./observeSpeaker.ts";
import { type UserMediaViewModel } from "./UserMediaViewModel.ts"; import { type UserMediaViewModel } from "./UserMediaViewModel.ts";
@@ -27,6 +27,7 @@ import {
} from "./RemoteUserMediaViewModel.ts"; } from "./RemoteUserMediaViewModel.ts";
import { createLocalScreenShare } from "./LocalScreenShareViewModel.ts"; import { createLocalScreenShare } from "./LocalScreenShareViewModel.ts";
import { createRemoteScreenShare } from "./RemoteScreenShareViewModel.ts"; import { createRemoteScreenShare } from "./RemoteScreenShareViewModel.ts";
import { memberTrack$ } from "./MemberMediaViewModel.ts";
/** /**
* Sorting bins defining the order in which media tiles appear in the layout. * Sorting bins defining the order in which media tiles appear in the layout.
@@ -107,10 +108,9 @@ export function createWrappedUserMedia(
const media$: Behavior<MemberMedia | null> = _media$; const media$: Behavior<MemberMedia | null> = _media$;
const screenShares$ = scope.behavior( const screenShares$ = scope.behavior(
media$.pipe( memberTrack$(scope, media$, "screenShare").pipe(
switchMap((media) => map((track) => track !== undefined),
media === null ? of(false) : media.screenShareEnabled$, distinctUntilChanged(),
),
// Technically more than one screen share might be possible... our // Technically more than one screen share might be possible... our
// MediaViewModels don't support it though since they look for a unique // MediaViewModels don't support it though since they look for a unique
// track for the given source. So generateItems here is a bit overkill. // track for the given source. So generateItems here is a bit overkill.
+25 -20
View File
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { import {
type AudioMediaTrack, type AudioMediaTrack,
type MediaSource,
type MemberMedia, type MemberMedia,
type RemoteRTCMember, type RemoteRTCMember,
} from "@element-hq/matrixrtc-sdk"; } from "@element-hq/matrixrtc-sdk";
@@ -123,33 +124,37 @@ const MemberAudio: FC<{
const media = useBehavior(media$); const media = useBehavior(media$);
return ( return (
media !== null && ( media !== null && (
<> <MemberTracksAudio
<AudioTrack tracks$={media.tracks$}
track$={media.microphone$} muted={muted}
muted={muted} plugins={plugins}
plugins={plugins} />
/>
<AudioTrack
track$={media.screenShareAudio$}
muted={muted}
plugins={plugins}
/>
</>
) )
); );
}; };
const AudioTrack: FC<{ /** The sources a call plays; anything published without one stays silent. */
track$: MemberMedia["microphone$"]; const playedSources = new Set<MediaSource>(["microphone", "screenShareAudio"]);
const MemberTracksAudio: FC<{
tracks$: MemberMedia["tracks$"];
muted?: boolean; muted?: boolean;
plugins: AudioPlugins; plugins: AudioPlugins;
}> = ({ track$, muted, plugins }) => { }> = ({ tracks$, muted, plugins }) => {
const track = useBehavior(track$); const tracks = useBehavior(tracks$);
return ( return tracks
track !== undefined && ( .filter(
<TrackAudio track={track} muted={muted} plugins={plugins} /> (track): track is AudioMediaTrack =>
track.kind === "audio" && playedSources.has(track.source),
) )
); .map((track) => (
<TrackAudio
key={track.id}
track={track}
muted={muted}
plugins={plugins}
/>
));
}; };
/** /**
+27 -26
View File
@@ -59,18 +59,19 @@ import {
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { type MediaDevices } from "../state/MediaDevices"; import { type MediaDevices } from "../state/MediaDevices";
import { import {
type AudioMediaTrack,
type Behavior, type Behavior,
constant, constant,
type DisconnectReason, type DisconnectReason,
type LocalAudioMediaTrack,
type LocalMemberMedia, type LocalMemberMedia,
type LocalRTCMember, type LocalRTCMember,
type LocalVideoMediaTrack,
type MatrixRTCClient, type MatrixRTCClient,
type MatrixRTCError, type MatrixRTCError,
type MemberMedia, type MemberMedia,
type PublishRequest,
type RemoteRTCMember, type RemoteRTCMember,
type TransportMetadata, type TransportMetadata,
type VideoMediaTrack,
} from "@element-hq/matrixrtc-sdk"; } from "@element-hq/matrixrtc-sdk";
import { ObservableScope } from "../state/ObservableScope"; import { ObservableScope } from "../state/ObservableScope";
import { MuteStates } from "../state/MuteStates"; import { MuteStates } from "../state/MuteStates";
@@ -291,9 +292,12 @@ export function mockMatrixRoom(room: Partial<MatrixRoom>): MatrixRoom {
return { ...mockEmitter(), ...room } as Partial<MatrixRoom> as MatrixRoom; return { ...mockEmitter(), ...room } as Partial<MatrixRoom> as MatrixRoom;
} }
/** A track as the SDK hands it out, with spies where a test may look. */ /**
export type MockMediaTrack = Omit<AudioMediaTrack, "kind"> & * A track as the SDK hands it out, with spies where a test may look. It
Omit<VideoMediaTrack, "kind"> & { kind: "audio" | "video" }; * carries the local controls too, so that one mock serves either side.
*/
export type MockMediaTrack = Omit<LocalAudioMediaTrack, "kind"> &
Omit<LocalVideoMediaTrack, "kind"> & { kind: "audio" | "video" };
export function mockMediaTrack( export function mockMediaTrack(
track: Partial<MockMediaTrack> = {}, track: Partial<MockMediaTrack> = {},
@@ -305,28 +309,33 @@ export function mockMediaTrack(
muted$: constant(false), muted$: constant(false),
encrypted$: constant(true), encrypted$: constant(true),
stats$: constant(undefined), stats$: constant(undefined),
isActive$: constant(false),
attach: vi.fn(), attach: vi.fn(),
detach: vi.fn(), detach: vi.fn(),
setAudioContext: vi.fn(), setAudioContext: vi.fn(),
setVolume: vi.fn(), setVolume: vi.fn(),
setEnabled: vi.fn(async (enabled: boolean) => Promise.resolve(enabled)),
setDevice: vi.fn(async () => Promise.resolve()),
facingMode$: constant(undefined), facingMode$: constant(undefined),
switchFacingMode: vi.fn(async () => Promise.resolve(undefined)),
setProcessor: vi.fn(async () => Promise.resolve()),
...track, ...track,
}; };
} }
export const mockVideoTrack = ( export const mockVideoTrack = (
track: Partial<MockMediaTrack> = {}, track: Partial<MockMediaTrack> = {},
): VideoMediaTrack => ): LocalVideoMediaTrack =>
mockMediaTrack({ kind: "video", ...track }) as VideoMediaTrack; mockMediaTrack({ kind: "video", ...track }) as LocalVideoMediaTrack;
export const mockAudioTrack = ( export const mockAudioTrack = (
track: Partial<MockMediaTrack> = {}, track: Partial<MockMediaTrack> = {},
): AudioMediaTrack => ): LocalAudioMediaTrack =>
mockMediaTrack({ mockMediaTrack({
kind: "audio", kind: "audio",
source: "microphone", source: "microphone",
...track, ...track,
}) as AudioMediaTrack; }) as LocalAudioMediaTrack;
/** A member's media as the SDK hands it out, with nothing published unless given. */ /** A member's media as the SDK hands it out, with nothing published unless given. */
export function mockMemberMedia( export function mockMemberMedia(
@@ -334,14 +343,8 @@ export function mockMemberMedia(
): LocalMemberMedia { ): LocalMemberMedia {
return { return {
local: false, local: false,
speaking$: constant(false), tracks$: constant([]),
screenShareEnabled$: constant(false),
microphone$: constant(undefined),
camera$: constant(undefined),
screenShare$: constant(undefined),
screenShareAudio$: constant(undefined),
encryptionError$: NEVER, encryptionError$: NEVER,
switchCamera: vi.fn().mockResolvedValue(undefined),
...media, ...media,
} as LocalMemberMedia; } as LocalMemberMedia;
} }
@@ -463,10 +466,14 @@ export function mockRTCMember(
return { return {
...base, ...base,
local: true, local: true,
sharingScreen$: constant(false), publish: vi.fn(async (request: PublishRequest) =>
toggleScreenSharing: null, Promise.resolve(
screenShareError$: constant(null), request.source === "microphone"
dismissScreenShareError: vi.fn(), ? mockAudioTrack()
: mockVideoTrack({ source: request.source }),
),
),
unpublish: vi.fn(async () => Promise.resolve()),
}; };
} }
@@ -504,12 +511,6 @@ export function mockMatrixRTCClient(
connected$, connected$,
reconnecting$, reconnecting$,
disconnectReason$, disconnectReason$,
setMicrophoneEnabled: vi.fn(async (enabled: boolean) =>
Promise.resolve(enabled),
),
setCameraEnabled: vi.fn(async (enabled: boolean) =>
Promise.resolve(enabled),
),
fatalError$, fatalError$,
localMember$, localMember$,
remoteMembers$, remoteMembers$,