mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
sdk-target-based-on-call-view-model client -> slot/participation
This commit is contained in:
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
> Superseded by the `sdk/` package (`@element-hq/matrixrtc-sdk`) and its harness in
|
> Superseded by the `sdk/` package (`@element-hq/matrixrtc-sdk`) and its harness in
|
||||||
> `sdk/dev`; see `sdk/SdkArchitecture.md`. This bundle is in production use, so it
|
> `sdk/dev`; see `sdk/SdkArchitecture.md`. This bundle is in production use, so it
|
||||||
> keeps building as a thin layer over that package, with its interface unchanged,
|
> keeps building as a thin layer over that package until its consumers have moved
|
||||||
> until its consumers have moved to the package directly. It will then be removed.
|
> to the package directly. It will then be removed. Its members are the package's
|
||||||
|
> `RTCMember`s now, in place of its own wrapper around the raw membership.
|
||||||
|
|
||||||
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
|
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2025 Element Creations Ltd.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
||||||
Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This file contains helper functions and types for the legacy SDK bundle.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
|
||||||
|
|
||||||
import { type WidgetHelpers } from "../src/widget";
|
|
||||||
|
|
||||||
export const tryMakeSticky = (widget: WidgetHelpers): void => {
|
|
||||||
const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
|
||||||
logger.info("try making sticky MatrixRTCSdk");
|
|
||||||
void widget.api
|
|
||||||
.setAlwaysOnScreen(true)
|
|
||||||
.then(() => {
|
|
||||||
logger.info("sticky MatrixRTCSdk");
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
logger.error("failed to make sticky MatrixRTCSdk", error);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
/** The data channel topic this bundle has always used, so that old and new builds talk. */
|
|
||||||
export const TEXT_LK_TOPIC = "matrixRTC";
|
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
div.innerHTML = "<h3>Members:</h3>";
|
div.innerHTML = "<h3>Members:</h3>";
|
||||||
|
|
||||||
// Create member list
|
// Create member list
|
||||||
const members = memberObjects.map((member) => member.membership.sender);
|
const members = memberObjects.map((member) => member.userId);
|
||||||
console.info("members changed", members);
|
console.info("members changed", members);
|
||||||
for (const m of members) {
|
for (const m of members) {
|
||||||
console.info("member", m);
|
console.info("member", m);
|
||||||
|
|||||||
@@ -8,125 +8,80 @@ Please see LICENSE in the repository root for full details.
|
|||||||
/**
|
/**
|
||||||
* LEGACY, in production use
|
* LEGACY, in production use
|
||||||
*
|
*
|
||||||
* This file is the entrypoint of the legacy SDK bundle,
|
* The entrypoint of `pnpm build:sdk-target-based-on-call-view-model`, a
|
||||||
* `pnpm build:sdk-target-based-on-call-view-model`, for use in widgets. It
|
* bundle for widgets that exposes `createMatrixRTCSdk`: join a slot and
|
||||||
* exposes `createMatrixRTCSdk`, which creates the `MatrixRTCSdk` interface
|
* exchange realtime data, with the `@element-hq/matrixrtc-sdk` package and
|
||||||
* (see below) to join an RTC session and exchange realtime data.
|
* Element Call's call view model doing the work underneath. New consumers use
|
||||||
*
|
* the package directly; this bundle goes away once the existing ones have.
|
||||||
* It is a thin layer over the `@element-hq/matrixrtc-sdk` package and Element
|
|
||||||
* Call's call view model, which take care of all the tricky bits:
|
|
||||||
* - sending delayed events
|
|
||||||
* - finding the right sfu
|
|
||||||
* - handling the media stream
|
|
||||||
* - sending join/leave state or sticky events
|
|
||||||
* - setting up encryption and sharing keys
|
|
||||||
*
|
|
||||||
* The interface is kept as it was for its existing consumers. New consumers
|
|
||||||
* use the `@element-hq/matrixrtc-sdk` package directly; this bundle goes away
|
|
||||||
* once the existing ones have moved.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
|
||||||
combineLatest,
|
|
||||||
filter,
|
|
||||||
map,
|
|
||||||
type Observable,
|
|
||||||
of,
|
|
||||||
switchMap,
|
|
||||||
} from "rxjs";
|
|
||||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { EMPTY, filter, map, type Observable, of, switchMap, take } from "rxjs";
|
||||||
import {
|
import {
|
||||||
type Behavior,
|
type Behavior,
|
||||||
createMatrixRTCClient,
|
createRTCSlot,
|
||||||
E2eeType,
|
E2eeType,
|
||||||
|
type LocalRTCMember,
|
||||||
ObservableScope,
|
ObservableScope,
|
||||||
type RTCMember,
|
type RemoteRTCMember,
|
||||||
type RTCMembership,
|
type RTCParticipation,
|
||||||
} from "@element-hq/matrixrtc-sdk";
|
} from "@element-hq/matrixrtc-sdk";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
callViewModelOptionsFromParams,
|
callViewModelOptionsFromParams,
|
||||||
|
type CallViewModel,
|
||||||
createCallViewModel$,
|
createCallViewModel$,
|
||||||
initialPublishRequests,
|
initialPublishRequests,
|
||||||
} from "../src/state/CallViewModel/CallViewModel";
|
} from "../src/state/CallViewModel/CallViewModel";
|
||||||
import { getUrlParams } from "../src/UrlParams";
|
|
||||||
import { MuteStates } from "../src/state/MuteStates";
|
|
||||||
import { MediaDevices } from "../src/state/MediaDevices";
|
|
||||||
import { TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
|
|
||||||
import { initializeWidget } from "../src/widget";
|
|
||||||
import { createWidgetHostBridge } from "../src/HostBridge";
|
|
||||||
import { observeElementSize$ } from "../src/utils/elementSize";
|
|
||||||
import {
|
import {
|
||||||
captureSettings,
|
captureSettings,
|
||||||
matrixRTCClientOptions,
|
rtcParticipationOptions,
|
||||||
|
rtcSlotOptions,
|
||||||
} from "../src/room/InCallView";
|
} from "../src/room/InCallView";
|
||||||
|
import { createWidgetHostBridge } from "../src/HostBridge";
|
||||||
|
import { MediaDevices } from "../src/state/MediaDevices";
|
||||||
|
import { MuteStates } from "../src/state/MuteStates";
|
||||||
|
import { getUrlParams } from "../src/UrlParams";
|
||||||
|
import { observeElementSize$ } from "../src/utils/elementSize";
|
||||||
|
import { initializeWidget } from "../src/widget";
|
||||||
|
|
||||||
/**
|
/** The data channel topic this bundle has always used, so that old and new builds talk. */
|
||||||
* A member as this bundle has always presented it. The media transport is
|
const TOPIC = "matrixRTC";
|
||||||
* the SDK's business now, so the transport objects are gone: `connection` and
|
|
||||||
* `participant` are always null and only kept so that existing code keeps
|
|
||||||
* destructuring. `membership` is what consumers read.
|
|
||||||
*/
|
|
||||||
interface MatrixRTCSdkMember {
|
|
||||||
/** @deprecated Always null: the SDK owns the transport connection. */
|
|
||||||
connection: null;
|
|
||||||
membership: RTCMembership;
|
|
||||||
/** @deprecated Always null: the SDK owns the transport participant. */
|
|
||||||
participant: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MatrixRTCSdk {
|
export interface MatrixRTCSdk {
|
||||||
/**
|
/** Joins the slot; `connected$` tracks the progress. A second call does nothing. */
|
||||||
* observe connected$ to track the state.
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
join: () => void;
|
join: () => void;
|
||||||
/** @throws on leave errors */
|
|
||||||
leave: () => void;
|
leave: () => void;
|
||||||
/**
|
/** Ends everything. The sdk cannot be restarted; create a new one. */
|
||||||
* Ends the rtc sdk. This will unsubscribe any event listeners. And end the associated scope.
|
|
||||||
* No updates can be received from the rtc sdk. The sdk cannot be restarted after.
|
|
||||||
* A new sdk needs to be created via createMatrixRTCSdk.
|
|
||||||
*/
|
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
|
|
||||||
/**
|
|
||||||
* flattened list of remote members
|
|
||||||
*/
|
|
||||||
remoteMembers$: Behavior<MatrixRTCSdkMember[]>;
|
|
||||||
/**
|
|
||||||
* flattened local member
|
|
||||||
*/
|
|
||||||
localMember$: Behavior<MatrixRTCSdkMember | null>;
|
|
||||||
/** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
|
|
||||||
connected$: Behavior<boolean>;
|
connected$: Behavior<boolean>;
|
||||||
sendData?: (data: unknown) => Promise<void>;
|
/** Our own member, null until our membership has been seen in the room. */
|
||||||
sendRoomMessage?: (message: string) => Promise<void>;
|
localMember$: Behavior<LocalRTCMember | null>;
|
||||||
|
/** The other members of the slot, with the media the participation carries for them. */
|
||||||
|
remoteMembers$: Behavior<RemoteRTCMember[]>;
|
||||||
|
/** What other members sent with `sendData`. */
|
||||||
|
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
|
||||||
|
/** Sends to every member on our transport. Rejects while not joined. */
|
||||||
|
sendData: (data: unknown) => Promise<void>;
|
||||||
|
sendRoomMessage: (message: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMatrixRTCSdk(
|
export async function createMatrixRTCSdk(
|
||||||
application: string = "m.call",
|
application = "m.call",
|
||||||
id: string = "",
|
id = "",
|
||||||
sticky: boolean = false,
|
sticky = false,
|
||||||
): Promise<MatrixRTCSdk> {
|
): Promise<MatrixRTCSdk> {
|
||||||
const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
||||||
const scope = new ObservableScope();
|
const scope = new ObservableScope();
|
||||||
|
|
||||||
// widget client
|
|
||||||
const widget = initializeWidget(application, true);
|
const widget = initializeWidget(application, true);
|
||||||
if (!widget) throw Error("No widget. This webapp can only start as a widget");
|
if (!widget) throw new Error("No widget: this bundle only runs as a widget");
|
||||||
const client = await widget.client;
|
const client = await widget.client;
|
||||||
const hostBridge = createWidgetHostBridge(widget);
|
const hostBridge = createWidgetHostBridge(widget);
|
||||||
logger.info("client created");
|
|
||||||
|
|
||||||
// url params
|
|
||||||
const urlParams = getUrlParams();
|
const urlParams = getUrlParams();
|
||||||
const { roomId, controlledAudioDevices, callIntent } = urlParams;
|
const { roomId, controlledAudioDevices, callIntent } = urlParams;
|
||||||
if (roomId === null) throw Error("could not get roomId from url params");
|
const room = roomId === null ? null : client.getRoom(roomId);
|
||||||
const room = client.getRoom(roomId);
|
if (room === null) throw new Error("No room: the url needs a joined roomId");
|
||||||
if (room === null) throw Error("could not get room from client");
|
|
||||||
|
|
||||||
// media devices
|
|
||||||
const mediaDevices = new MediaDevices(scope, {
|
const mediaDevices = new MediaDevices(scope, {
|
||||||
controlledAudioDevices,
|
controlledAudioDevices,
|
||||||
callIntent,
|
callIntent,
|
||||||
@@ -137,136 +92,97 @@ export async function createMatrixRTCSdk(
|
|||||||
{ audioEnabled: false, videoEnabled: false },
|
{ audioEnabled: false, videoEnabled: false },
|
||||||
hostBridge,
|
hostBridge,
|
||||||
);
|
);
|
||||||
|
|
||||||
// rtc client: the session, the transport and the media, as in the app
|
|
||||||
const encryptionSystem = { kind: E2eeType.PER_PARTICIPANT } as const;
|
const encryptionSystem = { kind: E2eeType.PER_PARTICIPANT } as const;
|
||||||
const capture = captureSettings();
|
const capture = captureSettings();
|
||||||
const rtcClient = createMatrixRTCClient(scope, client, room, {
|
const slot = createRTCSlot(scope, client, room, {
|
||||||
...matrixRTCClientOptions(urlParams, encryptionSystem),
|
...rtcSlotOptions(encryptionSystem),
|
||||||
publish: initialPublishRequests(muteStates, mediaDevices, capture),
|
|
||||||
application,
|
application,
|
||||||
slot: id,
|
id,
|
||||||
});
|
});
|
||||||
|
let vm: CallViewModel | undefined;
|
||||||
|
const failed =
|
||||||
|
(what: string) =>
|
||||||
|
(e: unknown): void =>
|
||||||
|
logger.error(what, e);
|
||||||
|
|
||||||
// call view model: the host bridge's hang-up handling and the leave flow
|
const over = <T>(
|
||||||
const callViewModel = createCallViewModel$(
|
pick: (participation: RTCParticipation) => Observable<T>,
|
||||||
scope,
|
fallback: T,
|
||||||
rtcClient,
|
): Behavior<T> =>
|
||||||
room,
|
scope.behavior(
|
||||||
mediaDevices,
|
slot.participation$.pipe(
|
||||||
muteStates,
|
switchMap((p) => (p === null ? of(fallback) : pick(p))),
|
||||||
{
|
),
|
||||||
...callViewModelOptionsFromParams(urlParams),
|
|
||||||
encryptionSystem,
|
|
||||||
capture,
|
|
||||||
hostBridge,
|
|
||||||
// The SDK owns its page, so the body is the space it has
|
|
||||||
windowSize$: scope.behavior(observeElementSize$(document.body)),
|
|
||||||
},
|
|
||||||
of({}),
|
|
||||||
of({}),
|
|
||||||
);
|
|
||||||
logger.info("CallViewModelCreated");
|
|
||||||
|
|
||||||
// Data arrives from attested members only; the SDK has matched the sender
|
|
||||||
const data$ = rtcClient.data$.pipe(
|
|
||||||
filter(({ topic }) => topic === TEXT_LK_TOPIC),
|
|
||||||
map(({ member, text }) => {
|
|
||||||
logger.info(`Received text from ${member.id}: ${text}`);
|
|
||||||
return { rtcBackendIdentity: member.id, data: text };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendData = async (data: unknown): Promise<void> => {
|
|
||||||
const dataString = JSON.stringify(data);
|
|
||||||
logger.info("try sending: ", dataString);
|
|
||||||
try {
|
|
||||||
await rtcClient.sendData(TEXT_LK_TOPIC, dataString);
|
|
||||||
logger.info("sent text");
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("failed sending: ", dataString, e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendRoomMessage = async (message: string): Promise<void> => {
|
|
||||||
const messageString = JSON.stringify(message);
|
|
||||||
logger.info("try sending to room: ", messageString);
|
|
||||||
try {
|
|
||||||
await client.sendTextMessage(room.roomId, message);
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("failed sending to room: ", messageString, e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// after hangup gets called
|
|
||||||
const leaveSubs = callViewModel.leave$.subscribe(() => {
|
|
||||||
const scheduleWidgetCloseOnLeave = async (): Promise<void> => {
|
|
||||||
logger.info("waiting for RTC leave");
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
rtcClient.status$
|
|
||||||
.pipe(filter((status) => status === "disconnected"))
|
|
||||||
.subscribe(() => resolve());
|
|
||||||
});
|
|
||||||
logger.info("send Unstick");
|
|
||||||
await hostBridge
|
|
||||||
.setAlwaysOnScreen(false)
|
|
||||||
.catch((e: unknown) =>
|
|
||||||
logger.error("Failed to set `alwaysOnScreen` to false", e),
|
|
||||||
);
|
|
||||||
logger.info("send Close");
|
|
||||||
await hostBridge
|
|
||||||
.close?.()
|
|
||||||
.catch((e: unknown) =>
|
|
||||||
logger.error("Failed to ask the host to close", e),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// schedule close first and then leave (scope.end)
|
|
||||||
void scheduleWidgetCloseOnLeave();
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info("createMatrixRTCSdk done");
|
|
||||||
|
|
||||||
const flatten = (member: RTCMember): Observable<MatrixRTCSdkMember> =>
|
|
||||||
member.membership$.pipe(
|
|
||||||
map((membership) => ({
|
|
||||||
connection: null,
|
|
||||||
membership,
|
|
||||||
participant: null,
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
join: (): void => {
|
join: (): void => {
|
||||||
// first lets try making the widget sticky
|
if (slot.participation$.value !== null) return;
|
||||||
if (sticky) tryMakeSticky(widget);
|
if (sticky)
|
||||||
callViewModel.join();
|
widget.api
|
||||||
|
.setAlwaysOnScreen(true)
|
||||||
|
.catch(failed("Failed to make the widget sticky"));
|
||||||
|
const participation = slot.join({
|
||||||
|
...rtcParticipationOptions(urlParams),
|
||||||
|
publish: initialPublishRequests(muteStates, mediaDevices, capture),
|
||||||
|
});
|
||||||
|
vm = createCallViewModel$(
|
||||||
|
scope,
|
||||||
|
participation,
|
||||||
|
room,
|
||||||
|
mediaDevices,
|
||||||
|
muteStates,
|
||||||
|
{
|
||||||
|
...callViewModelOptionsFromParams(urlParams),
|
||||||
|
encryptionSystem,
|
||||||
|
capture,
|
||||||
|
hostBridge,
|
||||||
|
// The bundle owns its page, so the body is the space it has
|
||||||
|
windowSize$: scope.behavior(observeElementSize$(document.body)),
|
||||||
|
},
|
||||||
|
of({}),
|
||||||
|
of({}),
|
||||||
|
);
|
||||||
|
// Once the hang-up has gone through, unstick and ask the host to close us
|
||||||
|
vm.leave$
|
||||||
|
.pipe(
|
||||||
|
switchMap(() =>
|
||||||
|
participation.status$.pipe(
|
||||||
|
filter((status) => status === "left"),
|
||||||
|
take(1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
scope.bind(),
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
hostBridge
|
||||||
|
.setAlwaysOnScreen(false)
|
||||||
|
.catch(failed("Failed to unstick the widget"))
|
||||||
|
.then(() => hostBridge.close?.())
|
||||||
|
.catch(failed("Failed to ask the host to close"));
|
||||||
|
});
|
||||||
},
|
},
|
||||||
leave: (): void => {
|
leave: (): void => vm?.leave(),
|
||||||
callViewModel.leave();
|
stop: (): void => scope.end(),
|
||||||
},
|
connected$: over((p) => p.connected$, false),
|
||||||
stop: (): void => {
|
localMember$: over((p) => p.localMember$, null),
|
||||||
leaveSubs.unsubscribe();
|
remoteMembers$: over((p) => p.remoteMembers$, []),
|
||||||
scope.end();
|
// Attested members only: the SDK has matched the sender
|
||||||
},
|
data$: slot.participation$.pipe(
|
||||||
data$,
|
switchMap((p) => p?.data$ ?? EMPTY),
|
||||||
localMember$: scope.behavior(
|
filter(({ topic }) => topic === TOPIC),
|
||||||
rtcClient.localMember$.pipe(
|
map(({ member, text }) => ({
|
||||||
switchMap((member) => (member === null ? of(null) : flatten(member))),
|
rtcBackendIdentity: member.rtcBackendIdentity,
|
||||||
),
|
data: text,
|
||||||
|
})),
|
||||||
),
|
),
|
||||||
connected$: callViewModel.connected$,
|
sendData: async (data): Promise<void> => {
|
||||||
remoteMembers$: scope.behavior(
|
const participation = slot.participation$.value;
|
||||||
rtcClient.remoteMembers$.pipe(
|
if (participation === null) throw new Error("Not joined");
|
||||||
switchMap((members) =>
|
await participation.sendData(TOPIC, JSON.stringify(data));
|
||||||
members.length === 0
|
},
|
||||||
? of([])
|
sendRoomMessage: async (message): Promise<void> => {
|
||||||
: combineLatest(members.map((member) => flatten(member))),
|
await client.sendTextMessage(room.roomId, message);
|
||||||
),
|
},
|
||||||
),
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
sendData,
|
|
||||||
sendRoomMessage,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user