mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
[SDK] split the clint object into -> slot and participation
This commit is contained in:
+20
-17
@@ -126,23 +126,26 @@ it, and `leave()` also sends the leave.
|
|||||||
|
|
||||||
## Vocabulary
|
## Vocabulary
|
||||||
|
|
||||||
| Word | Meaning here | Elsewhere |
|
The words the SDK is written in. They name the shape after the slot and
|
||||||
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
participation split in
|
||||||
| client | a `MatrixRTCClient`: one room, one slot, one local member | `MatrixClient` is always written out |
|
[`client-into-slot-session-split-plan.md`](./client-into-slot-session-split-plan.md);
|
||||||
| membership | one device's MatrixRTC state event, seen by hosts as `RTCMembership`, the js-sdk `CallMembership` behind it | |
|
the sections below still describe `MatrixRTCClient` until that lands.
|
||||||
| member | an `RTCMember`: a membership plus what the SDK derives from it (display name, transport, media) | Element Call's "matrixLivekitMember" |
|
|
||||||
| media | what a member sends, once it has arrived on its transport: `tracks$` on the member, `null` while waiting for media, and `encryptionError$` beside it. The backend supplies the same two fields | Element Call's `MediaViewModel` is a view of it |
|
|
||||||
| track | a `MediaTrack`: one publication of a member, audio or video, with `source`, `kind`, `id` and the behaviors on it. `LocalAudioMediaTrack` and `LocalVideoMediaTrack` add the controls over our own | LiveKit `TrackPublication` and `Track`, which never leave the SDK |
|
|
||||||
| source | a `MediaSource`: what a track is captured from, `microphone`, `camera`, `screenShare`, `screenShareAudio` or `unknown`. Picked out of `tracks$` with `trackBySource$` | LiveKit `Track.Source` |
|
|
||||||
| publish request | a `PublishRequest`: a source, where to capture it from and how to encode it. Given at the join in `options.publish`, or later to `publish` on the local member | |
|
|
||||||
| active | `isActive$` on an audio track: the track carries sound, as the backend measures it. "Speaking" is a call's reading of it on the microphone track | LiveKit "speaking", `isSpeaking` |
|
|
||||||
| attach, detach | handing a `<video>` or `<audio>` element to a track and taking it back. The SDK owns the stream and the observers on the element | LiveKit `Track.attach` |
|
|
||||||
| transport | where media is exchanged: a LiveKit service url today, described by `TransportMetadata` | MSC4143 "focus" |
|
|
||||||
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per client, chosen with `options.backend`; LiveKit under `sdk/src/media-backend/livekit/` is the one that exists | "SFU", a LiveKit room, a peer connection |
|
|
||||||
| application, slot | what the session is for (`m.call`) and which one of them in the room (`ROOM`) | MSC4143 |
|
|
||||||
|
|
||||||
Names use `RTC` in capitals, as the js-sdk does: `MatrixRTCClient`, `RTCMember`,
|
| Word | Meaning here | Elsewhere |
|
||||||
`matrixRTCMode`.
|
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||||
|
| slot | an `RTCSlot`: the namespace in a room that memberships join into, `application` plus `id`. It exists while the room does, with nobody in it too. Holds `members$`, its open or closed `status$`, and `join()` | the js-sdk `MatrixRTCSession` is one per slot; the MSC4143 slot event |
|
||||||
|
| participation | an `RTCParticipation`: the span between our `join()` of a slot and our `leave()`. Holds the connection and its `status$`, `localMember$`, `remoteMembers$`, the connected transports and the data channel. Made by `slot.join()`, never reused | "session" is not used here, because in the js-sdk it means the slot |
|
||||||
|
| member | an `RTCMember`: one membership in a slot with what the SDK derives from it, `rtcBackendIdentity`, `userId`, `deviceId`, `memberId`, `displayName$`, `avatarUrl$`, `transport$` and `applicationData$`. No media. What the slot hands out in `members$` | the js-sdk `CallMembership` behind it, which never leaves the SDK; Element Call's old "matrixLivekitMember" |
|
||||||
|
| local member, remote member | `LocalRTCMember` and `RemoteRTCMember`: an `RTCMember` plus its `MemberMedia`, which only a participation can carry. The local one's tracks carry the controls. What the participation hands out in `localMember$` and `remoteMembers$` | LiveKit `LocalParticipant` and `RemoteParticipant`, which never leave the SDK |
|
||||||
|
| media | a `MemberMedia`: what a member sends once it has arrived on its transport, `tracks$`, null while waiting, and `encryptionError$` beside it. The media backend supplies exactly these two fields per member | Element Call's `MediaViewModel` is a view of it |
|
||||||
|
| track | a `MediaTrack`: one publication of a member, audio or video, with `source`, `kind`, `id` and the behaviors on it. `LocalAudioMediaTrack` and `LocalVideoMediaTrack` add the controls over our own | LiveKit `TrackPublication` and `Track`, which never leave the SDK |
|
||||||
|
| publish request | a `PublishRequest`: a source, where to capture it from and how to encode it. Given at the join in `publish`, or later to `publish` on the participation | |
|
||||||
|
| attach, detach | handing a `<video>` or `<audio>` element to a track and taking it back. The SDK owns the stream and the observers on the element | LiveKit `Track.attach` |
|
||||||
|
| transport | an `RTCTransport`: where media is exchanged, a LiveKit service url today. Carries the raw js-sdk `Transport` from the membership and, in `resolved$`, what the backend fetched to connect to it | MSC4143 "focus" |
|
||||||
|
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation, chosen with `backend` in the join options; LiveKit under `sdk/src/media-backend/livekit/` is the one that exists | "SFU", a LiveKit room, a peer connection |
|
||||||
|
|
||||||
|
Names use `RTC` in capitals, as the js-sdk does: `RTCSlot`, `RTCParticipation`,
|
||||||
|
`RTCMember`, `matrixRTCMode`.
|
||||||
|
|
||||||
## Reactive primitives
|
## Reactive primitives
|
||||||
|
|
||||||
@@ -181,7 +184,7 @@ context, it is in the view model.
|
|||||||
|
|
||||||
### Creating one
|
### Creating one
|
||||||
|
|
||||||
````ts
|
```ts
|
||||||
import { type MatrixClient, type Room } from "matrix-js-sdk";
|
import { type MatrixClient, type Room } from "matrix-js-sdk";
|
||||||
import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc";
|
import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
|
|||||||
+44
-34
@@ -15,12 +15,13 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
|||||||
import { combineLatest, type Observable, of, switchMap } from "rxjs";
|
import { combineLatest, type Observable, of, switchMap } from "rxjs";
|
||||||
import {
|
import {
|
||||||
type AudioMediaTrack,
|
type AudioMediaTrack,
|
||||||
createMatrixRTCClient,
|
createRTCSlot,
|
||||||
E2eeType,
|
E2eeType,
|
||||||
|
type LocalRTCMember,
|
||||||
MatrixRTCMode,
|
MatrixRTCMode,
|
||||||
ObservableScope,
|
ObservableScope,
|
||||||
type RTCMember,
|
type RemoteRTCMember,
|
||||||
type MatrixRTCClient,
|
type RTCParticipation,
|
||||||
trackBySource$,
|
trackBySource$,
|
||||||
type VideoMediaTrack,
|
type VideoMediaTrack,
|
||||||
} from "@element-hq/matrixrtc-sdk";
|
} from "@element-hq/matrixrtc-sdk";
|
||||||
@@ -69,33 +70,34 @@ async function start(
|
|||||||
const room = await joinRoom(client, roomIdOrAlias);
|
const room = await joinRoom(client, roomIdOrAlias);
|
||||||
|
|
||||||
const scope = new ObservableScope();
|
const scope = new ObservableScope();
|
||||||
const rtcClient = createMatrixRTCClient(scope, client, room, {
|
const slot = createRTCSlot(scope, client, room, {
|
||||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||||
|
});
|
||||||
|
const rtcParticipation = slot.join({
|
||||||
publish: [{ source: "microphone" }, { source: "camera" }],
|
publish: [{ source: "microphone" }, { source: "camera" }],
|
||||||
});
|
});
|
||||||
rtcClient.status$.pipe(scope.bind()).subscribe((s) => {
|
rtcParticipation.status$.pipe(scope.bind()).subscribe((s) => {
|
||||||
status.textContent = s;
|
status.textContent = s;
|
||||||
});
|
});
|
||||||
rtcClient.fatalError$.pipe(scope.bind()).subscribe((error) => {
|
rtcParticipation.fatalError$.pipe(scope.bind()).subscribe((error) => {
|
||||||
if (error !== null) status.textContent = `Error: ${error.message}`;
|
if (error !== null) status.textContent = `Error: ${error.message}`;
|
||||||
});
|
});
|
||||||
showMembers(scope, rtcClient);
|
showMembers(scope, rtcParticipation);
|
||||||
showMessages(scope, rtcClient);
|
showMessages(scope, rtcParticipation);
|
||||||
rtcClient.join();
|
|
||||||
|
|
||||||
toggle(buttons.microphone, async (enabled) =>
|
toggle(buttons.microphone, async (enabled) =>
|
||||||
setPublished(rtcClient, "microphone", enabled),
|
setPublished(rtcParticipation, "microphone", enabled),
|
||||||
);
|
);
|
||||||
toggle(buttons.camera, async (enabled) =>
|
toggle(buttons.camera, async (enabled) =>
|
||||||
setPublished(rtcClient, "camera", enabled),
|
setPublished(rtcParticipation, "camera", enabled),
|
||||||
);
|
);
|
||||||
toggle(buttons.screenShare, async (enabled) =>
|
toggle(buttons.screenShare, async (enabled) =>
|
||||||
setPublished(rtcClient, "screenShare", enabled),
|
setPublished(rtcParticipation, "screenShare", enabled),
|
||||||
);
|
);
|
||||||
buttons.leave.hidden = false;
|
buttons.leave.hidden = false;
|
||||||
buttons.leave.onclick = (): void => {
|
buttons.leave.onclick = (): void => {
|
||||||
rtcClient.leave();
|
rtcParticipation.leave();
|
||||||
scope.end();
|
scope.end();
|
||||||
members.replaceChildren();
|
members.replaceChildren();
|
||||||
messages.replaceChildren();
|
messages.replaceChildren();
|
||||||
@@ -115,20 +117,20 @@ async function start(
|
|||||||
* published and unpublished.
|
* published and unpublished.
|
||||||
*/
|
*/
|
||||||
async function setPublished(
|
async function setPublished(
|
||||||
rtcClient: MatrixRTCClient,
|
rtcParticipation: RTCParticipation,
|
||||||
source: "microphone" | "camera" | "screenShare",
|
source: "microphone" | "camera" | "screenShare",
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const member = rtcClient.localMember$.value;
|
const track = rtcParticipation.localMember$.value?.tracks$.value?.find(
|
||||||
if (member === null) return !enabled;
|
(t) => t.source === source,
|
||||||
const track = member.tracks$.value?.find((t) => t.source === source);
|
);
|
||||||
if (track && source === "screenShare") {
|
if (track && source === "screenShare") {
|
||||||
if (!enabled) await member.unpublish(track.id);
|
if (!enabled) await rtcParticipation.unpublish(source);
|
||||||
return enabled;
|
return enabled;
|
||||||
}
|
}
|
||||||
if (track) return track.setEnabled(enabled);
|
if (track) return track.setEnabled(enabled);
|
||||||
if (!enabled) return false;
|
if (!enabled) return false;
|
||||||
await member.publish({ source });
|
await rtcParticipation.publish({ source });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,49 +150,57 @@ function toggle(
|
|||||||
/** Sends what the form holds on the "chat" topic, and lists what arrives. */
|
/** Sends what the form holds on the "chat" topic, and lists what arrives. */
|
||||||
function showMessages(
|
function showMessages(
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
rtcClient: MatrixRTCClient,
|
rtcParticipation: RTCParticipation,
|
||||||
): void {
|
): void {
|
||||||
dataForm.hidden = false;
|
dataForm.hidden = false;
|
||||||
dataForm.onsubmit = (event): void => {
|
dataForm.onsubmit = (event): void => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const text = new FormData(dataForm).get("text") as string;
|
const text = new FormData(dataForm).get("text") as string;
|
||||||
rtcClient.sendData("chat", text).then(
|
rtcParticipation.sendData("chat", text).then(
|
||||||
() => dataForm.reset(),
|
() => dataForm.reset(),
|
||||||
(e: unknown) => {
|
(e: unknown) => {
|
||||||
status.textContent = `Error: ${e}`;
|
status.textContent = `Error: ${e}`;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
rtcClient.data$.pipe(scope.bind()).subscribe(({ member, topic, text }) => {
|
rtcParticipation.data$
|
||||||
const line = messages.appendChild(document.createElement("li"));
|
.pipe(scope.bind())
|
||||||
line.dataset.testid = "message";
|
.subscribe(({ member, topic, text }) => {
|
||||||
line.dataset.topic = topic;
|
const line = messages.appendChild(document.createElement("li"));
|
||||||
line.dataset.userId = member.userId;
|
line.dataset.testid = "message";
|
||||||
line.textContent = `${member.displayName$.value}: ${text}`;
|
line.dataset.topic = topic;
|
||||||
});
|
line.dataset.userId = member.userId;
|
||||||
|
line.textContent = `${member.displayName$.value}: ${text}`;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMembers(scope: ObservableScope, rtcClient: MatrixRTCClient): void {
|
function showMembers(
|
||||||
|
scope: ObservableScope,
|
||||||
|
rtcParticipation: RTCParticipation,
|
||||||
|
): void {
|
||||||
const tiles = new Map<string, HTMLElement>();
|
const tiles = new Map<string, HTMLElement>();
|
||||||
combineLatest([rtcClient.localMember$, rtcClient.remoteMembers$])
|
combineLatest([rtcParticipation.localMember$, rtcParticipation.remoteMembers$])
|
||||||
.pipe(scope.bind())
|
.pipe(scope.bind())
|
||||||
.subscribe(([local, remote]) => {
|
.subscribe(([local, remote]) => {
|
||||||
const current = local === null ? remote : [local, ...remote];
|
const current = local === null ? remote : [local, ...remote];
|
||||||
for (const [id, tile] of tiles)
|
for (const [id, tile] of tiles)
|
||||||
if (!current.some((m) => m.id === id)) {
|
if (!current.some((m) => m.rtcBackendIdentity === id)) {
|
||||||
tile.remove();
|
tile.remove();
|
||||||
tiles.delete(id);
|
tiles.delete(id);
|
||||||
}
|
}
|
||||||
for (const member of current)
|
for (const member of current)
|
||||||
if (!tiles.has(member.id)) {
|
if (!tiles.has(member.rtcBackendIdentity)) {
|
||||||
const tile = memberTile(scope, member);
|
const tile = memberTile(scope, member);
|
||||||
tiles.set(member.id, tile);
|
tiles.set(member.rtcBackendIdentity, tile);
|
||||||
members.append(tile);
|
members.append(tile);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function memberTile(scope: ObservableScope, member: RTCMember): HTMLElement {
|
function memberTile(
|
||||||
|
scope: ObservableScope,
|
||||||
|
member: LocalRTCMember | RemoteRTCMember,
|
||||||
|
): HTMLElement {
|
||||||
const tile = document.createElement("section");
|
const tile = document.createElement("section");
|
||||||
tile.dataset.testid = "member";
|
tile.dataset.testid = "member";
|
||||||
tile.dataset.userId = member.userId;
|
tile.dataset.userId = member.userId;
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ export * from "./src/api";
|
|||||||
export * from "./src/media-api";
|
export * from "./src/media-api";
|
||||||
export * from "./src/media-backend/api";
|
export * from "./src/media-backend/api";
|
||||||
export { trackBySource$, type TrackOfSource } from "./src/utils/tracks";
|
export { trackBySource$, type TrackOfSource } from "./src/utils/tracks";
|
||||||
export { createMatrixRTCClient } from "./src/MatrixRTCClient";
|
export { createRTCSlot } from "./src/RTCSlot";
|
||||||
export {
|
export {
|
||||||
createLivekitBackend,
|
createLivekitBackend,
|
||||||
type LivekitBackendOptions,
|
type LivekitBackendOptions,
|
||||||
|
|||||||
@@ -6,29 +6,40 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { type MatrixClient, type Room } from "matrix-js-sdk";
|
import { type MatrixClient, type Room } from "matrix-js-sdk";
|
||||||
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
import {
|
||||||
type MatrixRTCSession as JsSdkRTCSession,
|
type MatrixRTCSession as JsSdkRTCSession,
|
||||||
MatrixRTCSessionEvent,
|
MatrixRTCSessionEvent,
|
||||||
MatrixRTCSessionManager,
|
|
||||||
MembershipManagerEvent,
|
MembershipManagerEvent,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
||||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { combineLatest, filter, from, fromEvent, map, Observable } from "rxjs";
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
combineLatest,
|
||||||
|
filter,
|
||||||
|
from,
|
||||||
|
fromEvent,
|
||||||
|
map,
|
||||||
|
Observable,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
import { defaultSessionTimings, MatrixRTCMode } from "./config";
|
import { defaultSessionTimings, MatrixRTCMode } from "./config";
|
||||||
import { E2eeType } from "./encryption";
|
import { E2eeType } from "./encryption";
|
||||||
|
import { type Behavior } from "./reactive/Behavior";
|
||||||
import { type ObservableScope } from "./reactive/ObservableScope";
|
import { type ObservableScope } from "./reactive/ObservableScope";
|
||||||
import { filterBehavior, generateItems } from "./reactive/observable";
|
import { generateItems } from "./reactive/observable";
|
||||||
import { mapScoped } from "./utils/mapScoped";
|
import { mapScoped } from "./utils/mapScoped";
|
||||||
import {
|
import {
|
||||||
type DataMessage,
|
type DataMessage,
|
||||||
type LocalRTCMember,
|
type LocalRTCMember,
|
||||||
type MatrixRTCClient,
|
|
||||||
type MatrixRTCClientOptions,
|
|
||||||
type RemoteRTCMember,
|
type RemoteRTCMember,
|
||||||
|
type RTCMember,
|
||||||
|
type RTCParticipation,
|
||||||
|
type RTCParticipationOptions,
|
||||||
|
type RTCSlot,
|
||||||
|
type RTCSlotOptions,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import {
|
import {
|
||||||
type MediaBackend,
|
type MediaBackend,
|
||||||
@@ -36,7 +47,6 @@ import {
|
|||||||
type MediaKey,
|
type MediaKey,
|
||||||
} from "./media-backend/api";
|
} from "./media-backend/api";
|
||||||
import { createLivekitBackend } from "./media-backend/livekit/LivekitBackend";
|
import { createLivekitBackend } from "./media-backend/livekit/LivekitBackend";
|
||||||
import { MatrixRTCError } from "./errors";
|
|
||||||
import { createHomeserverConnected$ } from "./matrixrtc/HomeserverConnected";
|
import { createHomeserverConnected$ } from "./matrixrtc/HomeserverConnected";
|
||||||
import { joinJsSdkSession } from "./matrixrtc/joinJsSdkSession";
|
import { joinJsSdkSession } from "./matrixrtc/joinJsSdkSession";
|
||||||
import {
|
import {
|
||||||
@@ -44,49 +54,55 @@ import {
|
|||||||
type PreparedTransport,
|
type PreparedTransport,
|
||||||
} from "./matrixrtc/LocalMember";
|
} from "./matrixrtc/LocalMember";
|
||||||
import { discoverLocalTransport } from "./matrixrtc/LocalTransport";
|
import { discoverLocalTransport } from "./matrixrtc/LocalTransport";
|
||||||
import {
|
|
||||||
createMatrixMemberMetadata$,
|
|
||||||
createRoomMembers$,
|
|
||||||
} from "./matrixrtc/MatrixMemberMetadata";
|
|
||||||
import {
|
import {
|
||||||
createLocalRTCMember,
|
createLocalRTCMember,
|
||||||
createRemoteRTCMember,
|
createRemoteRTCMember,
|
||||||
membershipKeys,
|
|
||||||
} from "./matrixrtc/Members";
|
} from "./matrixrtc/Members";
|
||||||
import {
|
import { createKeyRotationSuppressed$ } from "./matrixrtc/JsRtcSessionBehaviors";
|
||||||
createKeyRotationSuppressed$,
|
import { fatalError, participationStatus } from "./matrixrtc/status";
|
||||||
createMemberships$,
|
import { type TransportRegistry } from "./matrixrtc/Transports";
|
||||||
} from "./matrixrtc/JsRtcSessionBehaviors";
|
|
||||||
import { fatalError, sessionStatus } from "./matrixrtc/status";
|
/** What the slot lends a participation. */
|
||||||
import { createTransportRegistry } from "./matrixrtc/Transports";
|
export interface ParticipationDeps {
|
||||||
|
client: MatrixClient;
|
||||||
|
room: Room;
|
||||||
|
slot: RTCSlot;
|
||||||
|
jsSdkSession: JsSdkRTCSession;
|
||||||
|
members$: Behavior<RTCMember[]>;
|
||||||
|
transports: TransportRegistry;
|
||||||
|
slotOptions: Pick<RTCSlotOptions, "matrixRTCMode" | "encryptionSystem">;
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The participation, plus what the slot reads from it without exposing it. */
|
||||||
|
export interface ParticipationInternals {
|
||||||
|
participation: RTCParticipation;
|
||||||
|
connections$: MediaBackend["connections$"];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Takes the whole `MatrixClient` rather than a slice of it, and finds the
|
* Our span in a slot: the backend, the local transport, the join state
|
||||||
* MatrixRTC session itself. The js-sdk is the MatrixRTC implementation today;
|
* machine and the members with the media it carries for them. Starts joining
|
||||||
* when the rust-rtc crate replaces it, the SDK has to bridge the crate to the
|
* at once and ends with `leave()`, which also ends `scope`.
|
||||||
* js-sdk client, and only the SDK knows what that bridge needs. Holding the
|
|
||||||
* client keeps that change inside the SDK.
|
|
||||||
*/
|
*/
|
||||||
export function createMatrixRTCClient(
|
export function createRTCParticipation(
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
client: MatrixClient,
|
{
|
||||||
room: Room,
|
|
||||||
options: MatrixRTCClientOptions,
|
|
||||||
): MatrixRTCClient {
|
|
||||||
const logger = rootLogger.getChild("[MatrixRTCClient]");
|
|
||||||
const userId = client.getUserId();
|
|
||||||
const deviceId = client.getDeviceId();
|
|
||||||
if (!(userId && deviceId))
|
|
||||||
throw new MatrixRTCError("The client has to be logged in");
|
|
||||||
const { encryptionSystem, matrixRTCMode } = options;
|
|
||||||
const timings = { ...defaultSessionTimings, ...options.timings };
|
|
||||||
|
|
||||||
const jsSdkSession = sessionManager(
|
|
||||||
scope,
|
|
||||||
client,
|
client,
|
||||||
options,
|
room,
|
||||||
logger,
|
slot,
|
||||||
).getRoomSession(room);
|
jsSdkSession,
|
||||||
|
members$,
|
||||||
|
transports,
|
||||||
|
slotOptions: { matrixRTCMode, encryptionSystem },
|
||||||
|
logger: parentLogger,
|
||||||
|
}: ParticipationDeps,
|
||||||
|
options: RTCParticipationOptions,
|
||||||
|
): ParticipationInternals {
|
||||||
|
const logger = parentLogger.getChild("[RTCParticipation]");
|
||||||
|
const userId = client.getUserId()!;
|
||||||
|
const deviceId = client.getDeviceId()!;
|
||||||
|
const timings = { ...defaultSessionTimings, ...options.timings };
|
||||||
|
|
||||||
const ownMembershipIdentity: CallMembershipIdentityParts = {
|
const ownMembershipIdentity: CallMembershipIdentityParts = {
|
||||||
userId,
|
userId,
|
||||||
@@ -100,7 +116,7 @@ export function createMatrixRTCClient(
|
|||||||
: `${userId}:${deviceId}`,
|
: `${userId}:${deviceId}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const backend = createBackend(scope, client, options, {
|
const backend = createBackend(scope, client, options, matrixRTCMode, {
|
||||||
roomId: room.roomId,
|
roomId: room.roomId,
|
||||||
ownMembershipIdentity,
|
ownMembershipIdentity,
|
||||||
publish: options.publish,
|
publish: options.publish,
|
||||||
@@ -108,10 +124,7 @@ export function createMatrixRTCClient(
|
|||||||
mediaKeys$: mediaKeys$(jsSdkSession),
|
mediaKeys$: mediaKeys$(jsSdkSession),
|
||||||
timings,
|
timings,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
const memberships$ = createMemberships$(scope, jsSdkSession);
|
|
||||||
const transports = createTransportRegistry(scope, backend.connections$);
|
|
||||||
|
|
||||||
const preparedTransport$ = from(
|
const preparedTransport$ = from(
|
||||||
discoverLocalTransport(client, backend.transportType, options, logger).then(
|
discoverLocalTransport(client, backend.transportType, options, logger).then(
|
||||||
@@ -162,56 +175,40 @@ export function createMatrixRTCClient(
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
const context = {
|
|
||||||
metadata: createMatrixMemberMetadata$(
|
|
||||||
scope,
|
|
||||||
scope.behavior(memberships$.pipe(map(({ value }) => value))),
|
|
||||||
createRoomMembers$(scope, room),
|
|
||||||
),
|
|
||||||
transports,
|
|
||||||
};
|
|
||||||
|
|
||||||
const remoteMembers$ = scope.behavior<RemoteRTCMember[]>(
|
const remoteMembers$ = scope.behavior<RemoteRTCMember[]>(
|
||||||
memberships$.pipe(
|
members$.pipe(
|
||||||
map(({ value }) => value),
|
|
||||||
generateItems(
|
generateItems(
|
||||||
"MatrixRTCClient remoteMembers",
|
"RTCParticipation remoteMembers",
|
||||||
function* (memberships) {
|
function* (members) {
|
||||||
for (const membership of memberships) {
|
for (const member of members)
|
||||||
if (
|
if (!member.local) yield { keys: [member] as const, data: member };
|
||||||
membership.userId === userId &&
|
|
||||||
membership.deviceId === deviceId
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
yield { keys: membershipKeys(membership), data: membership };
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
(memberScope, membership$) =>
|
(memberScope, member$) =>
|
||||||
createRemoteRTCMember(
|
createRemoteRTCMember(
|
||||||
memberScope,
|
member$.value,
|
||||||
membership$,
|
backend.mediaFor(memberScope, member$.value),
|
||||||
backend.mediaFor$(memberScope, membership$),
|
|
||||||
context,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const localMembership$ = scope.behavior(
|
// Matched by the identity we joined with: a stale membership of ours from
|
||||||
memberships$.pipe(
|
// an earlier participation is `local` in the slot but is not this one
|
||||||
|
const ownMember$ = scope.behavior(
|
||||||
|
members$.pipe(
|
||||||
map(
|
map(
|
||||||
({ value }) =>
|
(members) =>
|
||||||
value.find(
|
members.find(
|
||||||
(membership) =>
|
(member) =>
|
||||||
membership.userId === userId && membership.deviceId === deviceId,
|
member.local &&
|
||||||
|
member.memberId === ownMembershipIdentity.memberId,
|
||||||
) ?? null,
|
) ?? null,
|
||||||
),
|
),
|
||||||
filterBehavior((membership) => membership !== null),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const localMember$ = scope.behavior<LocalRTCMember | null>(
|
const localMember$ = scope.behavior<LocalRTCMember | null>(
|
||||||
mapScoped(scope, localMembership$, (memberScope, membership$) =>
|
mapScoped(scope, ownMember$, (_memberScope, member) =>
|
||||||
createLocalRTCMember(memberScope, membership$, backend.local, context),
|
createLocalRTCMember(member, backend.local),
|
||||||
).pipe(map((member) => member ?? null)),
|
).pipe(map((member) => member ?? null)),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -219,7 +216,9 @@ export function createMatrixRTCClient(
|
|||||||
// ever hears from attested members
|
// ever hears from attested members
|
||||||
const data$ = backend.data$.pipe(
|
const data$ = backend.data$.pipe(
|
||||||
map(({ senderId, topic, text }): DataMessage | null => {
|
map(({ senderId, topic, text }): DataMessage | null => {
|
||||||
const member = remoteMembers$.value.find((m) => m.id === senderId);
|
const member = remoteMembers$.value.find(
|
||||||
|
(m) => m.rtcBackendIdentity === senderId,
|
||||||
|
);
|
||||||
if (member === undefined) {
|
if (member === undefined) {
|
||||||
logger.warn(`Dropping data from ${senderId}: not a member`);
|
logger.warn(`Dropping data from ${senderId}: not a member`);
|
||||||
return null;
|
return null;
|
||||||
@@ -229,48 +228,56 @@ export function createMatrixRTCClient(
|
|||||||
filter((message) => message !== null),
|
filter((message) => message !== null),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const left$ = new BehaviorSubject(false);
|
||||||
const status$ = scope.behavior(
|
const status$ = scope.behavior(
|
||||||
combineLatest(
|
combineLatest(
|
||||||
[
|
[
|
||||||
localMembership.state$,
|
localMembership.state$,
|
||||||
localMembership.joinRequested$,
|
|
||||||
localMembership.connected$,
|
localMembership.connected$,
|
||||||
localMembership.reconnecting$,
|
localMembership.reconnecting$,
|
||||||
|
left$,
|
||||||
],
|
],
|
||||||
sessionStatus,
|
participationStatus,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const leave = (): void => {
|
||||||
|
if (left$.value) return;
|
||||||
|
left$.next(true);
|
||||||
|
localMembership.leave();
|
||||||
|
scope.end();
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
join: localMembership.requestJoinAndPublish,
|
connections$: backend.connections$,
|
||||||
leave: localMembership.requestDisconnect,
|
participation: {
|
||||||
status$,
|
slot,
|
||||||
connected$: localMembership.connected$,
|
leave,
|
||||||
reconnecting$: localMembership.reconnecting$,
|
status$,
|
||||||
disconnectReason$: localMembership.disconnectReason$,
|
connected$: localMembership.connected$,
|
||||||
fatalError$: scope.behavior(localMembership.state$.pipe(map(fatalError))),
|
reconnecting$: localMembership.reconnecting$,
|
||||||
localMember$,
|
disconnectReason$: localMembership.disconnectReason$,
|
||||||
remoteMembers$,
|
fatalError$: scope.behavior(localMembership.state$.pipe(map(fatalError))),
|
||||||
memberCount$: scope.behavior(
|
localMember$,
|
||||||
combineLatest(
|
remoteMembers$,
|
||||||
[localMember$, remoteMembers$],
|
publish: async (request) => backend.local.publish(request),
|
||||||
(local, remote) => remote.length + (local === null ? 0 : 1),
|
unpublish: async (source) => backend.local.unpublish(source),
|
||||||
),
|
keyRotationSuppressed$: createKeyRotationSuppressed$(scope, jsSdkSession),
|
||||||
),
|
connectedTransports$: transports.connected$,
|
||||||
keyRotationSuppressed$: createKeyRotationSuppressed$(scope, jsSdkSession),
|
setAudioOutputDeviceId: async (deviceId) =>
|
||||||
connectedTransports$: transports.connected$,
|
backend.setAudioOutputDeviceId(deviceId),
|
||||||
setAudioOutputDeviceId: async (deviceId) =>
|
sendData: async (topic, text) => backend.sendData(topic, text),
|
||||||
backend.setAudioOutputDeviceId(deviceId),
|
data$,
|
||||||
sendData: async (topic, text) => backend.sendData(topic, text),
|
},
|
||||||
data$,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The host's backend, or LiveKit configured from the client options. */
|
/** The host's backend, or LiveKit configured from the participation options. */
|
||||||
function createBackend(
|
function createBackend(
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
client: MatrixClient,
|
client: MatrixClient,
|
||||||
{ backend, mediaQuality, matrixRTCMode }: MatrixRTCClientOptions,
|
{ backend, mediaQuality }: RTCParticipationOptions,
|
||||||
|
matrixRTCMode: MatrixRTCMode,
|
||||||
context: MediaBackendContext,
|
context: MediaBackendContext,
|
||||||
): MediaBackend {
|
): MediaBackend {
|
||||||
if (backend) return backend(scope, context);
|
if (backend) return backend(scope, context);
|
||||||
@@ -282,7 +289,7 @@ function createBackend(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The session's per-participant keys: the ones it already holds on subscribe, then each new one. */
|
/** The slot's per-participant keys: the ones it already holds on subscribe, then each new one. */
|
||||||
function mediaKeys$(session: JsSdkRTCSession): Observable<MediaKey> {
|
function mediaKeys$(session: JsSdkRTCSession): Observable<MediaKey> {
|
||||||
return new Observable((subscriber) => {
|
return new Observable((subscriber) => {
|
||||||
const onKey = (
|
const onKey = (
|
||||||
@@ -296,24 +303,3 @@ function mediaKeys$(session: JsSdkRTCSession): Observable<MediaKey> {
|
|||||||
return () => session.off(MatrixRTCSessionEvent.EncryptionKeyChanged, onKey);
|
return () => session.off(MatrixRTCSessionEvent.EncryptionKeyChanged, onKey);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The js-sdk keeps one session manager per slot. The client comes with the
|
|
||||||
* manager for the default slot; any other slot gets a manager of its own,
|
|
||||||
* which lives as long as the scope.
|
|
||||||
*/
|
|
||||||
function sessionManager(
|
|
||||||
scope: ObservableScope,
|
|
||||||
client: MatrixClient,
|
|
||||||
{ application = "m.call", slot = "ROOM" }: MatrixRTCClientOptions,
|
|
||||||
logger: Logger,
|
|
||||||
): MatrixRTCSessionManager {
|
|
||||||
if (application === "m.call" && slot === "ROOM") return client.matrixRTC;
|
|
||||||
const manager = new MatrixRTCSessionManager(logger, client, {
|
|
||||||
application,
|
|
||||||
id: slot,
|
|
||||||
});
|
|
||||||
manager.start();
|
|
||||||
scope.onEnd(() => manager.stop());
|
|
||||||
return manager;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/*
|
||||||
|
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 { type MatrixClient, type Room } from "matrix-js-sdk";
|
||||||
|
import { EventType } from "matrix-js-sdk/lib/@types/event";
|
||||||
|
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import {
|
||||||
|
type MatrixRTCSession as JsSdkRTCSession,
|
||||||
|
MatrixRTCSessionManager,
|
||||||
|
type SlotDescription,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { type MatrixEvent } from "matrix-js-sdk/lib/models/event";
|
||||||
|
import { RoomStateEvent } from "matrix-js-sdk/lib/models/room-state";
|
||||||
|
import { BehaviorSubject, filter, fromEvent, map, of, switchMap } from "rxjs";
|
||||||
|
|
||||||
|
import { ObservableScope } from "./reactive/ObservableScope";
|
||||||
|
import { generateItems } from "./reactive/observable";
|
||||||
|
import {
|
||||||
|
type RTCMember,
|
||||||
|
type RTCParticipation,
|
||||||
|
type RTCParticipationOptions,
|
||||||
|
type RTCSlot,
|
||||||
|
type RTCSlotOptions,
|
||||||
|
} from "./api";
|
||||||
|
import { MatrixRTCError } from "./errors";
|
||||||
|
import {
|
||||||
|
createRTCParticipation,
|
||||||
|
type ParticipationInternals,
|
||||||
|
} from "./RTCParticipation";
|
||||||
|
import { createMemberships$ } from "./matrixrtc/JsRtcSessionBehaviors";
|
||||||
|
import {
|
||||||
|
createMatrixMemberMetadata$,
|
||||||
|
createRoomMembers$,
|
||||||
|
} from "./matrixrtc/MatrixMemberMetadata";
|
||||||
|
import {
|
||||||
|
createRTCMember,
|
||||||
|
type MemberContext,
|
||||||
|
membershipKeys,
|
||||||
|
} from "./matrixrtc/Members";
|
||||||
|
import { createTransportRegistry } from "./matrixrtc/Transports";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes the whole `MatrixClient` rather than a slice of it, and finds the
|
||||||
|
* MatrixRTC session itself. The js-sdk is the MatrixRTC implementation today;
|
||||||
|
* when the rust-rtc crate replaces it, the SDK has to bridge the crate to the
|
||||||
|
* js-sdk client, and only the SDK knows what that bridge needs. Holding the
|
||||||
|
* client keeps that change inside the SDK.
|
||||||
|
*/
|
||||||
|
export function createRTCSlot(
|
||||||
|
scope: ObservableScope,
|
||||||
|
client: MatrixClient,
|
||||||
|
room: Room,
|
||||||
|
options: RTCSlotOptions,
|
||||||
|
): RTCSlot {
|
||||||
|
const logger = rootLogger.getChild("[RTCSlot]");
|
||||||
|
const userId = client.getUserId();
|
||||||
|
const deviceId = client.getDeviceId();
|
||||||
|
if (!(userId && deviceId))
|
||||||
|
throw new MatrixRTCError("The client has to be logged in");
|
||||||
|
const { application = "m.call", id = "ROOM" } = options;
|
||||||
|
|
||||||
|
const jsSdkSession = sessionManager(
|
||||||
|
scope,
|
||||||
|
client,
|
||||||
|
{ application, id },
|
||||||
|
logger,
|
||||||
|
).getRoomSession(room);
|
||||||
|
const memberships$ = createMemberships$(scope, jsSdkSession);
|
||||||
|
|
||||||
|
const active$ = new BehaviorSubject<ParticipationInternals | null>(null);
|
||||||
|
const participationScopes = new Set<ObservableScope>();
|
||||||
|
scope.onEnd(() => {
|
||||||
|
for (const participationScope of participationScopes)
|
||||||
|
participationScope.end();
|
||||||
|
});
|
||||||
|
const transports = createTransportRegistry(
|
||||||
|
scope,
|
||||||
|
scope.behavior(
|
||||||
|
active$.pipe(switchMap((active) => active?.connections$ ?? of([]))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const context: MemberContext = {
|
||||||
|
metadata: createMatrixMemberMetadata$(
|
||||||
|
scope,
|
||||||
|
scope.behavior(memberships$.pipe(map(({ value }) => value))),
|
||||||
|
createRoomMembers$(scope, room),
|
||||||
|
),
|
||||||
|
transports,
|
||||||
|
own: { userId, deviceId },
|
||||||
|
};
|
||||||
|
|
||||||
|
const members$ = scope.behavior<RTCMember[]>(
|
||||||
|
memberships$.pipe(
|
||||||
|
map(({ value }) => value),
|
||||||
|
generateItems(
|
||||||
|
"RTCSlot members",
|
||||||
|
function* (memberships) {
|
||||||
|
for (const membership of memberships)
|
||||||
|
yield { keys: membershipKeys(membership), data: membership };
|
||||||
|
},
|
||||||
|
(memberScope, membership$) =>
|
||||||
|
createRTCMember(memberScope, membership$, context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const status$ = scope.behavior(
|
||||||
|
fromEvent(room, RoomStateEvent.Events, (event: MatrixEvent) => event).pipe(
|
||||||
|
filter((event) => event.getType() === EventType.RTCSlot),
|
||||||
|
map(() => slotStatus(jsSdkSession)),
|
||||||
|
),
|
||||||
|
slotStatus(jsSdkSession),
|
||||||
|
);
|
||||||
|
|
||||||
|
const slot: RTCSlot = {
|
||||||
|
roomId: room.roomId,
|
||||||
|
application,
|
||||||
|
id,
|
||||||
|
status$,
|
||||||
|
members$,
|
||||||
|
participation$: scope.behavior(
|
||||||
|
active$.pipe(map((active) => active?.participation ?? null)),
|
||||||
|
),
|
||||||
|
join: (participationOptions: RTCParticipationOptions): RTCParticipation => {
|
||||||
|
if (active$.value !== null)
|
||||||
|
throw new MatrixRTCError(
|
||||||
|
"Already in this slot: leave the participation before joining again",
|
||||||
|
);
|
||||||
|
const participationScope = new ObservableScope();
|
||||||
|
participationScopes.add(participationScope);
|
||||||
|
const internals = createRTCParticipation(
|
||||||
|
participationScope,
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
room,
|
||||||
|
slot,
|
||||||
|
jsSdkSession,
|
||||||
|
members$,
|
||||||
|
transports,
|
||||||
|
slotOptions: options,
|
||||||
|
logger,
|
||||||
|
},
|
||||||
|
participationOptions,
|
||||||
|
);
|
||||||
|
participationScope.onEnd(() => {
|
||||||
|
participationScopes.delete(participationScope);
|
||||||
|
if (active$.value === internals) active$.next(null);
|
||||||
|
});
|
||||||
|
active$.next(internals);
|
||||||
|
return internals.participation;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotStatus(
|
||||||
|
session: Pick<JsSdkRTCSession, "getRtcSlot">,
|
||||||
|
): "open" | "closed" | undefined {
|
||||||
|
return session.getRtcSlot()?.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The js-sdk keeps one session manager per slot. The client comes with the
|
||||||
|
* manager for the default slot; any other slot gets a manager of its own,
|
||||||
|
* which lives as long as the scope.
|
||||||
|
*/
|
||||||
|
function sessionManager(
|
||||||
|
scope: ObservableScope,
|
||||||
|
client: MatrixClient,
|
||||||
|
{ application, id }: SlotDescription,
|
||||||
|
logger: Logger,
|
||||||
|
): MatrixRTCSessionManager {
|
||||||
|
if (application === "m.call" && id === "ROOM") return client.matrixRTC;
|
||||||
|
const manager = new MatrixRTCSessionManager(logger, client, {
|
||||||
|
application,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
manager.start();
|
||||||
|
scope.onEnd(() => manager.stop());
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
+118
-90
@@ -6,13 +6,12 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The public types of the SDK: the client, its members and transports. What a
|
* The public types of the SDK: the slot, the participation, their members and
|
||||||
* member sends is in `media-api.ts`. `createMatrixRTCClient` in
|
* transports. What a member sends is in `media-api.ts`. `createRTCSlot` in
|
||||||
* `MatrixRTCClient.ts` is the only way to obtain an implementation of them.
|
* `RTCSlot.ts` is the only way to obtain an implementation of them.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type CallMembership,
|
|
||||||
type RTCNotificationType,
|
type RTCNotificationType,
|
||||||
type Transport,
|
type Transport,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
@@ -20,12 +19,12 @@ import { type Observable } from "rxjs";
|
|||||||
|
|
||||||
import { type Behavior } from "./reactive/Behavior";
|
import { type Behavior } from "./reactive/Behavior";
|
||||||
import {
|
import {
|
||||||
type AudioMediaTrack,
|
|
||||||
type EncryptionError,
|
|
||||||
type LocalAudioMediaTrack,
|
type LocalAudioMediaTrack,
|
||||||
|
type LocalMemberMedia,
|
||||||
type LocalVideoMediaTrack,
|
type LocalVideoMediaTrack,
|
||||||
|
type MediaSource,
|
||||||
|
type MemberMedia,
|
||||||
type PublishRequest,
|
type PublishRequest,
|
||||||
type VideoMediaTrack,
|
|
||||||
} from "./media-api";
|
} from "./media-api";
|
||||||
import { type MediaBackendFactory } from "./media-backend/api";
|
import { type MediaBackendFactory } from "./media-backend/api";
|
||||||
import { type EncryptionSystem } from "./encryption";
|
import { type EncryptionSystem } from "./encryption";
|
||||||
@@ -34,26 +33,72 @@ import {
|
|||||||
type MediaQuality,
|
type MediaQuality,
|
||||||
type SessionTimings,
|
type SessionTimings,
|
||||||
} from "./config";
|
} from "./config";
|
||||||
|
import { type MatrixRTCError } from "./errors";
|
||||||
|
|
||||||
|
export { MatrixRTCError } from "./errors";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// MatrixRTCClient
|
// Slot
|
||||||
|
|
||||||
export interface MatrixRTCClientOptions {
|
export interface RTCSlotOptions {
|
||||||
encryptionSystem: EncryptionSystem;
|
/** The application the slot belongs to, as named in the membership. Default `m.call`. */
|
||||||
/** Resolved by the host; the SDK reads neither config.json nor settings. */
|
application?: string;
|
||||||
|
/** The application's slot in the room. Default `ROOM`. */
|
||||||
|
id?: string;
|
||||||
|
/** How memberships and keys in this slot are written. Resolved by the host. */
|
||||||
matrixRTCMode: MatrixRTCMode;
|
matrixRTCMode: MatrixRTCMode;
|
||||||
/** Published at the join; `publish` on the local member adds to it from then on. */
|
encryptionSystem: EncryptionSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The namespace in a room that memberships join into. It exists while the
|
||||||
|
* room does, with nobody in it too. The room has to be the one the client's
|
||||||
|
* sync loop maintains (`client.getRoom(roomId)`, once the join has synced):
|
||||||
|
* the detached copy `joinRoom` returns for a room joined just now never
|
||||||
|
* receives the state the members are read from.
|
||||||
|
*/
|
||||||
|
export interface RTCSlot {
|
||||||
|
readonly roomId: string;
|
||||||
|
readonly application: string;
|
||||||
|
readonly id: string;
|
||||||
|
/** From the slot state event; undefined while the room has none. */
|
||||||
|
status$: Behavior<"open" | "closed" | undefined>;
|
||||||
|
/**
|
||||||
|
* Everyone in the slot, one object per membership, kept for as long as the
|
||||||
|
* membership is. Our own membership is in here too, with `local: true`,
|
||||||
|
* once the room has seen it. No media: that is the participation's.
|
||||||
|
*/
|
||||||
|
members$: Behavior<RTCMember[]>;
|
||||||
|
/** The participation we are in, null between `leave()` and the next `join()`. */
|
||||||
|
participation$: Behavior<RTCParticipation | null>;
|
||||||
|
/**
|
||||||
|
* Joins the slot and returns the participation that lasts until its
|
||||||
|
* `leave()`. Throws while `participation$` is not null: a slot has one local
|
||||||
|
* member.
|
||||||
|
*/
|
||||||
|
join(options: RTCParticipationOptions): RTCParticipation; // Maybe MediaSession,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Participation
|
||||||
|
|
||||||
|
export interface RTCParticipationOptions {
|
||||||
|
/**
|
||||||
|
* Published at the join; `publish` on the participation adds to it from then
|
||||||
|
* on. Kept as an option, rather than only the method, because the LiveKit
|
||||||
|
* backend reads the initial requests to build the room's default capture
|
||||||
|
* options before the first connection exists.
|
||||||
|
*/
|
||||||
publish: PublishRequest[];
|
publish: PublishRequest[];
|
||||||
/**
|
/**
|
||||||
|
* LEGACY
|
||||||
|
*
|
||||||
|
* Conside already removing it. just skips code in js-sdk. But rust sdk will not have this feature.
|
||||||
* MSC4075 notification sent with the join. A parameter of the MatrixRTC
|
* MSC4075 notification sent with the join. A parameter of the MatrixRTC
|
||||||
* join itself, so it is here even though it is named after calls; reacting
|
* join itself, so it is here even though it is named after calls; reacting
|
||||||
* to a notification (ringing, timeouts, declines) is the application's job.
|
* to a notification (ringing, timeouts, declines) is the application's job.
|
||||||
*/
|
*/
|
||||||
sendNotificationType?: RTCNotificationType;
|
sendNotificationType?: RTCNotificationType;
|
||||||
/** The application the session belongs to, as named in the membership. Default `m.call`. */
|
|
||||||
application?: string;
|
|
||||||
/** The application's slot in the room. Default `ROOM`. */
|
|
||||||
slot?: string;
|
|
||||||
/**
|
/**
|
||||||
* Whatever the application wants to say about itself in the membership,
|
* Whatever the application wants to say about itself in the membership,
|
||||||
* under namespaced keys. Opaque to the SDK, apart from `m.call.intent`
|
* under namespaced keys. Opaque to the SDK, apart from `m.call.intent`
|
||||||
@@ -61,7 +106,7 @@ export interface MatrixRTCClientOptions {
|
|||||||
* field of its own.
|
* field of its own.
|
||||||
*/
|
*/
|
||||||
applicationData?: Record<string, unknown>;
|
applicationData?: Record<string, unknown>;
|
||||||
/** Session timings the host has configured; the defaults otherwise. */
|
/** Timings the host has configured; the defaults otherwise. */
|
||||||
timings?: Partial<SessionTimings>;
|
timings?: Partial<SessionTimings>;
|
||||||
/** Limits on what is published; LiveKit's defaults otherwise. */
|
/** Limits on what is published; LiveKit's defaults otherwise. */
|
||||||
mediaQuality?: MediaQuality;
|
mediaQuality?: MediaQuality;
|
||||||
@@ -76,51 +121,58 @@ export interface MatrixRTCClientOptions {
|
|||||||
backend?: MediaBackendFactory;
|
backend?: MediaBackendFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectionStatus =
|
export type ParticipationStatus =
|
||||||
| "waitingForTransport"
|
| "waitingForTransport"
|
||||||
| "connecting"
|
| "connecting"
|
||||||
| "connected"
|
| "connected"
|
||||||
| "reconnecting"
|
| "reconnecting"
|
||||||
| "disconnected";
|
| "disconnected"
|
||||||
|
/** `leave()` was called; the participation is over and stays so. */
|
||||||
|
| "left";
|
||||||
|
|
||||||
/** Why the client is not connected: the first failing of its three links. */
|
/** Why the participation is not connected: the first failing of its three links. */
|
||||||
export type DisconnectReason = "sync" | "membership" | "probablyLeft" | "media";
|
export type DisconnectReason = "sync" | "membership" | "probablyLeft" | "media";
|
||||||
|
|
||||||
import { type MatrixRTCError } from "./errors";
|
/** The span between our `join()` of a slot and our `leave()`. */
|
||||||
|
export interface RTCParticipation {
|
||||||
export { MatrixRTCError } from "./errors";
|
readonly slot: RTCSlot;
|
||||||
|
/** Sends the leave, tears the connections down and ends the participation. */
|
||||||
/**
|
|
||||||
* A session in one room. The room has to be the one the client's sync loop
|
|
||||||
* maintains (`client.getRoom(roomId)`, once the join has synced): the
|
|
||||||
* detached copy `joinRoom` returns for a room joined just now never receives
|
|
||||||
* the state the members are read from.
|
|
||||||
*/
|
|
||||||
export interface MatrixRTCClient {
|
|
||||||
join(): void;
|
|
||||||
leave(): void;
|
leave(): void;
|
||||||
/** Collapsed view of the local member's state machine. */
|
/** Collapsed view of the local member's state machine. */
|
||||||
status$: Behavior<ConnectionStatus>;
|
status$: Behavior<ParticipationStatus>;
|
||||||
connected$: Behavior<boolean>;
|
connected$: Behavior<boolean>;
|
||||||
reconnecting$: Behavior<boolean>;
|
reconnecting$: Behavior<boolean>;
|
||||||
/** Null while connected, the first failing link otherwise. */
|
/** Null while connected, the first failing link otherwise. */
|
||||||
disconnectReason$: Behavior<DisconnectReason | null>;
|
disconnectReason$: Behavior<DisconnectReason | null>;
|
||||||
|
/** A transport, Matrix or connection error that stops the participation. */
|
||||||
/** A transport, Matrix or connection error that stops the session. */
|
|
||||||
fatalError$: Behavior<MatrixRTCError | null>;
|
fatalError$: Behavior<MatrixRTCError | null>;
|
||||||
|
|
||||||
|
/** Null until our own membership has been seen in the room. */
|
||||||
localMember$: Behavior<LocalRTCMember | null>;
|
localMember$: Behavior<LocalRTCMember | null>;
|
||||||
|
/**
|
||||||
|
* The slot's other members, each with the media this participation carries
|
||||||
|
* for it. A call view needs nothing but this and `localMember$` to render.
|
||||||
|
*/
|
||||||
remoteMembers$: Behavior<RemoteRTCMember[]>;
|
remoteMembers$: Behavior<RemoteRTCMember[]>;
|
||||||
/** `remoteMembers.length`, plus one for the local member once it exists. */
|
|
||||||
memberCount$: Behavior<number>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the session has grown large enough that MatrixRTC has stopped
|
* Publishes a source and resolves with its track, which also shows up in
|
||||||
|
* `localMember$.tracks$`. Works from the moment `join()` returns: before the
|
||||||
|
* transport is connected.
|
||||||
|
*/
|
||||||
|
publish(
|
||||||
|
request: PublishRequest,
|
||||||
|
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
|
||||||
|
/** Removes one of our publications; a screen share takes its audio with it. */
|
||||||
|
unpublish(source: MediaSource): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the slot has grown large enough that MatrixRTC has stopped
|
||||||
* rotating the media encryption key.
|
* rotating the media encryption key.
|
||||||
*/
|
*/
|
||||||
keyRotationSuppressed$: Behavior<boolean>;
|
keyRotationSuppressed$: Behavior<boolean>;
|
||||||
|
|
||||||
/** Transports the session currently holds a live connection to. */
|
/** Transports the participation currently holds a live connection to. */
|
||||||
connectedTransports$: Behavior<TransportMetadata[]>;
|
connectedTransports$: Behavior<TransportMetadata[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,9 +191,9 @@ export interface MatrixRTCClient {
|
|||||||
*/
|
*/
|
||||||
sendData(topic: string, text: string): Promise<void>;
|
sendData(topic: string, text: string): Promise<void>;
|
||||||
/**
|
/**
|
||||||
* What remote members sent with `sendData`, on every transport the client
|
* What remote members sent with `sendData`, on every transport the
|
||||||
* is connected to. A message from an identity that is not a member is
|
* participation is connected to. A message from an identity that is not a
|
||||||
* dropped, so a host only ever hears from attested members.
|
* member is dropped, so a host only ever hears from attested members.
|
||||||
*/
|
*/
|
||||||
data$: Observable<DataMessage>;
|
data$: Observable<DataMessage>;
|
||||||
}
|
}
|
||||||
@@ -164,7 +216,7 @@ export interface DataMessage {
|
|||||||
export interface TransportMetadata {
|
export interface TransportMetadata {
|
||||||
/** `"livekit"` today. */
|
/** `"livekit"` today. */
|
||||||
type: string;
|
type: string;
|
||||||
/** Stable key, unique per transport in the session: the raw transport, serialised with sorted keys. */
|
/** Stable key, unique per transport in the slot: the raw transport, serialised with sorted keys. */
|
||||||
id: string;
|
id: string;
|
||||||
/** The transport object as it appears in the membership. */
|
/** The transport object as it appears in the membership. */
|
||||||
raw: Transport;
|
raw: Transport;
|
||||||
@@ -190,63 +242,39 @@ export type ResolvedTransport =
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Members
|
// Members
|
||||||
|
|
||||||
/**
|
/** A membership in the slot, with what the SDK derives from it. No media. */
|
||||||
* What a membership says, as hosts read it. The js-sdk `CallMembership` is
|
|
||||||
* behind it; anything beyond this is the js-sdk's API, not the SDK's.
|
|
||||||
*/
|
|
||||||
export type RTCMembership = Pick<
|
|
||||||
CallMembership,
|
|
||||||
| "userId"
|
|
||||||
| "deviceId"
|
|
||||||
| "memberId"
|
|
||||||
| "rtcBackendIdentity"
|
|
||||||
| "application"
|
|
||||||
| "applicationData"
|
|
||||||
| "getTransport"
|
|
||||||
| "transports"
|
|
||||||
| "createdTs"
|
|
||||||
| "getAbsoluteExpiry"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface RTCMember {
|
export interface RTCMember {
|
||||||
local: boolean;
|
local: boolean;
|
||||||
/** The identity the media backend knows this member by. */
|
/**
|
||||||
id: string;
|
* The identity this member has on the media backend's transport: the
|
||||||
|
* participant identity the SFU sees, and the sender of its data packets. The
|
||||||
|
* SDK uses it to match what arrives on the transport back to the member; a
|
||||||
|
* host needs it only as a stable key per membership or as a debug label.
|
||||||
|
*
|
||||||
|
* Today it is read off the membership, where the js-sdk computes it:
|
||||||
|
* `${userId}:${deviceId}` before sticky events, a hash of `memberId` in
|
||||||
|
* Matrix 2.0 mode. Eventually the backend will derive it itself, so treat it
|
||||||
|
* as opaque.
|
||||||
|
*/
|
||||||
|
rtcBackendIdentity: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
membership$: Behavior<RTCMembership>;
|
/** The membership's own id; what the Matrix 2.0 identity is derived from. */
|
||||||
displayName$: Behavior<string>;
|
memberId: string;
|
||||||
avatarUrl$: Behavior<string | undefined>;
|
|
||||||
/** Which transport this member is on; undefined when the membership has none. */
|
/** Which transport this member is on; undefined when the membership has none. */
|
||||||
transport$: Behavior<TransportMetadata | undefined>;
|
transport$: Behavior<TransportMetadata | undefined>;
|
||||||
/**
|
/** What the member's application says about itself, e.g. `m.call.intent`. */
|
||||||
* The member's tracks, in publication order, once it has shown up on its
|
applicationData$: Behavior<Record<string, unknown>>;
|
||||||
* transport; null until then ("waiting for media"). An entry stays the same
|
|
||||||
* object for as long as the same publication is behind it. Which track is
|
displayName$: Behavior<string>;
|
||||||
* which is in its `source`; `trackBySource$` picks one out.
|
avatarUrl$: Behavior<string | undefined>;
|
||||||
*/
|
|
||||||
tracks$: Behavior<(AudioMediaTrack | VideoMediaTrack)[] | null>;
|
|
||||||
/** Emits when the SFU reports a key problem for this member. */
|
|
||||||
encryptionError$: Observable<EncryptionError>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteRTCMember extends RTCMember {
|
export interface RemoteRTCMember extends RTCMember, MemberMedia {
|
||||||
local: false;
|
local: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalRTCMember extends RTCMember {
|
/** Our member: the same as a remote one, with tracks that carry the controls. */
|
||||||
|
export interface LocalRTCMember extends RTCMember, LocalMemberMedia {
|
||||||
local: true;
|
local: true;
|
||||||
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>;
|
|
||||||
/**
|
|
||||||
* Publishes a source and resolves with its track once it is in `tracks$`.
|
|
||||||
* Before the transport is connected the request is remembered and applied
|
|
||||||
* once it is. Rejects where the device could not be used, including the
|
|
||||||
* user closing the picker. One publication per source: publishing a source
|
|
||||||
* again unmutes it.
|
|
||||||
*/
|
|
||||||
publish(
|
|
||||||
request: PublishRequest,
|
|
||||||
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
|
|
||||||
/** Removes one of our tracks; a screen share takes its audio with it. */
|
|
||||||
unpublish(id: string): Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,25 +46,22 @@ describe("createLocalMembership$", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
local.connectionState$.next(MediaConnectionState.Connected);
|
local.connectionState$.next(MediaConnectionState.Connected);
|
||||||
expect(membership.state$.value).toEqual({
|
|
||||||
media: PublishState.WaitingForUser,
|
|
||||||
matrix: Status.Connected,
|
|
||||||
});
|
|
||||||
|
|
||||||
membership.requestJoinAndPublish();
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(membership.state$.value).toEqual({
|
expect(membership.state$.value).toEqual({
|
||||||
media: PublishState.Publishing,
|
media: PublishState.Publishing,
|
||||||
matrix: Status.Connected,
|
matrix: Status.Connected,
|
||||||
});
|
});
|
||||||
expect(membership.connected$.value).toBe(true);
|
expect(membership.connected$.value).toBe(true);
|
||||||
|
|
||||||
|
membership.leave();
|
||||||
|
expect(membership.state$.value).toEqual({
|
||||||
|
media: PublishState.WaitingForUser,
|
||||||
|
matrix: Status.Connected,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("publishes only while joined and the homeserver is reachable", async () => {
|
it("publishes only while joined and the homeserver is reachable", async () => {
|
||||||
const { membership, local, homeserver } = setup();
|
const { membership, local, homeserver } = setup();
|
||||||
expect(local.setPublishing).toHaveBeenLastCalledWith(false);
|
|
||||||
|
|
||||||
membership.requestJoinAndPublish();
|
|
||||||
expect(local.setPublishing).toHaveBeenLastCalledWith(true);
|
expect(local.setPublishing).toHaveBeenLastCalledWith(true);
|
||||||
|
|
||||||
homeserver.combined$.next([false, "sync"]);
|
homeserver.combined$.next([false, "sync"]);
|
||||||
@@ -72,7 +69,7 @@ describe("createLocalMembership$", () => {
|
|||||||
homeserver.combined$.next([true, null]);
|
homeserver.combined$.next([true, null]);
|
||||||
expect(local.setPublishing).toHaveBeenLastCalledWith(true);
|
expect(local.setPublishing).toHaveBeenLastCalledWith(true);
|
||||||
|
|
||||||
membership.requestDisconnect();
|
membership.leave();
|
||||||
expect(local.setPublishing).toHaveBeenLastCalledWith(false);
|
expect(local.setPublishing).toHaveBeenLastCalledWith(false);
|
||||||
// A sync outage after leaving must not bring the media back
|
// A sync outage after leaving must not bring the media back
|
||||||
homeserver.combined$.next([false, "sync"]);
|
homeserver.combined$.next([false, "sync"]);
|
||||||
@@ -82,10 +79,8 @@ describe("createLocalMembership$", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("joins with the delegated timings and hands over each delay id where the backend can", async () => {
|
it("joins with the delegated timings and hands over each delay id where the backend can", async () => {
|
||||||
const { membership, prepared$, delayId$, joinMatrixRTC, delegate } =
|
const { prepared$, delayId$, joinMatrixRTC, delegate } = setup();
|
||||||
setup();
|
|
||||||
prepared$.next({ transport, canDelegateDelayedLeave: true });
|
prepared$.next({ transport, canDelegateDelayedLeave: true });
|
||||||
membership.requestJoinAndPublish();
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(joinMatrixRTC).toHaveBeenCalledWith(
|
expect(joinMatrixRTC).toHaveBeenCalledWith(
|
||||||
transport,
|
transport,
|
||||||
@@ -101,10 +96,8 @@ describe("createLocalMembership$", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("never hands the leave over where the backend cannot take it", async () => {
|
it("never hands the leave over where the backend cannot take it", async () => {
|
||||||
const { membership, prepared$, delayId$, joinMatrixRTC, delegate } =
|
const { prepared$, delayId$, joinMatrixRTC, delegate } = setup();
|
||||||
setup();
|
|
||||||
prepared$.next({ transport, canDelegateDelayedLeave: false });
|
prepared$.next({ transport, canDelegateDelayedLeave: false });
|
||||||
membership.requestJoinAndPublish();
|
|
||||||
delayId$.next("delay-1");
|
delayId$.next("delay-1");
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(joinMatrixRTC).toHaveBeenCalledWith(
|
expect(joinMatrixRTC).toHaveBeenCalledWith(
|
||||||
@@ -124,7 +117,6 @@ describe("createLocalMembership$", () => {
|
|||||||
const { membership, local, prepared$ } = setup();
|
const { membership, local, prepared$ } = setup();
|
||||||
prepared$.next({ transport, canDelegateDelayedLeave: false });
|
prepared$.next({ transport, canDelegateDelayedLeave: false });
|
||||||
local.connectionState$.next(MediaConnectionState.Connected);
|
local.connectionState$.next(MediaConnectionState.Connected);
|
||||||
membership.requestJoinAndPublish();
|
|
||||||
|
|
||||||
local.connectionState$.next(MediaConnectionState.Reconnecting);
|
local.connectionState$.next(MediaConnectionState.Reconnecting);
|
||||||
expect(membership.state$.value).toEqual({
|
expect(membership.state$.value).toEqual({
|
||||||
|
|||||||
@@ -89,9 +89,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalMembership {
|
export interface LocalMembership {
|
||||||
requestJoinAndPublish: () => void;
|
/** Sends the leave and stops publishing. Final: there is no joining again. */
|
||||||
requestDisconnect: () => void;
|
leave: () => void;
|
||||||
joinRequested$: Behavior<boolean>;
|
|
||||||
state$: Behavior<LocalMemberState>;
|
state$: Behavior<LocalMemberState>;
|
||||||
/** Fully connected: to the homeserver, the session and the transport. */
|
/** Fully connected: to the homeserver, the session and the transport. */
|
||||||
connected$: Behavior<boolean>;
|
connected$: Behavior<boolean>;
|
||||||
@@ -101,8 +100,8 @@ export interface LocalMembership {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The local member's state machine: waits for the transport, publishes once
|
* The local member's state machine: waits for the transport, then publishes
|
||||||
* asked to join, and enters and leaves the MatrixRTC session in step.
|
* and enters the MatrixRTC session, and leaves both in step on `leave()`.
|
||||||
*/
|
*/
|
||||||
export function createLocalMembership$({
|
export function createLocalMembership$({
|
||||||
scope,
|
scope,
|
||||||
@@ -131,22 +130,22 @@ export function createLocalMembership$({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const joinRequested$ = new BehaviorSubject(false);
|
const joined$ = new BehaviorSubject(true);
|
||||||
const matrixError$ = new BehaviorSubject<MatrixRTCError | null>(null);
|
const matrixError$ = new BehaviorSubject<MatrixRTCError | null>(null);
|
||||||
|
|
||||||
// Nothing leaves this device while it may already have been dropped from
|
// Nothing leaves this device while it may already have been dropped from
|
||||||
// the session: the member would show as away while still being heard
|
// the session: the member would show as away while still being heard
|
||||||
combineLatest(
|
combineLatest(
|
||||||
[joinRequested$, homeserverConnected.combined$],
|
[joined$, homeserverConnected.combined$],
|
||||||
(join, [connected]) => join && connected,
|
(joined, [connected]) => joined && connected,
|
||||||
)
|
)
|
||||||
.pipe(distinctUntilChanged(), scope.bind())
|
.pipe(distinctUntilChanged(), scope.bind())
|
||||||
.subscribe((publish) => local.setPublishing(publish));
|
.subscribe((publish) => local.setPublishing(publish));
|
||||||
|
|
||||||
scope.reconcile(
|
scope.reconcile(
|
||||||
scope.behavior(combineLatest([transport$, joinRequested$])),
|
scope.behavior(combineLatest([transport$, joined$])),
|
||||||
async ([prepared, shouldJoin]) => {
|
async ([prepared, joined]) => {
|
||||||
if (prepared === null || !shouldJoin) return;
|
if (prepared === null || !joined) return;
|
||||||
try {
|
try {
|
||||||
joinMatrixRTC(
|
joinMatrixRTC(
|
||||||
prepared.transport,
|
prepared.transport,
|
||||||
@@ -191,8 +190,8 @@ export function createLocalMembership$({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mediaState$ = scope.behavior<LocalMemberMediaState>(
|
const mediaState$ = scope.behavior<LocalMemberMediaState>(
|
||||||
combineLatest([local.connectionState$, joinRequested$]).pipe(
|
combineLatest([local.connectionState$, joined$]).pipe(
|
||||||
map(([connectionState, shouldPublish]) => {
|
map(([connectionState, joined]) => {
|
||||||
if (connectionState !== MediaConnectionState.Connected)
|
if (connectionState !== MediaConnectionState.Connected)
|
||||||
return {
|
return {
|
||||||
connection:
|
connection:
|
||||||
@@ -200,9 +199,7 @@ export function createLocalMembership$({
|
|||||||
? toMatrixRTCError(connectionState)
|
? toMatrixRTCError(connectionState)
|
||||||
: connectionState,
|
: connectionState,
|
||||||
};
|
};
|
||||||
return shouldPublish
|
return joined ? PublishState.Publishing : PublishState.WaitingForUser;
|
||||||
? PublishState.Publishing
|
|
||||||
: PublishState.WaitingForUser;
|
|
||||||
}),
|
}),
|
||||||
distinctUntilChanged(deepCompare),
|
distinctUntilChanged(deepCompare),
|
||||||
),
|
),
|
||||||
@@ -260,9 +257,7 @@ export function createLocalMembership$({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requestJoinAndPublish: () => joinRequested$.next(true),
|
leave: () => joined$.next(false),
|
||||||
requestDisconnect: () => joinRequested$.next(false),
|
|
||||||
joinRequested$,
|
|
||||||
state$,
|
state$,
|
||||||
connected$,
|
connected$,
|
||||||
reconnecting$,
|
reconnecting$,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { type MatrixClient } from "matrix-js-sdk";
|
|||||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
import { type MatrixRTCClientOptions } from "../api";
|
import { type RTCParticipationOptions } from "../api";
|
||||||
import { MatrixRTCTransportMissingError } from "../errors";
|
import { MatrixRTCTransportMissingError } from "../errors";
|
||||||
import { TransportAutoDiscovery } from "./TransportAutoDiscovery";
|
import { TransportAutoDiscovery } from "./TransportAutoDiscovery";
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ export async function discoverLocalTransport(
|
|||||||
{
|
{
|
||||||
transportUrl,
|
transportUrl,
|
||||||
fallbackTransportUrl,
|
fallbackTransportUrl,
|
||||||
}: Pick<MatrixRTCClientOptions, "transportUrl" | "fallbackTransportUrl">,
|
}: Pick<RTCParticipationOptions, "transportUrl" | "fallbackTransportUrl">,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
): Promise<Transport> {
|
): Promise<Transport> {
|
||||||
const transport = transportUrl
|
const transport = transportUrl
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||||
import { distinctUntilChanged, map } from "rxjs";
|
import { distinctUntilChanged, map } from "rxjs";
|
||||||
|
|
||||||
import { type Behavior } from "../reactive/Behavior";
|
import { type Behavior } from "../reactive/Behavior";
|
||||||
@@ -16,16 +17,15 @@ import {
|
|||||||
type RemoteRTCMember,
|
type RemoteRTCMember,
|
||||||
type RTCMember,
|
type RTCMember,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import {
|
import { type LocalMemberMedia, type MemberMedia } from "../media-api";
|
||||||
type LocalMediaBackend,
|
|
||||||
type MemberMediaFields,
|
|
||||||
} from "../media-backend/api";
|
|
||||||
import { type TransportRegistry } from "./Transports";
|
import { type TransportRegistry } from "./Transports";
|
||||||
|
|
||||||
/** What every member is built from, besides its own membership and media. */
|
/** What every member is built from, besides its own membership. */
|
||||||
export interface MemberContext {
|
export interface MemberContext {
|
||||||
metadata: ReturnType<typeof createMatrixMemberMetadata$>;
|
metadata: ReturnType<typeof createMatrixMemberMetadata$>;
|
||||||
transports: TransportRegistry;
|
transports: TransportRegistry;
|
||||||
|
/** Whose memberships are `local`. */
|
||||||
|
own: { userId: string; deviceId: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Everything that tells one membership from another, for keying items. */
|
/** Everything that tells one membership from another, for keying items. */
|
||||||
@@ -40,50 +40,21 @@ export function membershipKeys(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRemoteRTCMember(
|
/** The slot's view of a membership: identity, room state and transport, no media. */
|
||||||
|
export function createRTCMember(
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
membership$: Behavior<CallMembership>,
|
membership$: Behavior<CallMembership>,
|
||||||
media: MemberMediaFields,
|
{ metadata, transports, own }: MemberContext,
|
||||||
context: MemberContext,
|
): RTCMember {
|
||||||
): RemoteRTCMember {
|
const { userId, deviceId, memberId, rtcBackendIdentity } = membership$.value;
|
||||||
return {
|
return {
|
||||||
...createRTCMember(scope, membership$, context),
|
local: userId === own.userId && deviceId === own.deviceId,
|
||||||
local: false,
|
// Read off the membership for now; the backend will derive it once the
|
||||||
tracks$: media.tracks$,
|
// identity scheme is its own and not the js-sdk's
|
||||||
encryptionError$: media.encryptionError$,
|
rtcBackendIdentity,
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createLocalRTCMember(
|
|
||||||
scope: ObservableScope,
|
|
||||||
membership$: Behavior<CallMembership>,
|
|
||||||
local: Pick<
|
|
||||||
LocalMediaBackend,
|
|
||||||
"tracks$" | "encryptionError$" | "publish" | "unpublish"
|
|
||||||
>,
|
|
||||||
context: MemberContext,
|
|
||||||
): LocalRTCMember {
|
|
||||||
return {
|
|
||||||
...createRTCMember(scope, membership$, context),
|
|
||||||
local: true,
|
|
||||||
tracks$: local.tracks$,
|
|
||||||
encryptionError$: local.encryptionError$,
|
|
||||||
publish: async (request) => local.publish(request),
|
|
||||||
unpublish: async (id) => local.unpublish(id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRTCMember(
|
|
||||||
scope: ObservableScope,
|
|
||||||
membership$: Behavior<CallMembership>,
|
|
||||||
{ metadata, transports }: MemberContext,
|
|
||||||
): Omit<RTCMember, "local" | "tracks$" | "encryptionError$"> {
|
|
||||||
const { userId, deviceId, rtcBackendIdentity } = membership$.value;
|
|
||||||
return {
|
|
||||||
id: rtcBackendIdentity,
|
|
||||||
userId,
|
userId,
|
||||||
deviceId,
|
deviceId,
|
||||||
membership$,
|
memberId,
|
||||||
displayName$: scope.behavior(
|
displayName$: scope.behavior(
|
||||||
metadata
|
metadata
|
||||||
.createDisplayNameBehavior$(scope, userId)
|
.createDisplayNameBehavior$(scope, userId)
|
||||||
@@ -97,5 +68,26 @@ function createRTCMember(
|
|||||||
map((transport) => transport && transports.get(transport)),
|
map((transport) => transport && transports.get(transport)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
applicationData$: scope.behavior(
|
||||||
|
membership$.pipe(
|
||||||
|
map((membership) => membership.applicationData),
|
||||||
|
distinctUntilChanged(deepCompare),
|
||||||
|
),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The participation's view of a remote member: the slot's member plus its media. */
|
||||||
|
export function createRemoteRTCMember(
|
||||||
|
member: RTCMember,
|
||||||
|
media: MemberMedia,
|
||||||
|
): RemoteRTCMember {
|
||||||
|
return { ...member, local: false, ...media };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLocalRTCMember(
|
||||||
|
member: RTCMember,
|
||||||
|
media: LocalMemberMedia,
|
||||||
|
): LocalRTCMember {
|
||||||
|
return { ...member, local: true, ...media };
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
PublishState,
|
PublishState,
|
||||||
TransportState,
|
TransportState,
|
||||||
} from "./LocalMember";
|
} from "./LocalMember";
|
||||||
import { fatalError, sessionStatus } from "./status";
|
import { fatalError, participationStatus } from "./status";
|
||||||
|
|
||||||
const publishing: LocalMemberState = {
|
const publishing: LocalMemberState = {
|
||||||
media: PublishState.Publishing,
|
media: PublishState.Publishing,
|
||||||
@@ -27,31 +27,39 @@ const connecting: LocalMemberState = {
|
|||||||
};
|
};
|
||||||
const error = new MatrixRTCError("gone");
|
const error = new MatrixRTCError("gone");
|
||||||
|
|
||||||
describe("sessionStatus", () => {
|
describe("participationStatus", () => {
|
||||||
it.each<[string, LocalMemberState, boolean, boolean, boolean, string]>([
|
it.each<[string, LocalMemberState, boolean, boolean, boolean, string]>([
|
||||||
[
|
[
|
||||||
"no transport yet",
|
"no transport yet",
|
||||||
TransportState.Waiting,
|
TransportState.Waiting,
|
||||||
true,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
"waitingForTransport",
|
"waitingForTransport",
|
||||||
],
|
],
|
||||||
["not asked to join", connecting, false, false, false, "disconnected"],
|
["joining", connecting, false, false, false, "connecting"],
|
||||||
["joining", connecting, true, false, false, "connecting"],
|
["connected", publishing, true, false, false, "connected"],
|
||||||
["connected", publishing, true, true, false, "connected"],
|
[
|
||||||
["dropped after connecting", connecting, true, false, true, "reconnecting"],
|
"dropped after connecting",
|
||||||
["a transport error", error, true, false, false, "disconnected"],
|
connecting,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
"reconnecting",
|
||||||
|
],
|
||||||
|
["a transport error", error, false, false, false, "disconnected"],
|
||||||
[
|
[
|
||||||
"a matrix error",
|
"a matrix error",
|
||||||
{ ...publishing, matrix: error },
|
{ ...publishing, matrix: error },
|
||||||
true,
|
true,
|
||||||
true,
|
false,
|
||||||
false,
|
false,
|
||||||
"disconnected",
|
"disconnected",
|
||||||
],
|
],
|
||||||
])("%s", (_name, state, joinRequested, connected, reconnecting, expected) => {
|
["left", publishing, true, false, true, "left"],
|
||||||
expect(sessionStatus(state, joinRequested, connected, reconnecting)).toBe(
|
["left after an error", error, false, false, true, "left"],
|
||||||
|
])("%s", (_name, state, connected, reconnecting, left, expected) => {
|
||||||
|
expect(participationStatus(state, connected, reconnecting, left)).toBe(
|
||||||
expected,
|
expected,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,24 +5,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type MatrixRTCError, type ConnectionStatus } from "../api";
|
import { type MatrixRTCError, type ParticipationStatus } from "../api";
|
||||||
import { type LocalMemberState, TransportState } from "./LocalMember";
|
import { type LocalMemberState, TransportState } from "./LocalMember";
|
||||||
|
|
||||||
/** The local member's state, collapsed to what a host shows. */
|
/** The local member's state, collapsed to what a host shows. */
|
||||||
export function sessionStatus(
|
export function participationStatus(
|
||||||
state: LocalMemberState,
|
state: LocalMemberState,
|
||||||
joinRequested: boolean,
|
|
||||||
connected: boolean,
|
connected: boolean,
|
||||||
reconnecting: boolean,
|
reconnecting: boolean,
|
||||||
): ConnectionStatus {
|
left: boolean,
|
||||||
|
): ParticipationStatus {
|
||||||
|
if (left) return "left";
|
||||||
if (fatalError(state) !== null) return "disconnected";
|
if (fatalError(state) !== null) return "disconnected";
|
||||||
if (state === TransportState.Waiting) return "waitingForTransport";
|
if (state === TransportState.Waiting) return "waitingForTransport";
|
||||||
if (connected) return "connected";
|
if (connected) return "connected";
|
||||||
if (reconnecting) return "reconnecting";
|
if (reconnecting) return "reconnecting";
|
||||||
return joinRequested ? "connecting" : "disconnected";
|
return "connecting";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The error that stops the session, if the state holds one. */
|
/** The error that stops the participation, if the state holds one. */
|
||||||
export function fatalError(state: LocalMemberState): MatrixRTCError | null {
|
export function fatalError(state: LocalMemberState): MatrixRTCError | null {
|
||||||
if (state === TransportState.Waiting) return null;
|
if (state === TransportState.Waiting) return null;
|
||||||
if (state instanceof Error) return state;
|
if (state instanceof Error) return state;
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
type TrackProcessor,
|
type TrackProcessor,
|
||||||
type VideoCodec,
|
type VideoCodec,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
|
import { type Observable } from "rxjs";
|
||||||
|
|
||||||
import { type Behavior } from "./reactive/Behavior";
|
import { type Behavior } from "./reactive/Behavior";
|
||||||
|
|
||||||
export interface AudioCaptureSettings {
|
export interface AudioCaptureSettings {
|
||||||
@@ -42,6 +44,11 @@ export type PublishRequest =
|
|||||||
source: "camera";
|
source: "camera";
|
||||||
deviceId?: string;
|
deviceId?: string;
|
||||||
/** Background blur and the like. */
|
/** Background blur and the like. */
|
||||||
|
// REVIEW: this is ugly makes us need to import lk
|
||||||
|
// We need an interface:
|
||||||
|
// - super high level: blur + background img
|
||||||
|
// - lower level: rtc stream insertable stream (copy lk TrackProcessor concept)
|
||||||
|
// - florian: should be independent of webCodec or WebRtc
|
||||||
processor?: TrackProcessor<Track.Kind.Video>;
|
processor?: TrackProcessor<Track.Kind.Video>;
|
||||||
capture?: VideoCaptureSettings;
|
capture?: VideoCaptureSettings;
|
||||||
}
|
}
|
||||||
@@ -73,8 +80,14 @@ export interface MediaTrack {
|
|||||||
id: string;
|
id: string;
|
||||||
muted$: Behavior<boolean>;
|
muted$: Behavior<boolean>;
|
||||||
/** False when the SFU reports the track as unencrypted. */
|
/** False when the SFU reports the track as unencrypted. */
|
||||||
|
// REVIEW: does it need to be a behavior
|
||||||
encrypted$: Behavior<boolean>;
|
encrypted$: Behavior<boolean>;
|
||||||
/** Polled while subscribed. */
|
/** Polled while subscribed. */
|
||||||
|
// REVIEW: we want the perf gain of not having it always run.
|
||||||
|
//
|
||||||
|
// proposals
|
||||||
|
// - obs -> on subscribe start polling timer
|
||||||
|
// - add public method the MediaTrack: for example: actiavteStatsPolling(ms)
|
||||||
stats$: Behavior<MediaStreamStats>;
|
stats$: Behavior<MediaStreamStats>;
|
||||||
/**
|
/**
|
||||||
* Rendering. The view hands its `<video>` or `<audio>` element over; the SDK
|
* Rendering. The view hands its `<video>` or `<audio>` element over; the SDK
|
||||||
@@ -95,6 +108,7 @@ export interface AudioMediaTrack extends MediaTrack {
|
|||||||
* "speaking". LiveKit measures it per member, so every audio track of a
|
* "speaking". LiveKit measures it per member, so every audio track of a
|
||||||
* member reports the same value.
|
* member reports the same value.
|
||||||
*/
|
*/
|
||||||
|
// REVIEW: audio Level (could even super-seed isActive "audioLevel != 0")
|
||||||
isActive$: Behavior<boolean>;
|
isActive$: Behavior<boolean>;
|
||||||
/** Route playback through Web Audio, for earpiece pan and gain. Undefined resets. */
|
/** Route playback through Web Audio, for earpiece pan and gain. Undefined resets. */
|
||||||
setAudioContext(ctx: AudioContext | undefined, plugins?: AudioNode[]): void;
|
setAudioContext(ctx: AudioContext | undefined, plugins?: AudioNode[]): void;
|
||||||
@@ -135,3 +149,24 @@ export interface LocalVideoMediaTrack extends VideoMediaTrack, LocalMediaTrack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type EncryptionError = "MissingKey" | "InvalidKey";
|
export type EncryptionError = "MissingKey" | "InvalidKey";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a member sends, once it has arrived on its transport: its tracks, null
|
||||||
|
* while nothing has arrived for it, and the key errors beside them. The media
|
||||||
|
* backend supplies exactly these two fields per member.
|
||||||
|
*/
|
||||||
|
export interface MemberMedia {
|
||||||
|
/**
|
||||||
|
* The member's tracks, in publication order, once it has shown up on its
|
||||||
|
* transport; null until then ("waiting for media"). An entry stays the same
|
||||||
|
* object for as long as the same publication is behind it. Which track is
|
||||||
|
* which is in its `source`; `trackBySource$` picks one out.
|
||||||
|
*/
|
||||||
|
tracks$: Behavior<(AudioMediaTrack | VideoMediaTrack)[] | null>;
|
||||||
|
/** Emits when the SFU reports a key problem for this member. */
|
||||||
|
encryptionError$: Observable<EncryptionError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalMemberMedia extends MemberMedia {
|
||||||
|
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,15 +17,13 @@ import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
|||||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
import { type Observable } from "rxjs";
|
import { type Observable } from "rxjs";
|
||||||
|
|
||||||
import {
|
import { type ResolvedTransport, type RTCMember } from "../api";
|
||||||
type LocalRTCMember,
|
|
||||||
type ResolvedTransport,
|
|
||||||
type RTCMember,
|
|
||||||
type RTCMembership,
|
|
||||||
} from "../api";
|
|
||||||
import {
|
import {
|
||||||
type LocalAudioMediaTrack,
|
type LocalAudioMediaTrack,
|
||||||
|
type LocalMemberMedia,
|
||||||
type LocalVideoMediaTrack,
|
type LocalVideoMediaTrack,
|
||||||
|
type MediaSource,
|
||||||
|
type MemberMedia,
|
||||||
type PublishRequest,
|
type PublishRequest,
|
||||||
} from "../media-api";
|
} from "../media-api";
|
||||||
import { type SessionTimings } from "../config";
|
import { type SessionTimings } from "../config";
|
||||||
@@ -53,13 +51,6 @@ export interface MediaKey {
|
|||||||
key: Uint8Array<ArrayBuffer>;
|
key: Uint8Array<ArrayBuffer>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The fields of a member the backend supplies, as the member exposes them. */
|
|
||||||
export type MemberMediaFields = Pick<RTCMember, "tracks$" | "encryptionError$">;
|
|
||||||
export type LocalMemberMediaFields = Pick<
|
|
||||||
LocalRTCMember,
|
|
||||||
"tracks$" | "encryptionError$"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface MediaBackendContext {
|
export interface MediaBackendContext {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||||
@@ -144,15 +135,12 @@ export interface MediaBackend {
|
|||||||
/**
|
/**
|
||||||
* The media of one remote member: its tracks, null while nothing has
|
* The media of one remote member: its tracks, null while nothing has
|
||||||
* arrived for it on its transport, and its key errors. Built in `scope`,
|
* arrived for it on its transport, and its key errors. Built in `scope`,
|
||||||
* which the caller ends with the member. This is also how the backend
|
* which the caller ends with the member or the participation, whichever
|
||||||
* learns which remote transports exist: it follows the membership's
|
* goes first. This is also how the backend learns which remote transports
|
||||||
* transport for as long as `scope` lives, and connects to every transport
|
* exist: it follows the member's `transport$` for as long as `scope` lives,
|
||||||
* some member is on.
|
* and connects to every transport some member is on.
|
||||||
*/
|
*/
|
||||||
mediaFor$(
|
mediaFor(scope: ObservableScope, member: RTCMember): MemberMedia;
|
||||||
scope: ObservableScope,
|
|
||||||
membership$: Behavior<RTCMembership>,
|
|
||||||
): MemberMediaFields;
|
|
||||||
|
|
||||||
/** Every connection the backend holds. For debugging and devtool purposes.*/
|
/** Every connection the backend holds. For debugging and devtool purposes.*/
|
||||||
readonly connections$: Behavior<BackendConnection[]>;
|
readonly connections$: Behavior<BackendConnection[]>;
|
||||||
@@ -175,7 +163,7 @@ export interface MediaBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** `tracks$` is null until the connection carries a local participant. */
|
/** `tracks$` is null until the connection carries a local participant. */
|
||||||
export interface LocalMediaBackend extends LocalMemberMediaFields {
|
export interface LocalMediaBackend extends LocalMemberMedia {
|
||||||
/**
|
/**
|
||||||
* State of the connection the local member publishes on. `Initialized`
|
* State of the connection the local member publishes on. `Initialized`
|
||||||
* while there is none; an `Error` is the connection's failure.
|
* while there is none; an `Error` is the connection's failure.
|
||||||
@@ -201,6 +189,6 @@ export interface LocalMediaBackend extends LocalMemberMediaFields {
|
|||||||
publish(
|
publish(
|
||||||
request: PublishRequest,
|
request: PublishRequest,
|
||||||
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
|
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
|
||||||
/** Removes one of our tracks by id; a screen share takes its audio with it. */
|
/** Removes one of our publications; a screen share takes its audio with it. */
|
||||||
unpublish(id: string): Promise<void>;
|
unpublish(source: MediaSource): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,12 @@ import {
|
|||||||
switchMap,
|
switchMap,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import { type ResolvedTransport, type RTCMembership } from "../../api";
|
import { type ResolvedTransport, type RTCMember } from "../../api";
|
||||||
|
|
||||||
import { type MediaQuality } from "../../config";
|
import { type MediaQuality } from "../../config";
|
||||||
import { MatrixRTCError } from "../../errors";
|
import { MatrixRTCError } from "../../errors";
|
||||||
import { type Behavior } from "../../reactive/Behavior";
|
import { type Behavior } from "../../reactive/Behavior";
|
||||||
|
import { type MemberMedia } from "../../media-api";
|
||||||
import {
|
import {
|
||||||
type Epoch,
|
type Epoch,
|
||||||
type ObservableScope,
|
type ObservableScope,
|
||||||
@@ -37,7 +38,6 @@ import {
|
|||||||
type BackendConnection,
|
type BackendConnection,
|
||||||
type MediaBackend,
|
type MediaBackend,
|
||||||
type MediaBackendContext,
|
type MediaBackendContext,
|
||||||
type MemberMediaFields,
|
|
||||||
type TransportCapabilities,
|
type TransportCapabilities,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { type Connection } from "./Connection";
|
import { type Connection } from "./Connection";
|
||||||
@@ -147,14 +147,14 @@ export function createLivekitBackend(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const mediaFor$ = (
|
const mediaFor = (
|
||||||
memberScope: ObservableScope,
|
memberScope: ObservableScope,
|
||||||
membership$: Behavior<RTCMembership>,
|
member: RTCMember,
|
||||||
): MemberMediaFields => {
|
): MemberMedia => {
|
||||||
const identity = membership$.value.rtcBackendIdentity;
|
const identity = member.rtcBackendIdentity;
|
||||||
const transport$ = memberScope.behavior(
|
const transport$ = memberScope.behavior(
|
||||||
membership$.pipe(
|
member.transport$.pipe(
|
||||||
map((membership) => membership.getTransport()),
|
map((transport) => transport?.raw),
|
||||||
map((transport) =>
|
map((transport) =>
|
||||||
isLivekitTransport(transport) ? transport : undefined,
|
isLivekitTransport(transport) ? transport : undefined,
|
||||||
),
|
),
|
||||||
@@ -224,7 +224,7 @@ export function createLivekitBackend(
|
|||||||
prepareLocalTransport,
|
prepareLocalTransport,
|
||||||
delegateDelayedLeave,
|
delegateDelayedLeave,
|
||||||
local: localMedia_,
|
local: localMedia_,
|
||||||
mediaFor$,
|
mediaFor,
|
||||||
connections$,
|
connections$,
|
||||||
setAudioOutputDeviceId: localMedia_.setAudioOutputDeviceId,
|
setAudioOutputDeviceId: localMedia_.setAudioOutputDeviceId,
|
||||||
sendData,
|
sendData,
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ import { generateItems } from "../../reactive/observable";
|
|||||||
import { E2eeType, type EncryptionSystem } from "../../encryption";
|
import { E2eeType, type EncryptionSystem } from "../../encryption";
|
||||||
import { type Behavior } from "../../reactive/Behavior";
|
import { type Behavior } from "../../reactive/Behavior";
|
||||||
import { mapScoped } from "../../utils/mapScoped";
|
import { mapScoped } from "../../utils/mapScoped";
|
||||||
import { type EncryptionError, type MediaSource } from "../../media-api";
|
import {
|
||||||
import { type LocalMemberMediaFields, type MemberMediaFields } from "../api";
|
type EncryptionError,
|
||||||
|
type LocalMemberMedia,
|
||||||
|
type MediaSource,
|
||||||
|
type MemberMedia,
|
||||||
|
} from "../../media-api";
|
||||||
import {
|
import {
|
||||||
createLivekitMediaTrack,
|
createLivekitMediaTrack,
|
||||||
createLocalLivekitMediaTrack,
|
createLocalLivekitMediaTrack,
|
||||||
@@ -45,7 +49,7 @@ export function createLivekitMemberMedia(
|
|||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
source$: Behavior<ParticipantSource>,
|
source$: Behavior<ParticipantSource>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
): MemberMediaFields {
|
): MemberMedia {
|
||||||
return {
|
return {
|
||||||
tracks$: memberTracks$(
|
tracks$: memberTracks$(
|
||||||
scope,
|
scope,
|
||||||
@@ -62,7 +66,7 @@ export function createLocalLivekitMemberMedia(
|
|||||||
source$: Behavior<ParticipantSource<LocalParticipant>>,
|
source$: Behavior<ParticipantSource<LocalParticipant>>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
setEnabled: (source: MediaSource, enabled: boolean) => Promise<boolean>,
|
setEnabled: (source: MediaSource, enabled: boolean) => Promise<boolean>,
|
||||||
): LocalMemberMediaFields {
|
): LocalMemberMedia {
|
||||||
return {
|
return {
|
||||||
tracks$: memberTracks$(
|
tracks$: memberTracks$(
|
||||||
scope,
|
scope,
|
||||||
|
|||||||
@@ -191,14 +191,11 @@ export function createLivekitLocalMedia({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const unpublish = async (id: string): Promise<void> => {
|
const unpublish = async (source: MediaSource): Promise<void> => {
|
||||||
const track = tracks$.value?.find((t) => t.id === id);
|
const published = publishedSource(source);
|
||||||
if (track === undefined) return;
|
if (published === undefined) return;
|
||||||
// The screen share audio goes with its video
|
desired.delete(published);
|
||||||
const source =
|
await publisher$.value?.unpublish(published);
|
||||||
track.source === "screenShareAudio" ? "screenShare" : track.source;
|
|
||||||
desired.delete(source);
|
|
||||||
await publisher$.value?.unpublish(source);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const setAudioOutputDeviceId = async (deviceId: string): Promise<void> => {
|
const setAudioOutputDeviceId = async (deviceId: string): Promise<void> => {
|
||||||
@@ -268,3 +265,17 @@ function trackWithId$(
|
|||||||
filter((track) => track !== undefined),
|
filter((track) => track !== undefined),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The publication a source belongs to: the screen share audio goes with its video. */
|
||||||
|
function publishedSource(
|
||||||
|
source: MediaSource,
|
||||||
|
): PublishRequest["source"] | undefined {
|
||||||
|
switch (source) {
|
||||||
|
case "screenShareAudio":
|
||||||
|
return "screenShare";
|
||||||
|
case "unknown":
|
||||||
|
return undefined;
|
||||||
|
default:
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user