Files
element-call-Github/sdk
Timo K.andClaude Fable 5.1 8638d4f159 Fall back to publishing on a remote SFU if possible
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>
2026-10-14 16:09:11 +02:00
..

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. An RTCSlot is the namespace in a room that memberships join into; join() returns an RTCParticipation, which lasts until its leave() and exposes state$, localMember$ and remoteMembers$. Members, transports and the participation state machine live here. createRTCSlot in RTCSlot.ts is the only way to get an implementation.
  • src/media-api.ts: media as a host sees it, on remoteMembers$ and localMember$. PublishRequest says what to publish (microphone, camera, screen share); MediaTrack is how it arrives. The underlying tracks stay hidden: the host hands an element to attach and 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; a MediaBackend speaks one media protocol and hands back the MemberMedia of each member through mediaFor(). A host rarely touches it; it matters when working on the LiveKit backend under src/media-backend/livekit/, the only place that imports livekit-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.