mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
update EC based on sdk media refactor
No more call specifics in the sdk layer
This commit is contained in:
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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$,
|
||||||
|
|||||||
Reference in New Issue
Block a user