summaryrefslogtreecommitdiff
path: root/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCall.ts
diff options
context:
space:
mode:
Diffstat (limited to 'includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCall.ts')
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCall.ts1598
1 files changed, 0 insertions, 1598 deletions
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCall.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCall.ts
deleted file mode 100644
index c0896c4..0000000
--- a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCall.ts
+++ /dev/null
@@ -1,1598 +0,0 @@
-import { TypedEventEmitter } from "../models/typed-event-emitter";
-import { CallFeed, SPEAKING_THRESHOLD } from "./callFeed";
-import { MatrixClient, IMyDevice } from "../client";
-import {
- CallErrorCode,
- CallEvent,
- CallEventHandlerMap,
- CallState,
- genCallID,
- MatrixCall,
- setTracksEnabled,
- createNewMatrixCall,
- CallError,
-} from "./call";
-import { RoomMember } from "../models/room-member";
-import { Room } from "../models/room";
-import { RoomStateEvent } from "../models/room-state";
-import { logger } from "../logger";
-import { ReEmitter } from "../ReEmitter";
-import { SDPStreamMetadataPurpose } from "./callEventTypes";
-import { MatrixEvent } from "../models/event";
-import { EventType } from "../@types/event";
-import { CallEventHandlerEvent } from "./callEventHandler";
-import { GroupCallEventHandlerEvent } from "./groupCallEventHandler";
-import { IScreensharingOpts } from "./mediaHandler";
-import { mapsEqual } from "../utils";
-import { GroupCallStats } from "./stats/groupCallStats";
-import { ByteSentStatsReport, ConnectionStatsReport, StatsReport } from "./stats/statsReport";
-
-export enum GroupCallIntent {
- Ring = "m.ring",
- Prompt = "m.prompt",
- Room = "m.room",
-}
-
-export enum GroupCallType {
- Video = "m.video",
- Voice = "m.voice",
-}
-
-export enum GroupCallTerminationReason {
- CallEnded = "call_ended",
-}
-
-export type CallsByUserAndDevice = Map<string, Map<string, MatrixCall>>;
-
-/**
- * Because event names are just strings, they do need
- * to be unique over all event types of event emitter.
- * Some objects could emit more then one set of events.
- */
-export enum GroupCallEvent {
- GroupCallStateChanged = "group_call_state_changed",
- ActiveSpeakerChanged = "active_speaker_changed",
- CallsChanged = "calls_changed",
- UserMediaFeedsChanged = "user_media_feeds_changed",
- ScreenshareFeedsChanged = "screenshare_feeds_changed",
- LocalScreenshareStateChanged = "local_screenshare_state_changed",
- LocalMuteStateChanged = "local_mute_state_changed",
- ParticipantsChanged = "participants_changed",
- Error = "group_call_error",
-}
-
-export type GroupCallEventHandlerMap = {
- [GroupCallEvent.GroupCallStateChanged]: (newState: GroupCallState, oldState: GroupCallState) => void;
- [GroupCallEvent.ActiveSpeakerChanged]: (activeSpeaker: CallFeed | undefined) => void;
- [GroupCallEvent.CallsChanged]: (calls: CallsByUserAndDevice) => void;
- [GroupCallEvent.UserMediaFeedsChanged]: (feeds: CallFeed[]) => void;
- [GroupCallEvent.ScreenshareFeedsChanged]: (feeds: CallFeed[]) => void;
- [GroupCallEvent.LocalScreenshareStateChanged]: (
- isScreensharing: boolean,
- feed?: CallFeed,
- sourceId?: string,
- ) => void;
- [GroupCallEvent.LocalMuteStateChanged]: (audioMuted: boolean, videoMuted: boolean) => void;
- [GroupCallEvent.ParticipantsChanged]: (participants: Map<RoomMember, Map<string, ParticipantState>>) => void;
- /**
- * Fires whenever an error occurs when call.js encounters an issue with setting up the call.
- * <p>
- * The error given will have a code equal to either `MatrixCall.ERR_LOCAL_OFFER_FAILED` or
- * `MatrixCall.ERR_NO_USER_MEDIA`. `ERR_LOCAL_OFFER_FAILED` is emitted when the local client
- * fails to create an offer. `ERR_NO_USER_MEDIA` is emitted when the user has denied access
- * to their audio/video hardware.
- * @param err - The error raised by MatrixCall.
- * @example
- * ```
- * matrixCall.on("error", function(err){
- * console.error(err.code, err);
- * });
- * ```
- */
- [GroupCallEvent.Error]: (error: GroupCallError) => void;
-};
-
-export enum GroupCallStatsReportEvent {
- ConnectionStats = "GroupCall.connection_stats",
- ByteSentStats = "GroupCall.byte_sent_stats",
-}
-
-export type GroupCallStatsReportEventHandlerMap = {
- [GroupCallStatsReportEvent.ConnectionStats]: (report: GroupCallStatsReport<ConnectionStatsReport>) => void;
- [GroupCallStatsReportEvent.ByteSentStats]: (report: GroupCallStatsReport<ByteSentStatsReport>) => void;
-};
-
-export enum GroupCallErrorCode {
- NoUserMedia = "no_user_media",
- UnknownDevice = "unknown_device",
- PlaceCallFailed = "place_call_failed",
-}
-
-export interface GroupCallStatsReport<T extends ConnectionStatsReport | ByteSentStatsReport> {
- report: T;
-}
-
-export class GroupCallError extends Error {
- public code: string;
-
- public constructor(code: GroupCallErrorCode, msg: string, err?: Error) {
- // Still don't think there's any way to have proper nested errors
- if (err) {
- super(msg + ": " + err);
- } else {
- super(msg);
- }
-
- this.code = code;
- }
-}
-
-export class GroupCallUnknownDeviceError extends GroupCallError {
- public constructor(public userId: string) {
- super(GroupCallErrorCode.UnknownDevice, "No device found for " + userId);
- }
-}
-
-export class OtherUserSpeakingError extends Error {
- public constructor() {
- super("Cannot unmute: another user is speaking");
- }
-}
-
-export interface IGroupCallDataChannelOptions {
- ordered: boolean;
- maxPacketLifeTime: number;
- maxRetransmits: number;
- protocol: string;
-}
-
-export interface IGroupCallRoomState {
- "m.intent": GroupCallIntent;
- "m.type": GroupCallType;
- "io.element.ptt"?: boolean;
- // TODO: Specify data-channels
- "dataChannelsEnabled"?: boolean;
- "dataChannelOptions"?: IGroupCallDataChannelOptions;
-}
-
-export interface IGroupCallRoomMemberFeed {
- purpose: SDPStreamMetadataPurpose;
-}
-
-export interface IGroupCallRoomMemberDevice {
- device_id: string;
- session_id: string;
- expires_ts: number;
- feeds: IGroupCallRoomMemberFeed[];
-}
-
-export interface IGroupCallRoomMemberCallState {
- "m.call_id": string;
- "m.foci"?: string[];
- "m.devices": IGroupCallRoomMemberDevice[];
-}
-
-export interface IGroupCallRoomMemberState {
- "m.calls": IGroupCallRoomMemberCallState[];
-}
-
-export enum GroupCallState {
- LocalCallFeedUninitialized = "local_call_feed_uninitialized",
- InitializingLocalCallFeed = "initializing_local_call_feed",
- LocalCallFeedInitialized = "local_call_feed_initialized",
- Entered = "entered",
- Ended = "ended",
-}
-
-export interface ParticipantState {
- sessionId: string;
- screensharing: boolean;
-}
-
-interface ICallHandlers {
- onCallFeedsChanged: (feeds: CallFeed[]) => void;
- onCallStateChanged: (state: CallState, oldState: CallState | undefined) => void;
- onCallHangup: (call: MatrixCall) => void;
- onCallReplaced: (newCall: MatrixCall) => void;
-}
-
-const DEVICE_TIMEOUT = 1000 * 60 * 60; // 1 hour
-
-function getCallUserId(call: MatrixCall): string | null {
- return call.getOpponentMember()?.userId || call.invitee || null;
-}
-
-export class GroupCall extends TypedEventEmitter<
- GroupCallEvent | CallEvent | GroupCallStatsReportEvent,
- GroupCallEventHandlerMap & CallEventHandlerMap & GroupCallStatsReportEventHandlerMap
-> {
- // Config
- public activeSpeakerInterval = 1000;
- public retryCallInterval = 5000;
- public participantTimeout = 1000 * 15;
- public pttMaxTransmitTime = 1000 * 20;
-
- public activeSpeaker?: CallFeed;
- public localCallFeed?: CallFeed;
- public localScreenshareFeed?: CallFeed;
- public localDesktopCapturerSourceId?: string;
- public readonly userMediaFeeds: CallFeed[] = [];
- public readonly screenshareFeeds: CallFeed[] = [];
- public groupCallId: string;
- public readonly allowCallWithoutVideoAndAudio: boolean;
-
- private readonly calls = new Map<string, Map<string, MatrixCall>>(); // user_id -> device_id -> MatrixCall
- private callHandlers = new Map<string, Map<string, ICallHandlers>>(); // user_id -> device_id -> ICallHandlers
- private activeSpeakerLoopInterval?: ReturnType<typeof setTimeout>;
- private retryCallLoopInterval?: ReturnType<typeof setTimeout>;
- private retryCallCounts: Map<string, Map<string, number>> = new Map(); // user_id -> device_id -> count
- private reEmitter: ReEmitter;
- private transmitTimer: ReturnType<typeof setTimeout> | null = null;
- private participantsExpirationTimer: ReturnType<typeof setTimeout> | null = null;
- private resendMemberStateTimer: ReturnType<typeof setInterval> | null = null;
- private initWithAudioMuted = false;
- private initWithVideoMuted = false;
- private initCallFeedPromise?: Promise<void>;
-
- private readonly stats: GroupCallStats;
-
- public constructor(
- private client: MatrixClient,
- public room: Room,
- public type: GroupCallType,
- public isPtt: boolean,
- public intent: GroupCallIntent,
- groupCallId?: string,
- private dataChannelsEnabled?: boolean,
- private dataChannelOptions?: IGroupCallDataChannelOptions,
- isCallWithoutVideoAndAudio?: boolean,
- ) {
- super();
- this.reEmitter = new ReEmitter(this);
- this.groupCallId = groupCallId ?? genCallID();
- this.creationTs =
- room.currentState.getStateEvents(EventType.GroupCallPrefix, this.groupCallId)?.getTs() ?? null;
- this.updateParticipants();
-
- room.on(RoomStateEvent.Update, this.onRoomState);
- this.on(GroupCallEvent.ParticipantsChanged, this.onParticipantsChanged);
- this.on(GroupCallEvent.GroupCallStateChanged, this.onStateChanged);
- this.on(GroupCallEvent.LocalScreenshareStateChanged, this.onLocalFeedsChanged);
- this.allowCallWithoutVideoAndAudio = !!isCallWithoutVideoAndAudio;
-
- const userID = this.client.getUserId() || "unknown";
- this.stats = new GroupCallStats(this.groupCallId, userID);
- this.stats.reports.on(StatsReport.CONNECTION_STATS, this.onConnectionStats);
- this.stats.reports.on(StatsReport.BYTE_SENT_STATS, this.onByteSentStats);
- }
-
- private onConnectionStats = (report: ConnectionStatsReport): void => {
- // @TODO: Implement data argumentation
- this.emit(GroupCallStatsReportEvent.ConnectionStats, { report });
- };
-
- private onByteSentStats = (report: ByteSentStatsReport): void => {
- // @TODO: Implement data argumentation
- this.emit(GroupCallStatsReportEvent.ByteSentStats, { report });
- };
-
- public async create(): Promise<GroupCall> {
- this.creationTs = Date.now();
- this.client.groupCallEventHandler!.groupCalls.set(this.room.roomId, this);
- this.client.emit(GroupCallEventHandlerEvent.Outgoing, this);
-
- const groupCallState: IGroupCallRoomState = {
- "m.intent": this.intent,
- "m.type": this.type,
- "io.element.ptt": this.isPtt,
- // TODO: Specify data-channels better
- "dataChannelsEnabled": this.dataChannelsEnabled,
- "dataChannelOptions": this.dataChannelsEnabled ? this.dataChannelOptions : undefined,
- };
-
- await this.client.sendStateEvent(this.room.roomId, EventType.GroupCallPrefix, groupCallState, this.groupCallId);
-
- return this;
- }
-
- private _state = GroupCallState.LocalCallFeedUninitialized;
-
- /**
- * The group call's state.
- */
- public get state(): GroupCallState {
- return this._state;
- }
-
- private set state(value: GroupCallState) {
- const prevValue = this._state;
- if (value !== prevValue) {
- this._state = value;
- this.emit(GroupCallEvent.GroupCallStateChanged, value, prevValue);
- }
- }
-
- private _participants = new Map<RoomMember, Map<string, ParticipantState>>();
-
- /**
- * The current participants in the call, as a map from members to device IDs
- * to participant info.
- */
- public get participants(): Map<RoomMember, Map<string, ParticipantState>> {
- return this._participants;
- }
-
- private set participants(value: Map<RoomMember, Map<string, ParticipantState>>) {
- const prevValue = this._participants;
- const participantStateEqual = (x: ParticipantState, y: ParticipantState): boolean =>
- x.sessionId === y.sessionId && x.screensharing === y.screensharing;
- const deviceMapsEqual = (x: Map<string, ParticipantState>, y: Map<string, ParticipantState>): boolean =>
- mapsEqual(x, y, participantStateEqual);
-
- // Only update if the map actually changed
- if (!mapsEqual(value, prevValue, deviceMapsEqual)) {
- this._participants = value;
- this.emit(GroupCallEvent.ParticipantsChanged, value);
- }
- }
-
- private _creationTs: number | null = null;
-
- /**
- * The timestamp at which the call was created, or null if it has not yet
- * been created.
- */
- public get creationTs(): number | null {
- return this._creationTs;
- }
-
- private set creationTs(value: number | null) {
- this._creationTs = value;
- }
-
- private _enteredViaAnotherSession = false;
-
- /**
- * Whether the local device has entered this call via another session, such
- * as a widget.
- */
- public get enteredViaAnotherSession(): boolean {
- return this._enteredViaAnotherSession;
- }
-
- public set enteredViaAnotherSession(value: boolean) {
- this._enteredViaAnotherSession = value;
- this.updateParticipants();
- }
-
- /**
- * Executes the given callback on all calls in this group call.
- * @param f - The callback.
- */
- public forEachCall(f: (call: MatrixCall) => void): void {
- for (const deviceMap of this.calls.values()) {
- for (const call of deviceMap.values()) f(call);
- }
- }
-
- public getLocalFeeds(): CallFeed[] {
- const feeds: CallFeed[] = [];
-
- if (this.localCallFeed) feeds.push(this.localCallFeed);
- if (this.localScreenshareFeed) feeds.push(this.localScreenshareFeed);
-
- return feeds;
- }
-
- public hasLocalParticipant(): boolean {
- return (
- this.participants.get(this.room.getMember(this.client.getUserId()!)!)?.has(this.client.getDeviceId()!) ??
- false
- );
- }
-
- /**
- * Determines whether the given call is one that we were expecting to exist
- * given our knowledge of who is participating in the group call.
- */
- private callExpected(call: MatrixCall): boolean {
- const userId = getCallUserId(call);
- const member = userId === null ? null : this.room.getMember(userId);
- const deviceId = call.getOpponentDeviceId();
- return member !== null && deviceId !== undefined && this.participants.get(member)?.get(deviceId) !== undefined;
- }
-
- public async initLocalCallFeed(): Promise<void> {
- if (this.state !== GroupCallState.LocalCallFeedUninitialized) {
- throw new Error(`Cannot initialize local call feed in the "${this.state}" state.`);
- }
- this.state = GroupCallState.InitializingLocalCallFeed;
-
- // wraps the real method to serialise calls, because we don't want to try starting
- // multiple call feeds at once
- if (this.initCallFeedPromise) return this.initCallFeedPromise;
-
- try {
- this.initCallFeedPromise = this.initLocalCallFeedInternal();
- await this.initCallFeedPromise;
- } finally {
- this.initCallFeedPromise = undefined;
- }
- }
-
- private async initLocalCallFeedInternal(): Promise<void> {
- logger.log(`GroupCall ${this.groupCallId} initLocalCallFeedInternal() running`);
-
- let stream: MediaStream;
-
- try {
- stream = await this.client.getMediaHandler().getUserMediaStream(true, this.type === GroupCallType.Video);
- } catch (error) {
- // If is allowed to join a call without a media stream, then we
- // don't throw an error here. But we need an empty Local Feed to establish
- // a connection later.
- if (this.allowCallWithoutVideoAndAudio) {
- stream = new MediaStream();
- } else {
- this.state = GroupCallState.LocalCallFeedUninitialized;
- throw error;
- }
- }
-
- // The call could've been disposed while we were waiting, and could
- // also have been started back up again (hello, React 18) so if we're
- // still in this 'initializing' state, carry on, otherwise bail.
- if (this._state !== GroupCallState.InitializingLocalCallFeed) {
- this.client.getMediaHandler().stopUserMediaStream(stream);
- throw new Error("Group call disposed while gathering media stream");
- }
-
- const callFeed = new CallFeed({
- client: this.client,
- roomId: this.room.roomId,
- userId: this.client.getUserId()!,
- deviceId: this.client.getDeviceId()!,
- stream,
- purpose: SDPStreamMetadataPurpose.Usermedia,
- audioMuted: this.initWithAudioMuted || stream.getAudioTracks().length === 0 || this.isPtt,
- videoMuted: this.initWithVideoMuted || stream.getVideoTracks().length === 0,
- });
-
- setTracksEnabled(stream.getAudioTracks(), !callFeed.isAudioMuted());
- setTracksEnabled(stream.getVideoTracks(), !callFeed.isVideoMuted());
-
- this.localCallFeed = callFeed;
- this.addUserMediaFeed(callFeed);
-
- this.state = GroupCallState.LocalCallFeedInitialized;
- }
-
- public async updateLocalUsermediaStream(stream: MediaStream): Promise<void> {
- if (this.localCallFeed) {
- const oldStream = this.localCallFeed.stream;
- this.localCallFeed.setNewStream(stream);
- const micShouldBeMuted = this.localCallFeed.isAudioMuted();
- const vidShouldBeMuted = this.localCallFeed.isVideoMuted();
- logger.log(
- `GroupCall ${this.groupCallId} updateLocalUsermediaStream() (oldStreamId=${oldStream.id}, newStreamId=${stream.id}, micShouldBeMuted=${micShouldBeMuted}, vidShouldBeMuted=${vidShouldBeMuted})`,
- );
- setTracksEnabled(stream.getAudioTracks(), !micShouldBeMuted);
- setTracksEnabled(stream.getVideoTracks(), !vidShouldBeMuted);
- this.client.getMediaHandler().stopUserMediaStream(oldStream);
- }
- }
-
- public async enter(): Promise<void> {
- if (this.state === GroupCallState.LocalCallFeedUninitialized) {
- await this.initLocalCallFeed();
- } else if (this.state !== GroupCallState.LocalCallFeedInitialized) {
- throw new Error(`Cannot enter call in the "${this.state}" state`);
- }
-
- logger.log(`GroupCall ${this.groupCallId} enter() running`);
- this.state = GroupCallState.Entered;
-
- this.client.on(CallEventHandlerEvent.Incoming, this.onIncomingCall);
-
- for (const call of this.client.callEventHandler!.calls.values()) {
- this.onIncomingCall(call);
- }
-
- this.retryCallLoopInterval = setInterval(this.onRetryCallLoop, this.retryCallInterval);
-
- this.activeSpeaker = undefined;
- this.onActiveSpeakerLoop();
- this.activeSpeakerLoopInterval = setInterval(this.onActiveSpeakerLoop, this.activeSpeakerInterval);
- }
-
- private dispose(): void {
- if (this.localCallFeed) {
- this.removeUserMediaFeed(this.localCallFeed);
- this.localCallFeed = undefined;
- }
-
- if (this.localScreenshareFeed) {
- this.client.getMediaHandler().stopScreensharingStream(this.localScreenshareFeed.stream);
- this.removeScreenshareFeed(this.localScreenshareFeed);
- this.localScreenshareFeed = undefined;
- this.localDesktopCapturerSourceId = undefined;
- }
-
- this.client.getMediaHandler().stopAllStreams();
-
- if (this.transmitTimer !== null) {
- clearTimeout(this.transmitTimer);
- this.transmitTimer = null;
- }
-
- if (this.retryCallLoopInterval !== undefined) {
- clearInterval(this.retryCallLoopInterval);
- this.retryCallLoopInterval = undefined;
- }
-
- if (this.participantsExpirationTimer !== null) {
- clearTimeout(this.participantsExpirationTimer);
- this.participantsExpirationTimer = null;
- }
-
- if (this.state !== GroupCallState.Entered) {
- return;
- }
-
- this.forEachCall((call) => call.hangup(CallErrorCode.UserHangup, false));
-
- this.activeSpeaker = undefined;
- clearInterval(this.activeSpeakerLoopInterval);
-
- this.retryCallCounts.clear();
- clearInterval(this.retryCallLoopInterval);
-
- this.client.removeListener(CallEventHandlerEvent.Incoming, this.onIncomingCall);
- this.stats.stop();
- }
-
- public leave(): void {
- this.dispose();
- this.state = GroupCallState.LocalCallFeedUninitialized;
- }
-
- public async terminate(emitStateEvent = true): Promise<void> {
- this.dispose();
-
- this.room.off(RoomStateEvent.Update, this.onRoomState);
- this.client.groupCallEventHandler!.groupCalls.delete(this.room.roomId);
- this.client.emit(GroupCallEventHandlerEvent.Ended, this);
- this.state = GroupCallState.Ended;
-
- if (emitStateEvent) {
- const existingStateEvent = this.room.currentState.getStateEvents(
- EventType.GroupCallPrefix,
- this.groupCallId,
- )!;
-
- await this.client.sendStateEvent(
- this.room.roomId,
- EventType.GroupCallPrefix,
- {
- ...existingStateEvent.getContent(),
- "m.terminated": GroupCallTerminationReason.CallEnded,
- },
- this.groupCallId,
- );
- }
- }
-
- /*
- * Local Usermedia
- */
-
- public isLocalVideoMuted(): boolean {
- if (this.localCallFeed) {
- return this.localCallFeed.isVideoMuted();
- }
-
- return true;
- }
-
- public isMicrophoneMuted(): boolean {
- if (this.localCallFeed) {
- return this.localCallFeed.isAudioMuted();
- }
-
- return true;
- }
-
- /**
- * Sets the mute state of the local participants's microphone.
- * @param muted - Whether to mute the microphone
- * @returns Whether muting/unmuting was successful
- */
- public async setMicrophoneMuted(muted: boolean): Promise<boolean> {
- // hasAudioDevice can block indefinitely if the window has lost focus,
- // and it doesn't make much sense to keep a device from being muted, so
- // we always allow muted = true changes to go through
- if (!muted && !(await this.client.getMediaHandler().hasAudioDevice())) {
- return false;
- }
-
- const sendUpdatesBefore = !muted && this.isPtt;
-
- // set a timer for the maximum transmit time on PTT calls
- if (this.isPtt) {
- // Set or clear the max transmit timer
- if (!muted && this.isMicrophoneMuted()) {
- this.transmitTimer = setTimeout(() => {
- this.setMicrophoneMuted(true);
- }, this.pttMaxTransmitTime);
- } else if (muted && !this.isMicrophoneMuted()) {
- if (this.transmitTimer !== null) clearTimeout(this.transmitTimer);
- this.transmitTimer = null;
- }
- }
-
- this.forEachCall((call) => call.localUsermediaFeed?.setAudioVideoMuted(muted, null));
-
- const sendUpdates = async (): Promise<void> => {
- const updates: Promise<void>[] = [];
- this.forEachCall((call) => updates.push(call.sendMetadataUpdate()));
-
- await Promise.all(updates).catch((e) =>
- logger.info(
- `GroupCall ${this.groupCallId} setMicrophoneMuted() failed to send some metadata updates`,
- e,
- ),
- );
- };
-
- if (sendUpdatesBefore) await sendUpdates();
-
- if (this.localCallFeed) {
- logger.log(
- `GroupCall ${this.groupCallId} setMicrophoneMuted() (streamId=${this.localCallFeed.stream.id}, muted=${muted})`,
- );
-
- // We needed this here to avoid an error in case user join a call without a device.
- // I can not use .then .catch functions because linter :-(
- try {
- if (!muted) {
- const stream = await this.client
- .getMediaHandler()
- .getUserMediaStream(true, !this.localCallFeed.isVideoMuted());
- if (stream === null) {
- // if case permission denied to get a stream stop this here
- /* istanbul ignore next */
- logger.log(
- `GroupCall ${this.groupCallId} setMicrophoneMuted() no device to receive local stream, muted=${muted}`,
- );
- return false;
- }
- }
- } catch (e) {
- /* istanbul ignore next */
- logger.log(
- `GroupCall ${this.groupCallId} setMicrophoneMuted() no device or permission to receive local stream, muted=${muted}`,
- );
- return false;
- }
-
- this.localCallFeed.setAudioVideoMuted(muted, null);
- // I don't believe its actually necessary to enable these tracks: they
- // are the one on the GroupCall's own CallFeed and are cloned before being
- // given to any of the actual calls, so these tracks don't actually go
- // anywhere. Let's do it anyway to avoid confusion.
- setTracksEnabled(this.localCallFeed.stream.getAudioTracks(), !muted);
- } else {
- logger.log(`GroupCall ${this.groupCallId} setMicrophoneMuted() no stream muted (muted=${muted})`);
- this.initWithAudioMuted = muted;
- }
-
- this.forEachCall((call) =>
- setTracksEnabled(call.localUsermediaFeed!.stream.getAudioTracks(), !muted && this.callExpected(call)),
- );
- this.emit(GroupCallEvent.LocalMuteStateChanged, muted, this.isLocalVideoMuted());
-
- if (!sendUpdatesBefore) await sendUpdates();
-
- return true;
- }
-
- /**
- * Sets the mute state of the local participants's video.
- * @param muted - Whether to mute the video
- * @returns Whether muting/unmuting was successful
- */
- public async setLocalVideoMuted(muted: boolean): Promise<boolean> {
- // hasAudioDevice can block indefinitely if the window has lost focus,
- // and it doesn't make much sense to keep a device from being muted, so
- // we always allow muted = true changes to go through
- if (!muted && !(await this.client.getMediaHandler().hasVideoDevice())) {
- return false;
- }
-
- if (this.localCallFeed) {
- /* istanbul ignore next */
- logger.log(
- `GroupCall ${this.groupCallId} setLocalVideoMuted() (stream=${this.localCallFeed.stream.id}, muted=${muted})`,
- );
-
- try {
- const stream = await this.client.getMediaHandler().getUserMediaStream(true, !muted);
- await this.updateLocalUsermediaStream(stream);
- this.localCallFeed.setAudioVideoMuted(null, muted);
- setTracksEnabled(this.localCallFeed.stream.getVideoTracks(), !muted);
- } catch (_) {
- // No permission to video device
- /* istanbul ignore next */
- logger.log(
- `GroupCall ${this.groupCallId} setLocalVideoMuted() no device or permission to receive local stream, muted=${muted}`,
- );
- return false;
- }
- } else {
- logger.log(`GroupCall ${this.groupCallId} setLocalVideoMuted() no stream muted (muted=${muted})`);
- this.initWithVideoMuted = muted;
- }
-
- const updates: Promise<unknown>[] = [];
- this.forEachCall((call) => updates.push(call.setLocalVideoMuted(muted)));
- await Promise.all(updates);
-
- // We setTracksEnabled again, independently from the call doing it
- // internally, since we might not be expecting the call
- this.forEachCall((call) =>
- setTracksEnabled(call.localUsermediaFeed!.stream.getVideoTracks(), !muted && this.callExpected(call)),
- );
-
- this.emit(GroupCallEvent.LocalMuteStateChanged, this.isMicrophoneMuted(), muted);
-
- return true;
- }
-
- public async setScreensharingEnabled(enabled: boolean, opts: IScreensharingOpts = {}): Promise<boolean> {
- if (enabled === this.isScreensharing()) {
- return enabled;
- }
-
- if (enabled) {
- try {
- logger.log(
- `GroupCall ${this.groupCallId} setScreensharingEnabled() is asking for screensharing permissions`,
- );
- const stream = await this.client.getMediaHandler().getScreensharingStream(opts);
-
- for (const track of stream.getTracks()) {
- const onTrackEnded = (): void => {
- this.setScreensharingEnabled(false);
- track.removeEventListener("ended", onTrackEnded);
- };
-
- track.addEventListener("ended", onTrackEnded);
- }
-
- logger.log(
- `GroupCall ${this.groupCallId} setScreensharingEnabled() granted screensharing permissions. Setting screensharing enabled on all calls`,
- );
-
- this.localDesktopCapturerSourceId = opts.desktopCapturerSourceId;
- this.localScreenshareFeed = new CallFeed({
- client: this.client,
- roomId: this.room.roomId,
- userId: this.client.getUserId()!,
- deviceId: this.client.getDeviceId()!,
- stream,
- purpose: SDPStreamMetadataPurpose.Screenshare,
- audioMuted: false,
- videoMuted: false,
- });
- this.addScreenshareFeed(this.localScreenshareFeed);
-
- this.emit(
- GroupCallEvent.LocalScreenshareStateChanged,
- true,
- this.localScreenshareFeed,
- this.localDesktopCapturerSourceId,
- );
-
- // TODO: handle errors
- this.forEachCall((call) => call.pushLocalFeed(this.localScreenshareFeed!.clone()));
-
- return true;
- } catch (error) {
- if (opts.throwOnFail) throw error;
- logger.error(
- `GroupCall ${this.groupCallId} setScreensharingEnabled() enabling screensharing error`,
- error,
- );
- this.emit(
- GroupCallEvent.Error,
- new GroupCallError(
- GroupCallErrorCode.NoUserMedia,
- "Failed to get screen-sharing stream: ",
- error as Error,
- ),
- );
- return false;
- }
- } else {
- this.forEachCall((call) => {
- if (call.localScreensharingFeed) call.removeLocalFeed(call.localScreensharingFeed);
- });
- this.client.getMediaHandler().stopScreensharingStream(this.localScreenshareFeed!.stream);
- this.removeScreenshareFeed(this.localScreenshareFeed!);
- this.localScreenshareFeed = undefined;
- this.localDesktopCapturerSourceId = undefined;
- this.emit(GroupCallEvent.LocalScreenshareStateChanged, false, undefined, undefined);
- return false;
- }
- }
-
- public isScreensharing(): boolean {
- return !!this.localScreenshareFeed;
- }
-
- /*
- * Call Setup
- *
- * There are two different paths for calls to be created:
- * 1. Incoming calls triggered by the Call.incoming event.
- * 2. Outgoing calls to the initial members of a room or new members
- * as they are observed by the RoomState.members event.
- */
-
- private onIncomingCall = (newCall: MatrixCall): void => {
- // The incoming calls may be for another room, which we will ignore.
- if (newCall.roomId !== this.room.roomId) {
- return;
- }
-
- if (newCall.state !== CallState.Ringing) {
- logger.warn(
- `GroupCall ${this.groupCallId} onIncomingCall() incoming call no longer in ringing state - ignoring`,
- );
- return;
- }
-
- if (!newCall.groupCallId || newCall.groupCallId !== this.groupCallId) {
- logger.log(
- `GroupCall ${this.groupCallId} onIncomingCall() ignored because it doesn't match the current group call`,
- );
- newCall.reject();
- return;
- }
-
- const opponentUserId = newCall.getOpponentMember()?.userId;
- if (opponentUserId === undefined) {
- logger.warn(`GroupCall ${this.groupCallId} onIncomingCall() incoming call with no member - ignoring`);
- return;
- }
-
- const deviceMap = this.calls.get(opponentUserId) ?? new Map<string, MatrixCall>();
- const prevCall = deviceMap.get(newCall.getOpponentDeviceId()!);
-
- if (prevCall?.callId === newCall.callId) return;
-
- logger.log(
- `GroupCall ${this.groupCallId} onIncomingCall() incoming call (userId=${opponentUserId}, callId=${newCall.callId})`,
- );
-
- if (prevCall) prevCall.hangup(CallErrorCode.Replaced, false);
-
- this.initCall(newCall);
-
- const feeds = this.getLocalFeeds().map((feed) => feed.clone());
- if (!this.callExpected(newCall)) {
- // Disable our tracks for users not explicitly participating in the
- // call but trying to receive the feeds
- for (const feed of feeds) {
- setTracksEnabled(feed.stream.getAudioTracks(), false);
- setTracksEnabled(feed.stream.getVideoTracks(), false);
- }
- }
- newCall.answerWithCallFeeds(feeds);
-
- deviceMap.set(newCall.getOpponentDeviceId()!, newCall);
- this.calls.set(opponentUserId, deviceMap);
- this.emit(GroupCallEvent.CallsChanged, this.calls);
- };
-
- /**
- * Determines whether a given participant expects us to call them (versus
- * them calling us).
- * @param userId - The participant's user ID.
- * @param deviceId - The participant's device ID.
- * @returns Whether we need to place an outgoing call to the participant.
- */
- private wantsOutgoingCall(userId: string, deviceId: string): boolean {
- const localUserId = this.client.getUserId()!;
- const localDeviceId = this.client.getDeviceId()!;
- return (
- // If a user's ID is less than our own, they'll call us
- userId >= localUserId &&
- // If this is another one of our devices, compare device IDs to tell whether it'll call us
- (userId !== localUserId || deviceId > localDeviceId)
- );
- }
-
- /**
- * Places calls to all participants that we're responsible for calling.
- */
- private placeOutgoingCalls(): void {
- let callsChanged = false;
-
- for (const [{ userId }, participantMap] of this.participants) {
- const callMap = this.calls.get(userId) ?? new Map<string, MatrixCall>();
-
- for (const [deviceId, participant] of participantMap) {
- const prevCall = callMap.get(deviceId);
-
- if (
- prevCall?.getOpponentSessionId() !== participant.sessionId &&
- this.wantsOutgoingCall(userId, deviceId)
- ) {
- callsChanged = true;
-
- if (prevCall !== undefined) {
- logger.debug(
- `GroupCall ${this.groupCallId} placeOutgoingCalls() replacing call (userId=${userId}, deviceId=${deviceId}, callId=${prevCall.callId})`,
- );
- prevCall.hangup(CallErrorCode.NewSession, false);
- }
-
- const newCall = createNewMatrixCall(this.client, this.room.roomId, {
- invitee: userId,
- opponentDeviceId: deviceId,
- opponentSessionId: participant.sessionId,
- groupCallId: this.groupCallId,
- });
-
- if (newCall === null) {
- logger.error(
- `GroupCall ${this.groupCallId} placeOutgoingCalls() failed to create call (userId=${userId}, device=${deviceId})`,
- );
- callMap.delete(deviceId);
- } else {
- this.initCall(newCall);
- callMap.set(deviceId, newCall);
-
- logger.debug(
- `GroupCall ${this.groupCallId} placeOutgoingCalls() placing call (userId=${userId}, deviceId=${deviceId}, sessionId=${participant.sessionId})`,
- );
-
- newCall
- .placeCallWithCallFeeds(
- this.getLocalFeeds().map((feed) => feed.clone()),
- participant.screensharing,
- )
- .then(() => {
- if (this.dataChannelsEnabled) {
- newCall.createDataChannel("datachannel", this.dataChannelOptions);
- }
- })
- .catch((e) => {
- logger.warn(
- `GroupCall ${this.groupCallId} placeOutgoingCalls() failed to place call (userId=${userId})`,
- e,
- );
-
- if (e instanceof CallError && e.code === GroupCallErrorCode.UnknownDevice) {
- this.emit(GroupCallEvent.Error, e);
- } else {
- this.emit(
- GroupCallEvent.Error,
- new GroupCallError(
- GroupCallErrorCode.PlaceCallFailed,
- `Failed to place call to ${userId}`,
- ),
- );
- }
-
- newCall.hangup(CallErrorCode.SignallingFailed, false);
- if (callMap.get(deviceId) === newCall) callMap.delete(deviceId);
- });
- }
- }
- }
-
- if (callMap.size > 0) {
- this.calls.set(userId, callMap);
- } else {
- this.calls.delete(userId);
- }
- }
-
- if (callsChanged) this.emit(GroupCallEvent.CallsChanged, this.calls);
- }
-
- /*
- * Room Member State
- */
-
- private getMemberStateEvents(): MatrixEvent[];
- private getMemberStateEvents(userId: string): MatrixEvent | null;
- private getMemberStateEvents(userId?: string): MatrixEvent[] | MatrixEvent | null {
- return userId === undefined
- ? this.room.currentState.getStateEvents(EventType.GroupCallMemberPrefix)
- : this.room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, userId);
- }
-
- private onRetryCallLoop = (): void => {
- let needsRetry = false;
-
- for (const [{ userId }, participantMap] of this.participants) {
- const callMap = this.calls.get(userId);
- let retriesMap = this.retryCallCounts.get(userId);
-
- for (const [deviceId, participant] of participantMap) {
- const call = callMap?.get(deviceId);
- const retries = retriesMap?.get(deviceId) ?? 0;
-
- if (
- call?.getOpponentSessionId() !== participant.sessionId &&
- this.wantsOutgoingCall(userId, deviceId) &&
- retries < 3
- ) {
- if (retriesMap === undefined) {
- retriesMap = new Map();
- this.retryCallCounts.set(userId, retriesMap);
- }
- retriesMap.set(deviceId, retries + 1);
- needsRetry = true;
- }
- }
- }
-
- if (needsRetry) this.placeOutgoingCalls();
- };
-
- private initCall(call: MatrixCall): void {
- const opponentMemberId = getCallUserId(call);
-
- if (!opponentMemberId) {
- throw new Error("Cannot init call without user id");
- }
-
- const onCallFeedsChanged = (): void => this.onCallFeedsChanged(call);
- const onCallStateChanged = (state: CallState, oldState?: CallState): void =>
- this.onCallStateChanged(call, state, oldState);
- const onCallHangup = this.onCallHangup;
- const onCallReplaced = (newCall: MatrixCall): void => this.onCallReplaced(call, newCall);
-
- let deviceMap = this.callHandlers.get(opponentMemberId);
- if (deviceMap === undefined) {
- deviceMap = new Map();
- this.callHandlers.set(opponentMemberId, deviceMap);
- }
-
- deviceMap.set(call.getOpponentDeviceId()!, {
- onCallFeedsChanged,
- onCallStateChanged,
- onCallHangup,
- onCallReplaced,
- });
-
- call.on(CallEvent.FeedsChanged, onCallFeedsChanged);
- call.on(CallEvent.State, onCallStateChanged);
- call.on(CallEvent.Hangup, onCallHangup);
- call.on(CallEvent.Replaced, onCallReplaced);
-
- call.isPtt = this.isPtt;
-
- this.reEmitter.reEmit(call, Object.values(CallEvent));
-
- call.initStats(this.stats);
-
- onCallFeedsChanged();
- }
-
- private disposeCall(call: MatrixCall, hangupReason: CallErrorCode): void {
- const opponentMemberId = getCallUserId(call);
- const opponentDeviceId = call.getOpponentDeviceId()!;
-
- if (!opponentMemberId) {
- throw new Error("Cannot dispose call without user id");
- }
-
- const deviceMap = this.callHandlers.get(opponentMemberId)!;
- const { onCallFeedsChanged, onCallStateChanged, onCallHangup, onCallReplaced } =
- deviceMap.get(opponentDeviceId)!;
-
- call.removeListener(CallEvent.FeedsChanged, onCallFeedsChanged);
- call.removeListener(CallEvent.State, onCallStateChanged);
- call.removeListener(CallEvent.Hangup, onCallHangup);
- call.removeListener(CallEvent.Replaced, onCallReplaced);
-
- deviceMap.delete(opponentMemberId);
- if (deviceMap.size === 0) this.callHandlers.delete(opponentMemberId);
-
- if (call.hangupReason === CallErrorCode.Replaced) {
- return;
- }
-
- const usermediaFeed = this.getUserMediaFeed(opponentMemberId, opponentDeviceId);
-
- if (usermediaFeed) {
- this.removeUserMediaFeed(usermediaFeed);
- }
-
- const screenshareFeed = this.getScreenshareFeed(opponentMemberId, opponentDeviceId);
-
- if (screenshareFeed) {
- this.removeScreenshareFeed(screenshareFeed);
- }
- }
-
- private onCallFeedsChanged = (call: MatrixCall): void => {
- const opponentMemberId = getCallUserId(call);
- const opponentDeviceId = call.getOpponentDeviceId()!;
-
- if (!opponentMemberId) {
- throw new Error("Cannot change call feeds without user id");
- }
-
- const currentUserMediaFeed = this.getUserMediaFeed(opponentMemberId, opponentDeviceId);
- const remoteUsermediaFeed = call.remoteUsermediaFeed;
- const remoteFeedChanged = remoteUsermediaFeed !== currentUserMediaFeed;
-
- if (remoteFeedChanged) {
- if (!currentUserMediaFeed && remoteUsermediaFeed) {
- this.addUserMediaFeed(remoteUsermediaFeed);
- } else if (currentUserMediaFeed && remoteUsermediaFeed) {
- this.replaceUserMediaFeed(currentUserMediaFeed, remoteUsermediaFeed);
- } else if (currentUserMediaFeed && !remoteUsermediaFeed) {
- this.removeUserMediaFeed(currentUserMediaFeed);
- }
- }
-
- const currentScreenshareFeed = this.getScreenshareFeed(opponentMemberId, opponentDeviceId);
- const remoteScreensharingFeed = call.remoteScreensharingFeed;
- const remoteScreenshareFeedChanged = remoteScreensharingFeed !== currentScreenshareFeed;
-
- if (remoteScreenshareFeedChanged) {
- if (!currentScreenshareFeed && remoteScreensharingFeed) {
- this.addScreenshareFeed(remoteScreensharingFeed);
- } else if (currentScreenshareFeed && remoteScreensharingFeed) {
- this.replaceScreenshareFeed(currentScreenshareFeed, remoteScreensharingFeed);
- } else if (currentScreenshareFeed && !remoteScreensharingFeed) {
- this.removeScreenshareFeed(currentScreenshareFeed);
- }
- }
- };
-
- private onCallStateChanged = (call: MatrixCall, state: CallState, _oldState: CallState | undefined): void => {
- if (state === CallState.Ended) return;
-
- const audioMuted = this.localCallFeed!.isAudioMuted();
-
- if (call.localUsermediaStream && call.isMicrophoneMuted() !== audioMuted) {
- call.setMicrophoneMuted(audioMuted);
- }
-
- const videoMuted = this.localCallFeed!.isVideoMuted();
-
- if (call.localUsermediaStream && call.isLocalVideoMuted() !== videoMuted) {
- call.setLocalVideoMuted(videoMuted);
- }
-
- const opponentUserId = call.getOpponentMember()?.userId;
- if (state === CallState.Connected && opponentUserId) {
- const retriesMap = this.retryCallCounts.get(opponentUserId);
- retriesMap?.delete(call.getOpponentDeviceId()!);
- if (retriesMap?.size === 0) this.retryCallCounts.delete(opponentUserId);
- }
- };
-
- private onCallHangup = (call: MatrixCall): void => {
- if (call.hangupReason === CallErrorCode.Replaced) return;
-
- const opponentUserId = call.getOpponentMember()?.userId ?? this.room.getMember(call.invitee!)!.userId;
- const deviceMap = this.calls.get(opponentUserId);
-
- // Sanity check that this call is in fact in the map
- if (deviceMap?.get(call.getOpponentDeviceId()!) === call) {
- this.disposeCall(call, call.hangupReason as CallErrorCode);
- deviceMap.delete(call.getOpponentDeviceId()!);
- if (deviceMap.size === 0) this.calls.delete(opponentUserId);
- this.emit(GroupCallEvent.CallsChanged, this.calls);
- }
- };
-
- private onCallReplaced = (prevCall: MatrixCall, newCall: MatrixCall): void => {
- const opponentUserId = prevCall.getOpponentMember()!.userId;
-
- let deviceMap = this.calls.get(opponentUserId);
- if (deviceMap === undefined) {
- deviceMap = new Map();
- this.calls.set(opponentUserId, deviceMap);
- }
-
- prevCall.hangup(CallErrorCode.Replaced, false);
- this.initCall(newCall);
- deviceMap.set(prevCall.getOpponentDeviceId()!, newCall);
- this.emit(GroupCallEvent.CallsChanged, this.calls);
- };
-
- /*
- * UserMedia CallFeed Event Handlers
- */
-
- public getUserMediaFeed(userId: string, deviceId: string): CallFeed | undefined {
- return this.userMediaFeeds.find((f) => f.userId === userId && f.deviceId! === deviceId);
- }
-
- private addUserMediaFeed(callFeed: CallFeed): void {
- this.userMediaFeeds.push(callFeed);
- callFeed.measureVolumeActivity(true);
- this.emit(GroupCallEvent.UserMediaFeedsChanged, this.userMediaFeeds);
- }
-
- private replaceUserMediaFeed(existingFeed: CallFeed, replacementFeed: CallFeed): void {
- const feedIndex = this.userMediaFeeds.findIndex(
- (f) => f.userId === existingFeed.userId && f.deviceId! === existingFeed.deviceId,
- );
-
- if (feedIndex === -1) {
- throw new Error("Couldn't find user media feed to replace");
- }
-
- this.userMediaFeeds.splice(feedIndex, 1, replacementFeed);
-
- existingFeed.dispose();
- replacementFeed.measureVolumeActivity(true);
- this.emit(GroupCallEvent.UserMediaFeedsChanged, this.userMediaFeeds);
- }
-
- private removeUserMediaFeed(callFeed: CallFeed): void {
- const feedIndex = this.userMediaFeeds.findIndex(
- (f) => f.userId === callFeed.userId && f.deviceId! === callFeed.deviceId,
- );
-
- if (feedIndex === -1) {
- throw new Error("Couldn't find user media feed to remove");
- }
-
- this.userMediaFeeds.splice(feedIndex, 1);
-
- callFeed.dispose();
- this.emit(GroupCallEvent.UserMediaFeedsChanged, this.userMediaFeeds);
-
- if (this.activeSpeaker === callFeed) {
- this.activeSpeaker = this.userMediaFeeds[0];
- this.emit(GroupCallEvent.ActiveSpeakerChanged, this.activeSpeaker);
- }
- }
-
- private onActiveSpeakerLoop = (): void => {
- let topAvg: number | undefined = undefined;
- let nextActiveSpeaker: CallFeed | undefined = undefined;
-
- for (const callFeed of this.userMediaFeeds) {
- if (callFeed.isLocal() && this.userMediaFeeds.length > 1) continue;
-
- const total = callFeed.speakingVolumeSamples.reduce(
- (acc, volume) => acc + Math.max(volume, SPEAKING_THRESHOLD),
- );
- const avg = total / callFeed.speakingVolumeSamples.length;
-
- if (!topAvg || avg > topAvg) {
- topAvg = avg;
- nextActiveSpeaker = callFeed;
- }
- }
-
- if (nextActiveSpeaker && this.activeSpeaker !== nextActiveSpeaker && topAvg && topAvg > SPEAKING_THRESHOLD) {
- this.activeSpeaker = nextActiveSpeaker;
- this.emit(GroupCallEvent.ActiveSpeakerChanged, this.activeSpeaker);
- }
- };
-
- /*
- * Screenshare Call Feed Event Handlers
- */
-
- public getScreenshareFeed(userId: string, deviceId: string): CallFeed | undefined {
- return this.screenshareFeeds.find((f) => f.userId === userId && f.deviceId! === deviceId);
- }
-
- private addScreenshareFeed(callFeed: CallFeed): void {
- this.screenshareFeeds.push(callFeed);
- this.emit(GroupCallEvent.ScreenshareFeedsChanged, this.screenshareFeeds);
- }
-
- private replaceScreenshareFeed(existingFeed: CallFeed, replacementFeed: CallFeed): void {
- const feedIndex = this.screenshareFeeds.findIndex(
- (f) => f.userId === existingFeed.userId && f.deviceId! === existingFeed.deviceId,
- );
-
- if (feedIndex === -1) {
- throw new Error("Couldn't find screenshare feed to replace");
- }
-
- this.screenshareFeeds.splice(feedIndex, 1, replacementFeed);
-
- existingFeed.dispose();
- this.emit(GroupCallEvent.ScreenshareFeedsChanged, this.screenshareFeeds);
- }
-
- private removeScreenshareFeed(callFeed: CallFeed): void {
- const feedIndex = this.screenshareFeeds.findIndex(
- (f) => f.userId === callFeed.userId && f.deviceId! === callFeed.deviceId,
- );
-
- if (feedIndex === -1) {
- throw new Error("Couldn't find screenshare feed to remove");
- }
-
- this.screenshareFeeds.splice(feedIndex, 1);
-
- callFeed.dispose();
- this.emit(GroupCallEvent.ScreenshareFeedsChanged, this.screenshareFeeds);
- }
-
- /**
- * Recalculates and updates the participant map to match the room state.
- */
- private updateParticipants(): void {
- const localMember = this.room.getMember(this.client.getUserId()!)!;
- if (!localMember) {
- // The client hasn't fetched enough of the room state to get our own member
- // event. This probably shouldn't happen, but sanity check & exit for now.
- logger.warn(
- `GroupCall ${this.groupCallId} updateParticipants() tried to update participants before local room member is available`,
- );
- return;
- }
-
- if (this.participantsExpirationTimer !== null) {
- clearTimeout(this.participantsExpirationTimer);
- this.participantsExpirationTimer = null;
- }
-
- if (this.state === GroupCallState.Ended) {
- this.participants = new Map();
- return;
- }
-
- const participants = new Map<RoomMember, Map<string, ParticipantState>>();
- const now = Date.now();
- const entered = this.state === GroupCallState.Entered || this.enteredViaAnotherSession;
- let nextExpiration = Infinity;
-
- for (const e of this.getMemberStateEvents()) {
- const member = this.room.getMember(e.getStateKey()!);
- const content = e.getContent<Record<any, unknown>>();
- const calls: Record<any, unknown>[] = Array.isArray(content["m.calls"]) ? content["m.calls"] : [];
- const call = calls.find((call) => call["m.call_id"] === this.groupCallId);
- const devices: Record<any, unknown>[] = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : [];
-
- // Filter out invalid and expired devices
- let validDevices = devices.filter(
- (d) =>
- typeof d.device_id === "string" &&
- typeof d.session_id === "string" &&
- typeof d.expires_ts === "number" &&
- d.expires_ts > now &&
- Array.isArray(d.feeds),
- ) as unknown as IGroupCallRoomMemberDevice[];
-
- // Apply local echo for the unentered case
- if (!entered && member?.userId === this.client.getUserId()!) {
- validDevices = validDevices.filter((d) => d.device_id !== this.client.getDeviceId()!);
- }
-
- // Must have a connected device and be joined to the room
- if (validDevices.length > 0 && member?.membership === "join") {
- const deviceMap = new Map<string, ParticipantState>();
- participants.set(member, deviceMap);
-
- for (const d of validDevices) {
- deviceMap.set(d.device_id, {
- sessionId: d.session_id,
- screensharing: d.feeds.some((f) => f.purpose === SDPStreamMetadataPurpose.Screenshare),
- });
- if (d.expires_ts < nextExpiration) nextExpiration = d.expires_ts;
- }
- }
- }
-
- // Apply local echo for the entered case
- if (entered) {
- let deviceMap = participants.get(localMember);
- if (deviceMap === undefined) {
- deviceMap = new Map();
- participants.set(localMember, deviceMap);
- }
-
- if (!deviceMap.has(this.client.getDeviceId()!)) {
- deviceMap.set(this.client.getDeviceId()!, {
- sessionId: this.client.getSessionId(),
- screensharing: this.getLocalFeeds().some((f) => f.purpose === SDPStreamMetadataPurpose.Screenshare),
- });
- }
- }
-
- this.participants = participants;
- if (nextExpiration < Infinity) {
- this.participantsExpirationTimer = setTimeout(() => this.updateParticipants(), nextExpiration - now);
- }
- }
-
- /**
- * Updates the local user's member state with the devices returned by the given function.
- * @param fn - A function from the current devices to the new devices. If it
- * returns null, the update will be skipped.
- * @param keepAlive - Whether the request should outlive the window.
- */
- private async updateDevices(
- fn: (devices: IGroupCallRoomMemberDevice[]) => IGroupCallRoomMemberDevice[] | null,
- keepAlive = false,
- ): Promise<void> {
- const now = Date.now();
- const localUserId = this.client.getUserId()!;
-
- const event = this.getMemberStateEvents(localUserId);
- const content = event?.getContent<Record<any, unknown>>() ?? {};
- const calls: Record<any, unknown>[] = Array.isArray(content["m.calls"]) ? content["m.calls"] : [];
-
- let call: Record<any, unknown> | null = null;
- const otherCalls: Record<any, unknown>[] = [];
- for (const c of calls) {
- if (c["m.call_id"] === this.groupCallId) {
- call = c;
- } else {
- otherCalls.push(c);
- }
- }
- if (call === null) call = {};
-
- const devices: Record<any, unknown>[] = Array.isArray(call["m.devices"]) ? call["m.devices"] : [];
-
- // Filter out invalid and expired devices
- const validDevices = devices.filter(
- (d) =>
- typeof d.device_id === "string" &&
- typeof d.session_id === "string" &&
- typeof d.expires_ts === "number" &&
- d.expires_ts > now &&
- Array.isArray(d.feeds),
- ) as unknown as IGroupCallRoomMemberDevice[];
-
- const newDevices = fn(validDevices);
- if (newDevices === null) return;
-
- const newCalls = [...(otherCalls as unknown as IGroupCallRoomMemberCallState[])];
- if (newDevices.length > 0) {
- newCalls.push({
- ...call,
- "m.call_id": this.groupCallId,
- "m.devices": newDevices,
- });
- }
-
- const newContent: IGroupCallRoomMemberState = { "m.calls": newCalls };
-
- await this.client.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, newContent, localUserId, {
- keepAlive,
- });
- }
-
- private async addDeviceToMemberState(): Promise<void> {
- await this.updateDevices((devices) => [
- ...devices.filter((d) => d.device_id !== this.client.getDeviceId()!),
- {
- device_id: this.client.getDeviceId()!,
- session_id: this.client.getSessionId(),
- expires_ts: Date.now() + DEVICE_TIMEOUT,
- feeds: this.getLocalFeeds().map((feed) => ({ purpose: feed.purpose })),
- // TODO: Add data channels
- },
- ]);
- }
-
- private async updateMemberState(): Promise<void> {
- // Clear the old update interval before proceeding
- if (this.resendMemberStateTimer !== null) {
- clearInterval(this.resendMemberStateTimer);
- this.resendMemberStateTimer = null;
- }
-
- if (this.state === GroupCallState.Entered) {
- // Add the local device
- await this.addDeviceToMemberState();
-
- // Resend the state event every so often so it doesn't become stale
- this.resendMemberStateTimer = setInterval(async () => {
- logger.log(`GroupCall ${this.groupCallId} updateMemberState() resending call member state"`);
- try {
- await this.addDeviceToMemberState();
- } catch (e) {
- logger.error(
- `GroupCall ${this.groupCallId} updateMemberState() failed to resend call member state`,
- e,
- );
- }
- }, (DEVICE_TIMEOUT * 3) / 4);
- } else {
- // Remove the local device
- await this.updateDevices(
- (devices) => devices.filter((d) => d.device_id !== this.client.getDeviceId()!),
- true,
- );
- }
- }
-
- /**
- * Cleans up our member state by filtering out logged out devices, inactive
- * devices, and our own device (if we know we haven't entered).
- */
- public async cleanMemberState(): Promise<void> {
- const { devices: myDevices } = await this.client.getDevices();
- const deviceMap = new Map<string, IMyDevice>(myDevices.map((d) => [d.device_id, d]));
-
- // updateDevices takes care of filtering out inactive devices for us
- await this.updateDevices((devices) => {
- const newDevices = devices.filter((d) => {
- const device = deviceMap.get(d.device_id);
- return (
- device?.last_seen_ts !== undefined &&
- !(
- d.device_id === this.client.getDeviceId()! &&
- this.state !== GroupCallState.Entered &&
- !this.enteredViaAnotherSession
- )
- );
- });
-
- // Skip the update if the devices are unchanged
- return newDevices.length === devices.length ? null : newDevices;
- });
- }
-
- private onRoomState = (): void => this.updateParticipants();
-
- private onParticipantsChanged = (): void => {
- // Re-run setTracksEnabled on all calls, so that participants that just
- // left get denied access to our media, and participants that just
- // joined get granted access
- this.forEachCall((call) => {
- const expected = this.callExpected(call);
- for (const feed of call.getLocalFeeds()) {
- setTracksEnabled(feed.stream.getAudioTracks(), !feed.isAudioMuted() && expected);
- setTracksEnabled(feed.stream.getVideoTracks(), !feed.isVideoMuted() && expected);
- }
- });
-
- if (this.state === GroupCallState.Entered) this.placeOutgoingCalls();
- };
-
- private onStateChanged = (newState: GroupCallState, oldState: GroupCallState): void => {
- if (
- newState === GroupCallState.Entered ||
- oldState === GroupCallState.Entered ||
- newState === GroupCallState.Ended
- ) {
- // We either entered, left, or ended the call
- this.updateParticipants();
- this.updateMemberState().catch((e) =>
- logger.error(
- `GroupCall ${this.groupCallId} onStateChanged() failed to update member state devices"`,
- e,
- ),
- );
- }
- };
-
- private onLocalFeedsChanged = (): void => {
- if (this.state === GroupCallState.Entered) {
- this.updateMemberState().catch((e) =>
- logger.error(
- `GroupCall ${this.groupCallId} onLocalFeedsChanged() failed to update member state feeds`,
- e,
- ),
- );
- }
- };
-
- public getGroupCallStats(): GroupCallStats {
- return this.stats;
- }
-}