Port of edf1feae from johannes/rtc-remote-publish onto the SDK split,
where
the MatrixRTC logic lives in sdk/ rather than src/state/CallViewModel/.
Where the original lands:
- getLocalTransport's remote fallback: the LiveKit backend's new
findRemotePublishTransport (sdk/src/media-backend/livekit/
RemotePublishTransport.ts), which tries the candidates from the
js-sdk's
getRemoteLivekitPublishCandidates oldest first and keeps the first
whose
homeserver issues a token with the publish grant.
discoverLocalTransport
asks the backend for it after the host's fallback, with a snapshot of
the
session's memberships from RTCParticipation, and throws
MatrixRTCTransportMissingError where that finds nothing too. Transport
discovery stays backend neutral: the MSC4195 token check is the
backend's.
- LocalMember's "no delegation on a remote SFU" and the custom
transport's
server_name: LivekitBackend.prepareLocalTransport locates the
transport on
the homeserver it names (serverNameOfTransport) and skips the
delegation
probe where that is not our own.
- SessionBehaviors' membership.getTransportServerName: the same helper
in
LivekitBackend.mediaFor.
- decodeSFUJWTPayload, SFUJWTPayload.video.canPublish and the exported
isCsApiUnavailable: sdk/src/media-backend/livekit/auth/.
- DeveloperSettingsTab, the widget capabilities, the locale string and
the
js-sdk pin (9405287c, 44.0.0): applied as is. The error boundary
snapshots follow the string.
Tests: the LocalTransport cases go to RemotePublishTransport.test.ts and
LocalTransport.test.ts, the LocalMember remote case and the
SessionBehaviors
server-name case to LivekitBackend.test.ts, plus the memberships
snapshot in
RTCParticipation.test.ts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MatrixRTC SDK (EXPERIMENTAL)
@element-hq/matrixrtc-sdk is the call model under Element Call, on its own:
it joins a MatrixRTC slot in a Matrix room, writes and watches the memberships,
connects to the media transport (LiveKit today), publishes the local media and
exposes every member and their tracks as observables. It has no UI. Element
Call's own CallViewModel is one consumer of it; any host can build a different
view on top. The design is in SdkArchitecture.md, what
moved out of Element Call's src/ in SdkMigration.md.
Status: first implementation. createRTCSlot and slot.join() join the
session, connect to the transport, publish the local media and expose every
member's media; the development harness and its e2e tests in playwright/sdk
drive it, and Element Call's own CallViewModel is built on it. It depends on
nothing in Element Call's src/.
Using it
import {
createRTCSlot,
E2eeType,
MatrixRTCMode,
ObservableScope,
trackBySource$,
} from "@element-hq/matrixrtc-sdk";
const scope = new ObservableScope();
const slot = createRTCSlot(
scope,
client, // a matrix-js-sdk MatrixClient, logged in and syncing
room, // the matrix-js-sdk Room, from client.getRoom() once the join has synced
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
matrixRTCMode: MatrixRTCMode.Compatibility,
},
);
const participation = slot.join({
publish: [{ source: "microphone" }, { source: "camera" }],
});
// A <video> for each remote member's camera and an <audio> for its microphone.
// A track plays on an element once attached to it; detach before the element
// leaves the DOM. (`tileFor`, `setName` and `removeTilesExcept` are the host's.)
participation.remoteMembers$.subscribe((members) => {
for (const member of members) {
const { video, audio } = tileFor(member.memberId);
member.displayName$.subscribe((name) => setName(member.memberId, name));
trackBySource$(scope, member.tracks$, "camera").subscribe((track) =>
track?.attach(video),
);
trackBySource$(scope, member.tracks$, "microphone").subscribe((track) =>
track?.attach(audio),
);
}
removeTilesExcept(members.map((m) => m.memberId));
});
// later
participation.leave();
scope.end();
participation.localMember$ is one member of the same shape, null until our own
membership is in the room; its microphone would only echo.
dev/main.ts is this example as a working page.
Reading the code
Three files define the surface. Read them in this order.
src/api.ts: the MatrixRTC side. AnRTCSlotis the namespace in a room that memberships join into;join()returns anRTCParticipation, which lasts until itsleave()and exposesstate$,localMember$andremoteMembers$. Members, transports and the participation state machine live here.createRTCSlotinRTCSlot.tsis the only way to get an implementation.src/media-api.ts: media as a host sees it, onremoteMembers$andlocalMember$.PublishRequestsays what to publish (microphone, camera, screen share);MediaTrackis how it arrives. The underlying tracks stay hidden: the host hands an element toattachand the SDK plays into it. The local variants carry the controls (setEnabled,setDevice,setProcessor).src/media-backend/api.ts: internal, the seam between the session and whatever carries the media. The session speaks MatrixRTC; aMediaBackendspeaks one media protocol and hands back theMemberMediaof each member throughmediaFor(). A host rarely touches it; it matters when working on the LiveKit backend undersrc/media-backend/livekit/, the only place that importslivekit-client, or when writing a new backend.
Development
Everything runs from the repository root. This directory is a pnpm project of its
own only so that a host can install it as a git dependency (see
pnpm-workspace.yaml).
pnpm dev:sdk # the harness, https://localhost:3002
pnpm build:sdk # dist/matrixrtc-sdk.js and dist/types
pnpm test:playwright playwright/sdk # needs `pnpm backend`
Installing
pnpm add github:element-hq/element-call#<ref>&path:/sdk
The host supplies matrix-js-sdk, livekit-client and rxjs; the build leaves
them external.