Update to changes in the sdk.

- plain tracks instead of media$
This commit is contained in:
Timo K.
2026-10-14 10:23:29 +02:00
committed by Timo
parent 52aaf84c3e
commit 7c5d1324e7
19 changed files with 358 additions and 259 deletions
+6 -18
View File
@@ -37,7 +37,6 @@ import {
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,
constant,
createMatrixRTCClient, createMatrixRTCClient,
E2eeType, E2eeType,
ObservableScope, ObservableScope,
@@ -60,7 +59,6 @@ import { observeElementSize$ } from "../src/utils/elementSize";
import { import {
captureSettings, captureSettings,
matrixRTCClientOptions, matrixRTCClientOptions,
selectedDeviceId$,
} from "../src/room/InCallView"; } from "../src/room/InCallView";
/** /**
@@ -143,22 +141,12 @@ 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 capture = captureSettings();
const rtcClient = createMatrixRTCClient( const rtcClient = createMatrixRTCClient(scope, client, room, {
scope, ...matrixRTCClientOptions(urlParams, encryptionSystem),
client, publish: initialPublishRequests(muteStates, mediaDevices, capture),
room, application,
{ slot: id,
publish: initialPublishRequests(muteStates, mediaDevices, capture), });
audioOutputDeviceId$: controlledAudioDevices
? constant(undefined)
: selectedDeviceId$(scope, mediaDevices.audioOutput),
},
{
...matrixRTCClientOptions(urlParams, encryptionSystem),
application,
slot: id,
},
);
// call view model: the host bridge's hang-up handling and the leave flow // call view model: the host bridge's hang-up handling and the leave flow
const callViewModel = createCallViewModel$( const callViewModel = createCallViewModel$(
+2 -3
View File
@@ -27,7 +27,6 @@ import {
mockRtcMembership, mockRtcMembership,
type MockRTCSession, type MockRTCSession,
mockMatrixRTCClient, mockMatrixRTCClient,
mockMemberMedia,
mockRTCMember, mockRTCMember,
} from "../utils/test"; } from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
@@ -103,13 +102,13 @@ beforeEach(() => {
localMember$: constant( localMember$: constant(
mockRTCMember(true, { mockRTCMember(true, {
membership: localRtcMember, membership: localRtcMember,
media$: constant(mockMemberMedia({ local: true })), tracks$: constant([]),
}), }),
), ),
remoteMembers$: constant([ remoteMembers$: constant([
mockRTCMember(false, { mockRTCMember(false, {
membership: aliceRtcMember, membership: aliceRtcMember,
media$: constant(mockMemberMedia()), tracks$: constant([]),
}), }),
]), ]),
}), }),
+5 -18
View File
@@ -72,7 +72,6 @@ import {
createMatrixRTCClient, createMatrixRTCClient,
type MatrixRTCClientOptions, type MatrixRTCClientOptions,
constant, constant,
type Behavior,
} from "@element-hq/matrixrtc-sdk"; } from "@element-hq/matrixrtc-sdk";
import { map } from "rxjs"; import { map } from "rxjs";
import { import {
@@ -94,7 +93,6 @@ import {
screenShareResolution, screenShareResolution,
} from "../settings/settings"; } from "../settings/settings";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { type MediaDevice } from "../state/MediaDevices";
import { createSentCallNotification$ } from "../state/CallViewModel/CallNotificationLifecycle.ts"; import { createSentCallNotification$ } from "../state/CallViewModel/CallNotificationLifecycle.ts";
import { ReactionsReader } from "../reactions/ReactionsReader"; import { ReactionsReader } from "../reactions/ReactionsReader";
import { MemberAudioRenderer } from "../tracks/MemberAudioRenderer.tsx"; import { MemberAudioRenderer } from "../tracks/MemberAudioRenderer.tsx";
@@ -168,17 +166,13 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.client, props.client,
props.matrixRoom, props.matrixRoom,
{ {
...matrixRTCClientOptions(urlParams, props.e2eeSystem),
publish: initialPublishRequests( publish: initialPublishRequests(
props.muteStates, props.muteStates,
mediaDevices, mediaDevices,
capture, capture,
), ),
// A host that routes audio itself leaves the browser's output alone
audioOutputDeviceId$: urlParams.controlledAudioDevices
? constant(undefined)
: selectedDeviceId$(scope, mediaDevices.audioOutput),
}, },
matrixRTCClientOptions(urlParams, props.e2eeSystem),
); );
const vm = createCallViewModel$( const vm = createCallViewModel$(
scope, scope,
@@ -744,14 +738,6 @@ export const InCallView: FC<InCallViewProps> = ({
); );
}; };
/** The id of the device the user picked, for the client to capture or play on. */
export function selectedDeviceId$(
scope: ObservableScope,
device: MediaDevice<unknown, { id: string }>,
): Behavior<string | undefined> {
return scope.behavior(device.selected$.pipe(map((d) => d?.id)));
}
/** /**
* How the tracks are captured and encoded, from Element Call's settings and * How the tracks are captured and encoded, from Element Call's settings and
* configuration. Shared with the legacy SDK bundle so that a widget built from * configuration. Shared with the legacy SDK bundle so that a widget built from
@@ -803,13 +789,14 @@ export function captureSettings(): CaptureSettings {
/** /**
* What the client needs from Element Call's configuration, settings and URL: * 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 * the SDK reads none of them itself. What to publish depends on the mute
* a widget built from it behaves like the app. * switches and the devices, so the caller adds that. Shared with the legacy
* SDK bundle so that a widget built from it behaves like the app.
*/ */
export function matrixRTCClientOptions( export function matrixRTCClientOptions(
urlParams: ReturnType<typeof useUrlParams>, urlParams: ReturnType<typeof useUrlParams>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
): MatrixRTCClientOptions { ): Omit<MatrixRTCClientOptions, "publish"> {
const config = Config.get(); const config = Config.get();
const session = config.matrix_rtc_session; const session = config.matrix_rtc_session;
return { return {
+51 -1
View File
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { test, vi, onTestFinished, it, describe, expect } from "vitest"; import { test, vi, onTestFinished, it, describe, expect } from "vitest";
import { import {
BehaviorSubject,
combineLatest, combineLatest,
debounceTime, debounceTime,
distinctUntilChanged, distinctUntilChanged,
@@ -26,6 +27,7 @@ import { deepCompare } from "matrix-js-sdk/lib/utils";
import { type Layout } from "../layout-types.ts"; import { type Layout } from "../layout-types.ts";
import { import {
mockMatrixRoomMember, mockMatrixRoomMember,
mockMediaDevices,
mockRemoteParticipant, mockRemoteParticipant,
withTestScheduler, withTestScheduler,
mockRtcMembership, mockRtcMembership,
@@ -46,7 +48,10 @@ import {
localRtcMember, localRtcMember,
localRtcMemberDevice2, localRtcMemberDevice2,
} from "../../utils/test-fixtures.ts"; } from "../../utils/test-fixtures.ts";
import { MediaDevices } from "../MediaDevices.ts"; import {
MediaDevices,
type SelectedAudioOutputDevice,
} from "../MediaDevices.ts";
import { getValue } from "../../utils/observable.ts"; import { getValue } from "../../utils/observable.ts";
import { type Behavior, constant } from "../Behavior.ts"; import { type Behavior, constant } from "../Behavior.ts";
import { import {
@@ -236,6 +241,51 @@ const modes = [[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]];
describe.each(modes)("CallViewModel (%s mode)", (mode) => { describe.each(modes)("CallViewModel (%s mode)", (mode) => {
const withCallViewModel = withCallViewModelInMode(mode); const withCallViewModel = withCallViewModelInMode(mode);
/** Media devices whose audio output the test selects. */
const withAudioOutput = (): {
mediaDevices: MediaDevices;
selected$: BehaviorSubject<SelectedAudioOutputDevice | undefined>;
} => {
const selected$ = new BehaviorSubject<
SelectedAudioOutputDevice | undefined
>(undefined);
const mediaDevices = mockMediaDevices({
audioOutput: {
available$: constant(new Map()),
selected$,
select: vi.fn(),
},
});
return { mediaDevices, selected$ };
};
test("the selected audio output is handed to the client as it changes", () => {
const { mediaDevices, selected$ } = withAudioOutput();
withCallViewModel({ mediaDevices }, (_vm, _session, { rtcClient }) => {
// Nothing selected yet, so the browser's choice stands
expect(rtcClient.setAudioOutputDeviceId).not.toHaveBeenCalled();
selected$.next({ id: "speaker", virtualEarpiece: false });
expect(rtcClient.setAudioOutputDeviceId).toHaveBeenCalledWith("speaker");
selected$.next({ id: "headphones", virtualEarpiece: false });
expect(rtcClient.setAudioOutputDeviceId).toHaveBeenLastCalledWith(
"headphones",
);
expect(rtcClient.setAudioOutputDeviceId).toHaveBeenCalledTimes(2);
});
});
test("a host that controls the audio devices keeps the output from the client", () => {
const { mediaDevices, selected$ } = withAudioOutput();
withCallViewModel(
{ mediaDevices },
(_vm, _session, { rtcClient }) => {
selected$.next({ id: "earpiece", virtualEarpiece: true });
expect(rtcClient.setAudioOutputDeviceId).not.toHaveBeenCalled();
},
{ controlledAudioDevices: true },
);
});
test("participants are retained during a focus switch", () => { test("participants are retained during a focus switch", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
// Participants disappear on frame 2 and come back on frame 3 // Participants disappear on frame 2 and come back on frame 3
+28 -8
View File
@@ -22,7 +22,6 @@ import {
pauseWhen, pauseWhen,
type PublishRequest, type PublishRequest,
type RemoteRTCMember, type RemoteRTCMember,
type RTCMember,
trackBySource$, trackBySource$,
type TransportMetadata, type TransportMetadata,
type VideoCaptureSettings, type VideoCaptureSettings,
@@ -193,6 +192,12 @@ export interface CallViewModelOptions {
showControls?: boolean; showControls?: boolean;
/** Whether to hide the screen-sharing button. Defaults to false. */ /** Whether to hide the screen-sharing button. Defaults to false. */
hideScreensharing?: boolean; hideScreensharing?: boolean;
/**
* Whether the host routes the audio itself, outside the browser, so that
* the selected output device is never handed to the client. Defaults to
* false.
*/
controlledAudioDevices?: boolean;
autoLeaveWhenOthersLeft?: boolean; autoLeaveWhenOthersLeft?: boolean;
/** /**
* If the call is started in a way where we want it to behave like a telephone usecase * If the call is started in a way where we want it to behave like a telephone usecase
@@ -232,11 +237,15 @@ export interface CallViewModelOptions {
*/ */
export function callViewModelOptionsFromParams( export function callViewModelOptionsFromParams(
params: UrlParams, params: UrlParams,
): Pick<CallViewModelOptions, "header" | "showControls" | "hideScreensharing"> { ): Pick<
CallViewModelOptions,
"header" | "showControls" | "hideScreensharing" | "controlledAudioDevices"
> {
return { return {
header: params.header, header: params.header,
showControls: params.showControls, showControls: params.showControls,
hideScreensharing: params.hideScreensharing, hideScreensharing: params.hideScreensharing,
controlledAudioDevices: params.controlledAudioDevices,
}; };
} }
@@ -484,8 +493,8 @@ export function createCallViewModel$(
(LocalAudioMediaTrack | LocalVideoMediaTrack)[] (LocalAudioMediaTrack | LocalVideoMediaTrack)[]
>( >(
rtcClient.localMember$.pipe( rtcClient.localMember$.pipe(
switchMap((member) => member?.media$ ?? of(null)), switchMap((member) => member?.tracks$ ?? of(null)),
switchMap((media) => media?.tracks$ ?? of([])), map((tracks) => tracks ?? []),
), ),
); );
// Our own tracks, which carry the controls remote ones lack // Our own tracks, which carry the controls remote ones lack
@@ -570,6 +579,19 @@ export function createCallViewModel$(
followDevice(microphone$, mediaDevices.audioInput, "microphone"); followDevice(microphone$, mediaDevices.audioInput, "microphone");
followDevice(camera$, mediaDevices.videoInput, "camera"); followDevice(camera$, mediaDevices.videoInput, "camera");
// The output device follows the selection onto the client the same way. A
// host that routes audio itself picks the output outside the browser, so the
// client is left alone
if (!options.controlledAudioDevices)
mediaDevices.audioOutput.selected$
.pipe(scope.bind())
.subscribe((selected) => {
if (selected === undefined) return;
rtcClient.setAudioOutputDeviceId(selected.id).catch((e) => {
logger.error("Failed to switch the audio output device", e);
});
});
// Attached once the camera track exists rather than carried in the publish // 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 // request, so that a new track and a change of processor take the same path
combineLatest([camera$, videoProcessor$]) combineLatest([camera$, videoProcessor$])
@@ -580,7 +602,7 @@ export function createCallViewModel$(
}); });
}); });
const members$ = scope.behavior<RTCMember[]>( const members$ = scope.behavior<(LocalRTCMember | RemoteRTCMember)[]>(
combineLatest( combineLatest(
[rtcClient.localMember$, rtcClient.remoteMembers$], [rtcClient.localMember$, rtcClient.remoteMembers$],
(local, remote) => (local === null ? remote : [local, ...remote]), (local, remote) => (local === null ? remote : [local, ...remote]),
@@ -651,9 +673,7 @@ export function createCallViewModel$(
id: `${mediaId}:${dup}`, id: `${mediaId}:${dup}`,
userId: member.userId, userId: member.userId,
rtcBackendIdentity: member.id, rtcBackendIdentity: member.id,
...(member.local member,
? { local: true, media$: (member as LocalRTCMember).media$ }
: { local: false, media$: member.media$ }),
encryptionSystem: options.encryptionSystem, encryptionSystem: options.encryptionSystem,
focusUrl$: scope.behavior( focusUrl$: scope.behavior(
member.transport$.pipe(map((transport) => transport?.id)), member.transport$.pipe(map((transport) => transport?.id)),
@@ -10,8 +10,8 @@ import {
type Behavior, type Behavior,
constant, constant,
E2eeType, E2eeType,
type LocalMemberMedia,
type LocalRTCMember, type LocalRTCMember,
type MatrixRTCClient,
type MatrixRTCMode, type MatrixRTCMode,
type ObservableScope, type ObservableScope,
type RemoteRTCMember, type RemoteRTCMember,
@@ -37,7 +37,7 @@ import {
mockAudioTrack, mockAudioTrack,
mockMediaDevices, mockMediaDevices,
mockVideoTrack, mockVideoTrack,
mockMemberMedia, type MockTracks,
mockMuteStates, mockMuteStates,
MockRTCSession, MockRTCSession,
mockRTCMember, mockRTCMember,
@@ -112,6 +112,8 @@ export function withCallViewModel(mode: MatrixRTCMode) {
rtcSession: MockRTCSession, rtcSession: MockRTCSession,
subjects: { subjects: {
raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>>; raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>>;
/** The client the view model was built on, with spies for its calls. */
rtcClient: MatrixRTCClient;
}, },
setSyncState: (value: SyncState) => void, setSyncState: (value: SyncState) => void,
) => void, ) => void,
@@ -151,11 +153,10 @@ export function withCallViewModel(mode: MatrixRTCMode) {
); );
const scope = testScope(); const scope = testScope();
const mediaOf = ( const tracksOf = (
scope: ObservableScope, scope: ObservableScope,
participant: Participant, participant: Participant,
local: boolean, ): Behavior<MockTracks> => {
): LocalMemberMedia => {
// A microphone and a camera are always published; the screen share // A microphone and a camera are always published; the screen share
// comes and goes // comes and goes
const microphone = mockAudioTrack({ const microphone = mockAudioTrack({
@@ -170,18 +171,13 @@ export function withCallViewModel(mode: MatrixRTCMode) {
), ),
}); });
const screenShare = mockVideoTrack({ source: "screenShare" }); const screenShare = mockVideoTrack({ source: "screenShare" });
return mockMemberMedia({ return scope.behavior(
local, (sharingScreen.get(participant) ?? constant(false)).pipe(
tracks$: scope.behavior( map((sharing) =>
(sharingScreen.get(participant) ?? constant(false)).pipe( sharing ? [microphone, camera, screenShare] : [microphone, camera],
map((sharing) =>
sharing
? [microphone, camera, screenShare]
: [microphone, camera],
),
), ),
), ),
}); );
}; };
const participantOf = ( const participantOf = (
scope: ObservableScope, scope: ObservableScope,
@@ -204,12 +200,12 @@ export function withCallViewModel(mode: MatrixRTCMode) {
membership, membership,
roomMember: roomMembers.find((m) => m.userId === membership.userId), roomMember: roomMembers.find((m) => m.userId === membership.userId),
transportUrl: "http://my-default-service-url.com", transportUrl: "http://my-default-service-url.com",
media$: scope.behavior( tracks$: scope.behavior(
participantOf(scope, membership).pipe( participantOf(scope, membership).pipe(
map((participant) => switchMap((participant) =>
participant === undefined participant === undefined
? null ? of(null)
: mediaOf(scope, participant, false), : tracksOf(scope, participant),
), ),
), ),
), ),
@@ -239,7 +235,7 @@ export function withCallViewModel(mode: MatrixRTCMode) {
membership: localRtcMember, membership: localRtcMember,
roomMember: carol, roomMember: carol,
transportUrl: "http://my-default-service-url.com", transportUrl: "http://my-default-service-url.com",
media$: constant(mediaOf(scope, localParticipant, true)), tracks$: tracksOf(scope, localParticipant),
}) })
: null, : null,
), ),
@@ -283,6 +279,6 @@ export function withCallViewModel(mode: MatrixRTCMode) {
); );
void mode; void mode;
continuation(vm, rtcSession, { raisedHands$: raisedHands$ }, setSyncState); continuation(vm, rtcSession, { raisedHands$, rtcClient }, setSyncState);
}; };
} }
+7 -7
View File
@@ -8,9 +8,10 @@ Please see LICENSE in the repository root for full details.
import { import {
type Behavior, type Behavior,
type LocalMemberMedia, type LocalRTCMember,
type LocalVideoMediaTrack, type LocalVideoMediaTrack,
type ObservableScope, type ObservableScope,
trackBySource$,
} from "@element-hq/matrixrtc-sdk"; } from "@element-hq/matrixrtc-sdk";
import { map, of, switchMap } from "rxjs"; import { map, of, switchMap } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
@@ -23,7 +24,6 @@ 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;
@@ -42,9 +42,9 @@ export interface LocalUserMediaViewModel extends BaseUserMediaViewModel {
export interface LocalUserMediaInputs extends Omit< export interface LocalUserMediaInputs extends Omit<
BaseUserMediaInputs, BaseUserMediaInputs,
"media$" "member"
> { > {
media$: Behavior<LocalMemberMedia | null>; member: Pick<LocalRTCMember, "local" | "tracks$" | "encryptionError$">;
mediaDevices: MediaDevices; mediaDevices: MediaDevices;
} }
@@ -54,11 +54,11 @@ export function createLocalUserMedia(
): LocalUserMediaViewModel { ): LocalUserMediaViewModel {
const baseUserMedia = createBaseUserMedia(scope, inputs); const baseUserMedia = createBaseUserMedia(scope, inputs);
// Our own camera track, which carries the controls a remote one lacks // Our own camera track, which carries the controls a remote one lacks
const camera$ = memberTrack$<LocalVideoMediaTrack>( const camera$ = trackBySource$(
scope, scope,
inputs.media$, inputs.member.tracks$,
"camera", "camera",
); ) as Behavior<LocalVideoMediaTrack | undefined>;
const facingMode$ = scope.behavior( const facingMode$ = scope.behavior(
camera$.pipe(switchMap((camera) => camera?.facingMode$ ?? of(undefined))), camera$.pipe(switchMap((camera) => camera?.facingMode$ ?? of(undefined))),
); );
+37 -57
View File
@@ -18,14 +18,15 @@ import {
mockAudioTrack, mockAudioTrack,
mockLocalMedia, mockLocalMedia,
mockMediaDevices, mockMediaDevices,
mockMemberMedia,
mockRemoteMedia, mockRemoteMedia,
mockRemoteScreenShare, mockRemoteScreenShare,
mockRtcMembership, mockRtcMembership,
type MockTracks,
mockVideoTrack, mockVideoTrack,
withTestScheduler, withTestScheduler,
} from "../../utils/test"; } from "../../utils/test";
import { showConnectionStats } from "../../settings/settings"; import { showConnectionStats } from "../../settings/settings";
import { EncryptionStatus } from "./MemberMediaViewModel";
const platformMock = vi.hoisted(() => vi.fn(() => "desktop")); const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../../Platform", () => ({ vi.mock("../../Platform", () => ({
@@ -38,13 +39,9 @@ const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
test("control a participant's volume", () => { test("control a participant's volume", () => {
const setVolume = vi.fn(); const setVolume = vi.fn();
const vm = mockRemoteMedia( const vm = mockRemoteMedia(rtcMembership, {}, [
rtcMembership, mockAudioTrack({ setVolume }),
{}, ]);
mockMemberMedia({
tracks$: constant([mockAudioTrack({ setVolume })]),
}),
);
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-ab---c---d|", { schedule("-ab---c---d|", {
a() { a() {
@@ -89,15 +86,9 @@ test("control a participant's volume", () => {
test("control a participant's screen share volume", () => { test("control a participant's screen share volume", () => {
const setVolume = vi.fn(); const setVolume = vi.fn();
const vm = mockRemoteScreenShare( const vm = mockRemoteScreenShare(rtcMembership, {}, [
rtcMembership, mockAudioTrack({ source: "screenShareAudio", setVolume }),
{}, ]);
mockMemberMedia({
tracks$: constant([
mockAudioTrack({ source: "screenShareAudio", setVolume }),
]),
}),
);
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-ab---c---d|", { schedule("-ab---c---d|", {
a() { a() {
@@ -137,23 +128,13 @@ test("control a participant's screen share volume", () => {
}); });
test("local media remembers whether it should always be shown", () => { test("local media remembers whether it should always be shown", () => {
const vm1 = mockLocalMedia( const vm1 = mockLocalMedia(rtcMembership, {}, [], mockMediaDevices({}));
rtcMembership,
{},
mockMemberMedia({ local: true }),
mockMediaDevices({}),
);
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm1.setAlwaysShow(false) }); schedule("-a|", { a: () => vm1.setAlwaysShow(false) });
expectObservable(vm1.alwaysShow$).toBe("ab", { a: true, b: false }); expectObservable(vm1.alwaysShow$).toBe("ab", { a: true, b: false });
}); });
// Next local media should start out *not* always shown // Next local media should start out *not* always shown
const vm2 = mockLocalMedia( const vm2 = mockLocalMedia(rtcMembership, {}, [], mockMediaDevices({}));
rtcMembership,
{},
mockMemberMedia({ local: true }),
mockMediaDevices({}),
);
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm2.setAlwaysShow(true) }); schedule("-a|", { a: () => vm2.setAlwaysShow(true) });
expectObservable(vm2.alwaysShow$).toBe("ab", { a: false, b: true }); expectObservable(vm2.alwaysShow$).toBe("ab", { a: false, b: true });
@@ -178,10 +159,7 @@ test("switch cameras", async () => {
const vm = mockLocalMedia( const vm = mockLocalMedia(
rtcMembership, rtcMembership,
{}, {},
mockMemberMedia({ [mockVideoTrack({ facingMode$, switchFacingMode })],
local: true,
tracks$: constant([mockVideoTrack({ facingMode$, switchFacingMode })]),
}),
mockMediaDevices({ mockMediaDevices({
videoInput: { videoInput: {
available$: constant(new Map()), available$: constant(new Map()),
@@ -215,7 +193,7 @@ test("no camera switch where the facing mode is unknown", () => {
const vm = mockLocalMedia( const vm = mockLocalMedia(
rtcMembership, rtcMembership,
{}, {},
mockMemberMedia({ local: true, tracks$: constant([mockVideoTrack()]) }), [mockVideoTrack()],
mockMediaDevices({}), mockMediaDevices({}),
); );
expect(vm.switchCamera$.value).toBeNull(); expect(vm.switchCamera$.value).toBeNull();
@@ -227,26 +205,34 @@ test("remote media is in waiting state while its media has not arrived", () => {
}); });
test("remote media is not in waiting state once its media is there", () => { test("remote media is not in waiting state once its media is there", () => {
const vm = mockRemoteMedia(rtcMembership, {}, mockMemberMedia()); const vm = mockRemoteMedia(rtcMembership, {}, []);
expect(vm.waitingForMedia$.value).toBe(false); expect(vm.waitingForMedia$.value).toBe(false);
}); });
test("remote media stops waiting once its tracks arrive, however few", () => {
const tracks$ = new BehaviorSubject<MockTracks>(null);
const vm = mockRemoteMedia(rtcMembership, {}, tracks$);
expect(vm.waitingForMedia$.value).toBe(true);
expect(vm.encryptionStatus$.value).toBe(EncryptionStatus.Connecting);
tracks$.next([]);
expect(vm.waitingForMedia$.value).toBe(false);
});
test("local media never reports an encryption problem", () => {
const vm = mockLocalMedia(rtcMembership, {}, [], mockMediaDevices({}));
expect(vm.encryptionStatus$.value).toBe(EncryptionStatus.Okay);
});
test("remote media is not in waiting state when user does not intend to publish anywhere", () => { test("remote media is not in waiting state when user does not intend to publish anywhere", () => {
const vm = mockRemoteMedia(rtcMembership, {}, null, {}); const vm = mockRemoteMedia(rtcMembership, {}, null, {});
expect(vm.waitingForMedia$.value).toBe(false); expect(vm.waitingForMedia$.value).toBe(false);
}); });
test("audio and video follow the tracks' mute state", () => { test("audio and video follow the tracks' mute state", () => {
const vm = mockRemoteMedia( const vm = mockRemoteMedia(rtcMembership, {}, [
rtcMembership, mockAudioTrack({ muted$: constant(false) }),
{}, mockVideoTrack({ muted$: constant(true) }),
mockMemberMedia({ ]);
tracks$: constant([
mockAudioTrack({ muted$: constant(false) }),
mockVideoTrack({ muted$: constant(true) }),
]),
}),
);
expect(vm.audioEnabled$.value).toBe(true); expect(vm.audioEnabled$.value).toBe(true);
expect(vm.videoEnabled$.value).toBe(false); expect(vm.videoEnabled$.value).toBe(false);
}); });
@@ -256,19 +242,13 @@ test("user media polls stream stats only while the setting is on", () => {
withTestScheduler(({ cold, expectObservable, schedule }) => { withTestScheduler(({ cold, expectObservable, schedule }) => {
const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats; const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats;
// A behavior that only runs its source while subscribed, as the SDK's does // A behavior that only runs its source while subscribed, as the SDK's does
const vm = mockRemoteMedia( const vm = mockRemoteMedia(rtcMembership, {}, [
rtcMembership, mockAudioTrack({
{}, stats$: cold("-s", {
mockMemberMedia({ s: stats,
tracks$: constant([ }) as unknown as Behavior<MediaStreamStats>,
mockAudioTrack({
stats$: cold("-s", {
s: stats,
}) as unknown as Behavior<MediaStreamStats>,
}),
]),
}), }),
); ]);
schedule("-a-b", { schedule("-a-b", {
a() { a() {
showConnectionStats.setValue(true); showConnectionStats.setValue(true);
+19 -30
View File
@@ -7,15 +7,13 @@ Please see LICENSE in the repository root for full details.
*/ */
import { import {
type AudioMediaTrack,
type Behavior, type Behavior,
E2eeType, E2eeType,
type EncryptionError, type EncryptionError,
type EncryptionSystem, type EncryptionSystem,
type MediaSource,
type MediaTrack, type MediaTrack,
type MemberMedia,
type ObservableScope, type ObservableScope,
type RTCMember,
trackBySource$, trackBySource$,
type VideoMediaTrack, type VideoMediaTrack,
} from "@element-hq/matrixrtc-sdk"; } from "@element-hq/matrixrtc-sdk";
@@ -65,33 +63,24 @@ export interface BaseMemberMediaViewModel extends BaseMediaViewModel {
encryptionStatus$: Behavior<EncryptionStatus>; encryptionStatus$: Behavior<EncryptionStatus>;
} }
/** The member whose media this is, as far as the media view models read it. */
export type MediaMember = Pick<
RTCMember,
"local" | "tracks$" | "encryptionError$"
>;
export interface MemberMediaInputs extends BaseMediaViewModel { export interface MemberMediaInputs extends BaseMediaViewModel {
media$: Behavior<MemberMedia | null>; member: MediaMember;
audioSource: "microphone" | "screenShareAudio"; audioSource: "microphone" | "screenShareAudio";
videoSource: "camera" | "screenShare"; videoSource: "camera" | "screenShare";
focusUrl$: Behavior<string | undefined>; focusUrl$: Behavior<string | undefined>;
encryptionSystem: EncryptionSystem; encryptionSystem: EncryptionSystem;
} }
/**
* 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>(
scope: ObservableScope,
media$: Behavior<MemberMedia | null>,
source: MediaSource,
): Behavior<T | undefined> {
const tracks$ = scope.behavior<MediaTrack[]>(
media$.pipe(switchMap((media) => media?.tracks$ ?? of([]))),
);
return trackBySource$(scope, tracks$, source) as Behavior<T | undefined>;
}
export function createMemberMedia( export function createMemberMedia(
scope: ObservableScope, scope: ObservableScope,
{ {
media$, member,
audioSource, audioSource,
videoSource, videoSource,
transport$, transport$,
@@ -99,8 +88,8 @@ export function createMemberMedia(
...inputs ...inputs
}: MemberMediaInputs, }: MemberMediaInputs,
): BaseMemberMediaViewModel { ): BaseMemberMediaViewModel {
const audio$ = memberTrack$<AudioMediaTrack>(scope, media$, audioSource); const audio$ = trackBySource$(scope, member.tracks$, audioSource);
const video$ = memberTrack$<VideoMediaTrack>(scope, media$, videoSource); const video$ = trackBySource$(scope, member.tracks$, videoSource);
const unencrypted$ = ( const unencrypted$ = (
track$: Behavior<MediaTrack | undefined>, track$: Behavior<MediaTrack | undefined>,
): Observable<boolean> => ): Observable<boolean> =>
@@ -123,16 +112,16 @@ export function createMemberMedia(
), ),
), ),
encryptionStatus$: scope.behavior( encryptionStatus$: scope.behavior(
media$.pipe( member.tracks$.pipe(
switchMap((media): Observable<EncryptionStatus> => { switchMap((tracks): Observable<EncryptionStatus> => {
if (media === null) return of(EncryptionStatus.Connecting); if (tracks === null) return of(EncryptionStatus.Connecting);
if (media.local || encryptionSystem.kind === E2eeType.NONE) if (member.local || encryptionSystem.kind === E2eeType.NONE)
return of(EncryptionStatus.Okay); return of(EncryptionStatus.Okay);
const perParticipant = const perParticipant =
encryptionSystem.kind === E2eeType.PER_PARTICIPANT; encryptionSystem.kind === E2eeType.PER_PARTICIPANT;
return combineLatest([ return combineLatest([
encryptionError$(media, "MissingKey"), encryptionError$(member.encryptionError$, "MissingKey"),
encryptionError$(media, "InvalidKey"), encryptionError$(member.encryptionError$, "InvalidKey"),
receivingOkay$(audio$), receivingOkay$(audio$),
receivingOkay$(video$), receivingOkay$(video$),
]).pipe( ]).pipe(
@@ -156,10 +145,10 @@ export function createMemberMedia(
} }
function encryptionError$( function encryptionError$(
media: MemberMedia, errors$: Observable<EncryptionError>,
criteria: EncryptionError, criteria: EncryptionError,
): Observable<boolean> { ): Observable<boolean> {
return media.encryptionError$.pipe( return errors$.pipe(
map((error) => error === criteria), map((error) => error === criteria),
distinctUntilChanged(), distinctUntilChanged(),
throttleTime(1000), // Throttle to avoid spamming the UI throttleTime(1000), // Throttle to avoid spamming the UI
@@ -7,9 +7,9 @@ Please see LICENSE in the repository root for full details.
*/ */
import { import {
type AudioMediaTrack,
type Behavior, type Behavior,
type ObservableScope, type ObservableScope,
trackBySource$,
} from "@element-hq/matrixrtc-sdk"; } from "@element-hq/matrixrtc-sdk";
import { map } from "rxjs"; import { map } from "rxjs";
@@ -19,7 +19,6 @@ import {
createBaseScreenShare, createBaseScreenShare,
} from "./ScreenShareViewModel"; } from "./ScreenShareViewModel";
import { createVolumeControls, type VolumeControls } from "../VolumeControls"; import { createVolumeControls, type VolumeControls } from "../VolumeControls";
import { memberTrack$ } from "./MemberMediaViewModel";
export interface RemoteScreenShareViewModel export interface RemoteScreenShareViewModel
extends BaseScreenShareViewModel, VolumeControls { extends BaseScreenShareViewModel, VolumeControls {
@@ -42,9 +41,9 @@ export function createRemoteScreenShare(
scope: ObservableScope, scope: ObservableScope,
{ pretendToBeDisconnected$, ...inputs }: RemoteScreenShareInputs, { pretendToBeDisconnected$, ...inputs }: RemoteScreenShareInputs,
): RemoteScreenShareViewModel { ): RemoteScreenShareViewModel {
const audio$ = memberTrack$<AudioMediaTrack>( const audio$ = trackBySource$(
scope, scope,
inputs.media$, inputs.member.tracks$,
"screenShareAudio", "screenShareAudio",
); );
return { return {
+4 -5
View File
@@ -7,9 +7,9 @@ Please see LICENSE in the repository root for full details.
*/ */
import { import {
type AudioMediaTrack,
type Behavior, type Behavior,
type ObservableScope, type ObservableScope,
trackBySource$,
} from "@element-hq/matrixrtc-sdk"; } from "@element-hq/matrixrtc-sdk";
import { combineLatest, map, of, switchMap } from "rxjs"; import { combineLatest, map, of, switchMap } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
@@ -20,7 +20,6 @@ import {
type BaseUserMediaViewModel, type BaseUserMediaViewModel,
createBaseUserMedia, createBaseUserMedia,
} from "./UserMediaViewModel"; } from "./UserMediaViewModel";
import { memberTrack$ } from "./MemberMediaViewModel";
export interface RemoteUserMediaViewModel export interface RemoteUserMediaViewModel
extends BaseUserMediaViewModel, VolumeControls { extends BaseUserMediaViewModel, VolumeControls {
@@ -44,11 +43,11 @@ export function createRemoteUserMedia(
const waitingForMedia$ = scope.behavior( const waitingForMedia$ = scope.behavior(
combineLatest( combineLatest(
[inputs.focusUrl$, inputs.media$], [inputs.focusUrl$, inputs.member.tracks$],
// Without a transport the user is not attempting to publish anywhere // Without a transport the user is not attempting to publish anywhere
// and so we shouldn't expect media. (They might be a subscribe-only bot // and so we shouldn't expect media. (They might be a subscribe-only bot
// for example.) // for example.)
(focusUrl, media) => focusUrl !== undefined && media === null, (focusUrl, tracks) => focusUrl !== undefined && tracks === null,
), ),
); );
waitingForMedia$.pipe(scope.bind()).subscribe((waiting) => { waitingForMedia$.pipe(scope.bind()).subscribe((waiting) => {
@@ -60,7 +59,7 @@ export function createRemoteUserMedia(
...createVolumeControls(scope, { ...createVolumeControls(scope, {
pretendToBeDisconnected$, pretendToBeDisconnected$,
sink$: scope.behavior( sink$: scope.behavior(
memberTrack$<AudioMediaTrack>(scope, inputs.media$, "microphone").pipe( trackBySource$(scope, inputs.member.tracks$, "microphone").pipe(
map((track) => (volume: number) => track?.setVolume(volume)), map((track) => (volume: number) => track?.setVolume(volume)),
), ),
), ),
+5 -7
View File
@@ -7,11 +7,10 @@ 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 ObservableScope, type ObservableScope,
trackBySource$,
} from "@element-hq/matrixrtc-sdk"; } from "@element-hq/matrixrtc-sdk";
import { import {
BehaviorSubject, BehaviorSubject,
@@ -27,7 +26,6 @@ import { type LocalUserMediaViewModel } from "./LocalUserMediaViewModel";
import { import {
createMemberMedia, createMemberMedia,
type MemberMediaInputs, type MemberMediaInputs,
memberTrack$,
type BaseMemberMediaViewModel, type BaseMemberMediaViewModel,
} from "./MemberMediaViewModel"; } from "./MemberMediaViewModel";
import { type RemoteUserMediaViewModel } from "./RemoteUserMediaViewModel"; import { type RemoteUserMediaViewModel } from "./RemoteUserMediaViewModel";
@@ -79,7 +77,7 @@ export function createBaseUserMedia(
...inputs ...inputs
}: BaseUserMediaInputs, }: BaseUserMediaInputs,
): BaseUserMediaViewModel { ): BaseUserMediaViewModel {
const { media$ } = inputs; const { tracks$ } = inputs.member;
const toggleCropVideo$ = new Subject<void>(); const toggleCropVideo$ = new Subject<void>();
const videoAspectRatio$ = new BehaviorSubject(NaN); const videoAspectRatio$ = new BehaviorSubject(NaN);
const enabled$ = ( const enabled$ = (
@@ -87,7 +85,7 @@ export function createBaseUserMedia(
source: "microphone" | "camera", source: "microphone" | "camera",
): Behavior<boolean> => ): Behavior<boolean> =>
scope.behavior( scope.behavior(
memberTrack$<MediaTrack>(scope, media$, source).pipe( trackBySource$(scope, tracks$, source).pipe(
switchMap((track) => switchMap((track) =>
track === undefined track === undefined
? of(false) ? of(false)
@@ -102,7 +100,7 @@ export function createBaseUserMedia(
): Behavior<MediaStreamStats> => ): Behavior<MediaStreamStats> =>
scope.behavior( scope.behavior(
combineLatest([ combineLatest([
memberTrack$<MediaTrack>(scope, media$, source), trackBySource$(scope, tracks$, source),
showConnectionStats.value$, showConnectionStats.value$,
]).pipe( ]).pipe(
switchMap(([track, show]) => switchMap(([track, show]) =>
@@ -120,7 +118,7 @@ export function createBaseUserMedia(
type: "user", type: "user",
// What a call calls speaking is the microphone carrying sound // What a call calls speaking is the microphone carrying sound
speaking$: scope.behavior( speaking$: scope.behavior(
memberTrack$<AudioMediaTrack>(scope, media$, "microphone").pipe( trackBySource$(scope, tracks$, "microphone").pipe(
switchMap((track) => track?.isActive$ ?? of(false)), switchMap((track) => track?.isActive$ ?? of(false)),
), ),
), ),
+18 -24
View File
@@ -8,9 +8,10 @@ Please see LICENSE in the repository root for full details.
import { import {
type Behavior, type Behavior,
generateItems, generateItems,
type LocalMemberMedia, type LocalRTCMember,
type MemberMedia,
type ObservableScope, type ObservableScope,
type RemoteRTCMember,
trackBySource$,
} from "@element-hq/matrixrtc-sdk"; } from "@element-hq/matrixrtc-sdk";
import { combineLatest, distinctUntilChanged, map, of } from "rxjs"; import { combineLatest, distinctUntilChanged, map, of } from "rxjs";
@@ -27,7 +28,6 @@ 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.
@@ -81,34 +81,28 @@ export type WrappedUserMediaViewModel = UserMediaViewModel & {
type WrappedUserMediaInputs = Omit< type WrappedUserMediaInputs = Omit<
LocalUserMediaInputs & RemoteUserMediaInputs, LocalUserMediaInputs & RemoteUserMediaInputs,
"media$" "member"
> & > & { member: LocalRTCMember | RemoteRTCMember };
(
| { local: true; media$: Behavior<LocalMemberMedia | null> }
| { local: false; media$: Behavior<MemberMedia | null> }
);
export function createWrappedUserMedia( export function createWrappedUserMedia(
scope: ObservableScope, scope: ObservableScope,
{ mediaDevices, pretendToBeDisconnected$, ...rest }: WrappedUserMediaInputs, {
member,
mediaDevices,
pretendToBeDisconnected$,
...inputs
}: WrappedUserMediaInputs,
): WrappedUserMediaViewModel { ): WrappedUserMediaViewModel {
const { local, media$: _media$, ...inputs } = rest; const userMedia = member.local
const userMedia = rest.local ? createLocalUserMedia(scope, { member, mediaDevices, ...inputs })
? createLocalUserMedia(scope, {
media$: rest.media$,
mediaDevices,
...inputs,
})
: createRemoteUserMedia(scope, { : createRemoteUserMedia(scope, {
media$: rest.media$, member,
pretendToBeDisconnected$, pretendToBeDisconnected$,
...inputs, ...inputs,
}); });
// TypeScript needs this widening of the type to happen in a separate statement
const media$: Behavior<MemberMedia | null> = _media$;
const screenShares$ = scope.behavior( const screenShares$ = scope.behavior(
memberTrack$(scope, media$, "screenShare").pipe( trackBySource$(scope, member.tracks$, "screenShare").pipe(
map((track) => track !== undefined), map((track) => track !== undefined),
distinctUntilChanged(), distinctUntilChanged(),
// Technically more than one screen share might be possible... our // Technically more than one screen share might be possible... our
@@ -121,12 +115,12 @@ export function createWrappedUserMedia(
}, },
(scope, _data$, key) => { (scope, _data$, key) => {
const id = `${inputs.id}:${key}`; const id = `${inputs.id}:${key}`;
return local return member.local
? createLocalScreenShare(scope, { ...inputs, id, media$ }) ? createLocalScreenShare(scope, { ...inputs, id, member })
: createRemoteScreenShare(scope, { : createRemoteScreenShare(scope, {
...inputs, ...inputs,
id, id,
media$, member,
pretendToBeDisconnected$, pretendToBeDisconnected$,
}); });
}, },
+2 -3
View File
@@ -17,7 +17,6 @@ import {
mockRemoteMedia, mockRemoteMedia,
mockLocalMedia, mockLocalMedia,
mockMediaDevices, mockMediaDevices,
mockMemberMedia,
} from "../utils/test"; } from "../utils/test";
import { GridTileViewModel } from "../state/TileViewModel"; import { GridTileViewModel } from "../state/TileViewModel";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
@@ -62,7 +61,7 @@ test("GridTile displays remote media", async () => {
rawDisplayName: "Alice", rawDisplayName: "Alice",
getMxcAvatarUrl: () => "mxc://adfsg", getMxcAvatarUrl: () => "mxc://adfsg",
}, },
mockMemberMedia(), [],
); );
const { container } = render( const { container } = render(
@@ -92,7 +91,7 @@ test("GridTile displays local media", async () => {
rawDisplayName: "Alice", rawDisplayName: "Alice",
getMxcAvatarUrl: () => "mxc://adfsg", getMxcAvatarUrl: () => "mxc://adfsg",
}, },
mockMemberMedia({ local: true }), [],
mockMediaDevices({}), mockMediaDevices({}),
); );
+4 -5
View File
@@ -19,7 +19,6 @@ import {
mockLocalMedia, mockLocalMedia,
mockRemoteMedia, mockRemoteMedia,
mockRemoteScreenShare, mockRemoteScreenShare,
mockMemberMedia,
} from "../utils/test"; } from "../utils/test";
import { SpotlightTileViewModel } from "../state/TileViewModel"; import { SpotlightTileViewModel } from "../state/TileViewModel";
import { constant } from "../state/Behavior"; import { constant } from "../state/Behavior";
@@ -41,7 +40,7 @@ test("SpotlightTile is accessible", async () => {
rawDisplayName: "Alice", rawDisplayName: "Alice",
getMxcAvatarUrl: () => "mxc://adfsg", getMxcAvatarUrl: () => "mxc://adfsg",
}, },
mockMemberMedia(), [],
); );
const vm2 = mockLocalMedia( const vm2 = mockLocalMedia(
@@ -50,7 +49,7 @@ test("SpotlightTile is accessible", async () => {
rawDisplayName: "Bob", rawDisplayName: "Bob",
getMxcAvatarUrl: () => "mxc://dlskf", getMxcAvatarUrl: () => "mxc://dlskf",
}, },
mockMemberMedia({ local: true }), [],
mockMediaDevices({}), mockMediaDevices({}),
); );
@@ -98,7 +97,7 @@ test("Screen share volume UI is shown when screen share has audio", async () =>
const vm = mockRemoteScreenShare( const vm = mockRemoteScreenShare(
mockRtcMembership("@alice:example.org", "AAAA"), mockRtcMembership("@alice:example.org", "AAAA"),
{}, {},
mockMemberMedia(), [],
); );
vi.spyOn(vm, "audioEnabled$", "get").mockReturnValue(constant(true)); vi.spyOn(vm, "audioEnabled$", "get").mockReturnValue(constant(true));
@@ -136,7 +135,7 @@ test("Screen share volume UI is hidden when screen share has no audio", async ()
const vm = mockRemoteScreenShare( const vm = mockRemoteScreenShare(
mockRtcMembership("@alice:example.org", "AAAA"), mockRtcMembership("@alice:example.org", "AAAA"),
{}, {},
mockMemberMedia(), [],
); );
vi.spyOn(vm, "audioEnabled$", "get").mockReturnValue(constant(false)); vi.spyOn(vm, "audioEnabled$", "get").mockReturnValue(constant(false));
+101
View File
@@ -0,0 +1,101 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { act, render, type RenderResult } from "@testing-library/react";
import { type Behavior, type RemoteRTCMember } from "@element-hq/matrixrtc-sdk";
import { BehaviorSubject } from "rxjs";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { MemberAudioRenderer } from "./MemberAudioRenderer";
import {
mockAudioTrack,
mockMediaDevices,
mockRTCMember,
mockRtcMembership,
type MockTracks,
mockVideoTrack,
} from "../utils/test";
/** The renderer creates one for the earpiece graph; jsdom has none. */
class FakeAudioContext {
public createGain(): unknown {
return { gain: { value: 1 } };
}
public createStereoPanner(): unknown {
return { pan: { value: 0 } };
}
public async close(): Promise<void> {}
}
beforeEach(() => {
vi.stubGlobal("AudioContext", FakeAudioContext);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function renderMembers(
members: { id: string; tracks$: Behavior<MockTracks> }[],
): RenderResult {
const rtcMembers: RemoteRTCMember[] = members.map(({ id, tracks$ }) =>
mockRTCMember(false, {
membership: mockRtcMembership(`@${id}:example.org`, "DEVICE"),
tracks$,
}),
);
return render(
<MediaDevicesContext.Provider value={mockMediaDevices({})}>
<MemberAudioRenderer members={rtcMembers} />
</MediaDevicesContext.Provider>,
);
}
test("plays nothing for a member whose media has not arrived", () => {
const { queryAllByTestId } = renderMembers([
{ id: "alice", tracks$: new BehaviorSubject<MockTracks>(null) },
]);
expect(queryAllByTestId("audio")).toHaveLength(0);
});
test("plays the microphone and the screen share audio, and nothing else", () => {
const microphone = mockAudioTrack({ id: "mic" });
const screenShareAudio = mockAudioTrack({
id: "share-audio",
source: "screenShareAudio",
});
const camera = mockVideoTrack({ id: "camera" });
const unknownAudio = mockAudioTrack({ id: "unknown", source: "unknown" });
const { queryAllByTestId } = renderMembers([
{
id: "alice",
tracks$: new BehaviorSubject<MockTracks>([
microphone,
camera,
screenShareAudio,
unknownAudio,
]),
},
]);
expect(queryAllByTestId("audio")).toHaveLength(2);
expect(microphone.attach).toHaveBeenCalledTimes(1);
expect(screenShareAudio.attach).toHaveBeenCalledTimes(1);
expect(camera.attach).not.toHaveBeenCalled();
expect(unknownAudio.attach).not.toHaveBeenCalled();
});
test("starts playing once the member's tracks arrive", () => {
const tracks$ = new BehaviorSubject<MockTracks>(null);
const { queryAllByTestId } = renderMembers([{ id: "alice", tracks$ }]);
expect(queryAllByTestId("audio")).toHaveLength(0);
const microphone = mockAudioTrack({ id: "mic" });
act(() => tracks$.next([microphone]));
expect(queryAllByTestId("audio")).toHaveLength(1);
expect(microphone.attach).toHaveBeenCalledTimes(1);
});
+5 -22
View File
@@ -9,7 +9,6 @@ Please see LICENSE in the repository root for full details.
import { import {
type AudioMediaTrack, type AudioMediaTrack,
type MediaSource, type MediaSource,
type MemberMedia,
type RemoteRTCMember, type RemoteRTCMember,
} from "@element-hq/matrixrtc-sdk"; } from "@element-hq/matrixrtc-sdk";
import { type FC, useEffect, useMemo, useState } from "react"; import { type FC, useEffect, useMemo, useState } from "react";
@@ -102,7 +101,7 @@ export const MemberAudioRenderer: FC<MemberAudioRendererProps> = ({
{members.map((member) => ( {members.map((member) => (
<MemberAudio <MemberAudio
key={member.id} key={member.id}
media$={member.media$} tracks$={member.tracks$}
muted={muted} muted={muted}
plugins={plugins} plugins={plugins}
/> />
@@ -116,32 +115,16 @@ interface AudioPlugins {
nodes: AudioNode[]; nodes: AudioNode[];
} }
const MemberAudio: FC<{
media$: RemoteRTCMember["media$"];
muted?: boolean;
plugins: AudioPlugins;
}> = ({ media$, muted, plugins }) => {
const media = useBehavior(media$);
return (
media !== null && (
<MemberTracksAudio
tracks$={media.tracks$}
muted={muted}
plugins={plugins}
/>
)
);
};
/** The sources a call plays; anything published without one stays silent. */ /** The sources a call plays; anything published without one stays silent. */
const playedSources = new Set<MediaSource>(["microphone", "screenShareAudio"]); const playedSources = new Set<MediaSource>(["microphone", "screenShareAudio"]);
const MemberTracksAudio: FC<{ /** One member's audio tracks; nothing while its media has not arrived. */
tracks$: MemberMedia["tracks$"]; const MemberAudio: FC<{
tracks$: RemoteRTCMember["tracks$"];
muted?: boolean; muted?: boolean;
plugins: AudioPlugins; plugins: AudioPlugins;
}> = ({ tracks$, muted, plugins }) => { }> = ({ tracks$, muted, plugins }) => {
const tracks = useBehavior(tracks$); const tracks = useBehavior(tracks$) ?? [];
return tracks return tracks
.filter( .filter(
(track): track is AudioMediaTrack => (track): track is AudioMediaTrack =>
+2 -3
View File
@@ -28,7 +28,6 @@ import {
mockMatrixRoom, mockMatrixRoom,
mockMatrixRTCClient, mockMatrixRTCClient,
mockMediaDevices, mockMediaDevices,
mockMemberMedia,
mockMuteStates, mockMuteStates,
MockRTCSession, MockRTCSession,
mockRTCMember, mockRTCMember,
@@ -172,7 +171,7 @@ export function getBasicCallViewModelEnvironment(
(m) => m.userId === localRtcMember.userId, (m) => m.userId === localRtcMember.userId,
), ),
transportUrl: "https://example.com", transportUrl: "https://example.com",
media$: constant(mockMemberMedia({ local: true })), tracks$: constant([]),
}) })
: null, : null,
), ),
@@ -188,7 +187,7 @@ export function getBasicCallViewModelEnvironment(
membership, membership,
roomMember: members.find((m) => m.userId === membership.userId), roomMember: members.find((m) => m.userId === membership.userId),
transportUrl: "https://example.com", transportUrl: "https://example.com",
media$: constant(mockMemberMedia()), tracks$: constant([]),
}), }),
), ),
), ),
+42 -23
View File
@@ -59,19 +59,20 @@ 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 EncryptionError,
type LocalAudioMediaTrack, type LocalAudioMediaTrack,
type LocalMemberMedia,
type LocalRTCMember, type LocalRTCMember,
type LocalVideoMediaTrack, type LocalVideoMediaTrack,
type MatrixRTCClient, type MatrixRTCClient,
type MatrixRTCError, type MatrixRTCError,
type MemberMedia,
type PublishRequest, 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";
@@ -337,17 +338,8 @@ export const mockAudioTrack = (
...track, ...track,
}) as LocalAudioMediaTrack; }) as LocalAudioMediaTrack;
/** A member's media as the SDK hands it out, with nothing published unless given. */ /** The tracks a member has, or null while its media has not arrived. */
export function mockMemberMedia( export type MockTracks = (AudioMediaTrack | VideoMediaTrack)[] | null;
media: Partial<Omit<LocalMemberMedia, "local">> & { local?: boolean } = {},
): LocalMemberMedia {
return {
local: false,
tracks$: constant([]),
encryptionError$: NEVER,
...media,
} as LocalMemberMedia;
}
/** Whoever publishes media, known by identity only. */ /** Whoever publishes media, known by identity only. */
export function mockRemoteParticipant( export function mockRemoteParticipant(
@@ -363,7 +355,7 @@ export function mockRemoteParticipant(
export function mockLocalMedia( export function mockLocalMedia(
rtcMember: CallMembership, rtcMember: CallMembership,
roomMember: Partial<RoomMember>, roomMember: Partial<RoomMember>,
media: LocalMemberMedia | null, tracks: MockTracks,
mediaDevices: MediaDevices, mediaDevices: MediaDevices,
): LocalUserMediaViewModel { ): LocalUserMediaViewModel {
const member = mockMatrixRoomMember(rtcMember, roomMember); const member = mockMatrixRoomMember(rtcMember, roomMember);
@@ -371,7 +363,11 @@ export function mockLocalMedia(
id: "local", id: "local",
userId: member.userId, userId: member.userId,
rtcBackendIdentity: rtcMember.rtcBackendIdentity, rtcBackendIdentity: rtcMember.rtcBackendIdentity,
media$: constant(media), member: mockRTCMember(true, {
membership: rtcMember,
roomMember,
tracks$: constant(tracks),
}),
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
focusUrl$: constant("https://rtc-example.org"), focusUrl$: constant("https://rtc-example.org"),
mediaDevices, mediaDevices,
@@ -385,7 +381,8 @@ export function mockLocalMedia(
export function mockRemoteMedia( export function mockRemoteMedia(
rtcMember: CallMembership, rtcMember: CallMembership,
roomMember: Partial<RoomMember>, roomMember: Partial<RoomMember>,
media: MemberMedia | null, /** The tracks, or a behavior of them for a test that has them arrive. */
tracks: MockTracks | Behavior<MockTracks>,
{ focusUrl }: { focusUrl?: string } = { focusUrl: "https://rtc-example.org" }, { focusUrl }: { focusUrl?: string } = { focusUrl: "https://rtc-example.org" },
): RemoteUserMediaViewModel { ): RemoteUserMediaViewModel {
const member = mockMatrixRoomMember(rtcMember, roomMember); const member = mockMatrixRoomMember(rtcMember, roomMember);
@@ -393,7 +390,12 @@ export function mockRemoteMedia(
id: "remote", id: "remote",
userId: member.userId, userId: member.userId,
rtcBackendIdentity: rtcMember.rtcBackendIdentity, rtcBackendIdentity: rtcMember.rtcBackendIdentity,
media$: constant(media), member: mockRTCMember(false, {
membership: rtcMember,
roomMember,
tracks$:
tracks === null || Array.isArray(tracks) ? constant(tracks) : tracks,
}),
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
focusUrl$: constant(focusUrl), focusUrl$: constant(focusUrl),
pretendToBeDisconnected$: constant(false), pretendToBeDisconnected$: constant(false),
@@ -407,13 +409,17 @@ export function mockRemoteMedia(
export function mockRemoteScreenShare( export function mockRemoteScreenShare(
rtcMember: CallMembership, rtcMember: CallMembership,
roomMember: Partial<RoomMember>, roomMember: Partial<RoomMember>,
media: MemberMedia | null, tracks: MockTracks,
): RemoteScreenShareViewModel { ): RemoteScreenShareViewModel {
const member = mockMatrixRoomMember(rtcMember, roomMember); const member = mockMatrixRoomMember(rtcMember, roomMember);
return createRemoteScreenShare(testScope(), { return createRemoteScreenShare(testScope(), {
id: "screenshare", id: "screenshare",
userId: member.userId, userId: member.userId,
media$: constant(media), member: mockRTCMember(false, {
membership: rtcMember,
roomMember,
tracks$: constant(tracks),
}),
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
focusUrl$: constant("https://rtc-example.org"), focusUrl$: constant("https://rtc-example.org"),
pretendToBeDisconnected$: constant(false), pretendToBeDisconnected$: constant(false),
@@ -425,7 +431,9 @@ export function mockRemoteScreenShare(
export interface MockMemberInputs { export interface MockMemberInputs {
membership: CallMembership; membership: CallMembership;
roomMember?: Partial<RoomMember>; roomMember?: Partial<RoomMember>;
media$?: Behavior<LocalMemberMedia | null>; /** Null, the default, is a member whose media has not arrived. */
tracks$?: Behavior<MockTracks>;
encryptionError$?: Observable<EncryptionError>;
transportUrl?: string | undefined; transportUrl?: string | undefined;
} }
@@ -440,7 +448,13 @@ export function mockRTCMember(
): RemoteRTCMember; ): RemoteRTCMember;
export function mockRTCMember( export function mockRTCMember(
local: boolean, local: boolean,
{ membership, roomMember, media$, transportUrl }: MockMemberInputs, {
membership,
roomMember,
tracks$ = constant(null),
encryptionError$ = NEVER,
transportUrl,
}: MockMemberInputs,
): LocalRTCMember | RemoteRTCMember { ): LocalRTCMember | RemoteRTCMember {
const member = mockMatrixRoomMember(membership, roomMember); const member = mockMatrixRoomMember(membership, roomMember);
const transport: TransportMetadata | undefined = const transport: TransportMetadata | undefined =
@@ -460,12 +474,16 @@ export function mockRTCMember(
displayName$: constant(member.rawDisplayName ?? membership.userId), displayName$: constant(member.rawDisplayName ?? membership.userId),
avatarUrl$: constant(member.getMxcAvatarUrl()), avatarUrl$: constant(member.getMxcAvatarUrl()),
transport$: constant(transport), transport$: constant(transport),
media$: media$ ?? constant(null), encryptionError$,
}; };
if (!local) return { ...base, local: false }; if (!local) return { ...base, local: false, tracks$ };
return { return {
...base, ...base,
local: true, local: true,
// The mock tracks carry the local controls, whichever side they stand in for
tracks$: tracks$ as Behavior<
(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null
>,
publish: vi.fn(async (request: PublishRequest) => publish: vi.fn(async (request: PublishRequest) =>
Promise.resolve( Promise.resolve(
request.source === "microphone" request.source === "microphone"
@@ -522,6 +540,7 @@ export function mockMatrixRTCClient(
), ),
keyRotationSuppressed$, keyRotationSuppressed$,
connectedTransports$, connectedTransports$,
setAudioOutputDeviceId: vi.fn(async () => Promise.resolve()),
sendData: vi.fn(async () => Promise.resolve()), sendData: vi.fn(async () => Promise.resolve()),
data$: NEVER, data$: NEVER,
}; };