Files
element-call-Github/sdk/dev/main.ts
T
Timo K. 5c6d2f0891 Update SDK to be consumed by Element Call's CallViewModel
Update the public API to export the primitives and operators the call
model needs, and change `LocalMediaInputs` from observable flags to
immediate booleans with imperative setters. Add session timings, media
quality and capture settings as configuration options, and allow the
host to override the transport discovery with explicit URLs.
2026-10-08 13:53:56 +02:00

232 lines
7.1 KiB
TypeScript

/*
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.
*/
/**
* The smallest consumer of the SDK: log in, join a room, show every member's
* camera and play every remote member's microphone, mute, leave. What a host
* writes, and nothing a host would not.
*/
import { logger } from "matrix-js-sdk/lib/logger";
import { combineLatest, type Observable, of, switchMap } from "rxjs";
import {
constant,
createMatrixRTCClient,
E2eeType,
MatrixRTCMode,
type MediaTrack,
ObservableScope,
type RTCMember,
type MatrixRTCClient,
} from "@element-hq/matrixrtc-sdk";
import { createSession, joinRoom, type Login } from "./session";
const form = document.querySelector("form")!;
const status = document.getElementById("status")!;
const members = document.getElementById("members")!;
const buttons = {
microphone: document.getElementById("microphone") as HTMLButtonElement,
camera: document.getElementById("camera") as HTMLButtonElement,
leave: document.getElementById("leave") as HTMLButtonElement,
};
// So that a test, or a bookmark, can fill the form from the URL
const params = new URLSearchParams(location.search);
for (const input of form.querySelectorAll("input"))
input.value = params.get(input.name) ?? input.value;
form.addEventListener("submit", (event) => {
event.preventDefault();
const fields = new FormData(form);
const field = (name: string): string => fields.get(name) as string;
const login: Login = field("accessToken")
? {
accessToken: field("accessToken"),
userId: field("userId"),
deviceId: field("deviceId"),
}
: { username: field("username"), password: field("password") };
void start(field("homeserver"), login, field("room"));
});
async function start(
homeserver: string,
login: Login,
roomIdOrAlias: string,
): Promise<void> {
try {
status.textContent = "Logging in";
const client = await createSession(homeserver, login);
const room = await joinRoom(client, roomIdOrAlias);
const scope = new ObservableScope();
const rtcClient = createMatrixRTCClient(
scope,
client,
room,
{
microphoneEnabled: true,
cameraEnabled: true,
audioInputDeviceId$: constant(undefined),
videoInputDeviceId$: constant(undefined),
audioOutputDeviceId$: constant(undefined),
videoProcessor$: constant(undefined),
},
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
matrixRTCMode: MatrixRTCMode.Compatibility,
},
);
rtcClient.status$.pipe(scope.bind()).subscribe((s) => {
status.textContent = s;
});
rtcClient.fatalError$.pipe(scope.bind()).subscribe((error) => {
if (error !== null) status.textContent = `Error: ${error.message}`;
});
showMembers(scope, rtcClient);
rtcClient.join();
toggle(buttons.microphone, async (enabled) =>
rtcClient.setMicrophoneEnabled(enabled),
);
toggle(buttons.camera, async (enabled) =>
rtcClient.setCameraEnabled(enabled),
);
buttons.leave.hidden = false;
buttons.leave.onclick = (): void => {
rtcClient.leave();
scope.end();
members.replaceChildren();
for (const button of Object.values(buttons)) button.hidden = true;
status.textContent = "Left";
};
} catch (e) {
status.textContent = `Error: ${e}`;
logger.error(e);
}
}
/** A pressed button means the source is on; the SDK says what it could do. */
function toggle(
button: HTMLButtonElement,
set: (enabled: boolean) => Promise<boolean>,
): void {
button.hidden = false;
button.onclick = (): void => {
void set(button.ariaPressed !== "true").then((enabled) => {
button.ariaPressed = String(enabled);
});
};
}
function showMembers(scope: ObservableScope, rtcClient: MatrixRTCClient): void {
const tiles = new Map<string, HTMLElement>();
combineLatest([rtcClient.localMember$, rtcClient.remoteMembers$])
.pipe(scope.bind())
.subscribe(([local, remote]) => {
const current = local === null ? remote : [local, ...remote];
for (const [id, tile] of tiles)
if (!current.some((m) => m.id === id)) {
tile.remove();
tiles.delete(id);
}
for (const member of current)
if (!tiles.has(member.id)) {
const tile = memberTile(scope, member);
tiles.set(member.id, tile);
members.append(tile);
}
});
}
function memberTile(scope: ObservableScope, member: RTCMember): HTMLElement {
const tile = document.createElement("section");
tile.dataset.testid = "member";
tile.dataset.userId = member.userId;
tile.dataset.local = String(member.local);
const name = tile.appendChild(document.createElement("h2"));
member.displayName$.pipe(scope.bind()).subscribe((n) => {
name.textContent = n;
});
const camera$ = member.media$.pipe(
switchMap((media) => media?.camera$ ?? of(undefined)),
);
render(scope, camera$, tile.appendChild(document.createElement("video")));
// Our own microphone would only echo
if (!member.local) {
const microphone$ = member.media$.pipe(
switchMap((media) => media?.microphone$ ?? of(undefined)),
);
render(
scope,
microphone$,
tile.appendChild(document.createElement("audio")),
);
}
return tile;
}
/**
* Plays a track on an element and labels the element with the track's state,
* so that the page shows what the member is sending and how well it arrives.
*/
function render(
scope: ObservableScope,
track$: Observable<MediaTrack | undefined>,
element: HTMLMediaElement,
): void {
let attached: MediaTrack | undefined;
track$.pipe(scope.bind()).subscribe((track) => {
attached?.detach(element);
attached = track;
track?.attach(element);
});
scope.onEnd(() => attached?.detach(element));
const label = (
key: string,
value$: Observable<string | number | boolean | undefined>,
): void => {
value$.pipe(scope.bind()).subscribe((value) => {
if (value === undefined) delete element.dataset[key];
else element.dataset[key] = String(value);
});
};
const of$ = <T>(
pick: (track: MediaTrack) => Observable<T>,
): Observable<T | undefined> =>
track$.pipe(switchMap((track) => (track ? pick(track) : of(undefined))));
label(
"muted",
of$((t) => t.muted$),
);
label(
"encrypted",
of$((t) => t.encrypted$),
);
const stats$ = of$((t) => t.stats$);
label("frameWidth", stats$.pipe(switchMap((s) => of(frames(s)?.frameWidth))));
label("frames", stats$.pipe(switchMap((s) => of(frames(s)?.count))));
}
/** The frame counters an RTP stream reports, from either end of it. */
function frames(
stats: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined,
): { frameWidth: number | undefined; count: number | undefined } | undefined {
if (stats === undefined) return undefined;
return {
frameWidth: stats.frameWidth,
count:
stats.type === "inbound-rtp"
? (stats as RTCInboundRtpStreamStats).framesDecoded
: (stats as RTCOutboundRtpStreamStats).framesEncoded,
};
}