UNPKG

matrix-js-sdk

Version:
1,301 lines (1,072 loc) 54.3 kB
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"; 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 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 = "error", } export type GroupCallEventHandlerMap = { [GroupCallEvent.GroupCallStateChanged]: (newState: GroupCallState, oldState: GroupCallState) => void; [GroupCallEvent.ActiveSpeakerChanged]: (activeSpeaker: CallFeed | undefined) => void; [GroupCallEvent.CallsChanged]: (calls: Map<string, Map<string, MatrixCall>>) => 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 GroupCallErrorCode { NoUserMedia = "no_user_media", UnknownDevice = "unknown_device", PlaceCallFailed = "place_call_failed", } 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, GroupCallEventHandlerMap & CallEventHandlerMap > { // 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; 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; 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, ) { 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); } 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 ); } public async initLocalCallFeed(): Promise<CallFeed> { logger.log(`groupCall ${this.groupCallId} initLocalCallFeed`); if (this.state !== GroupCallState.LocalCallFeedUninitialized) { throw new Error(`Cannot initialize local call feed in the "${this.state}" state.`); } this.state = GroupCallState.InitializingLocalCallFeed; let stream: MediaStream; let disposed = false; const onState = (state: GroupCallState): void => { if (state === GroupCallState.LocalCallFeedUninitialized) { disposed = true; } }; this.on(GroupCallEvent.GroupCallStateChanged, onState); try { stream = await this.client.getMediaHandler().getUserMediaStream(true, this.type === GroupCallType.Video); } catch (error) { this.state = GroupCallState.LocalCallFeedUninitialized; throw error; } finally { this.off(GroupCallEvent.GroupCallStateChanged, onState); } // The call could've been disposed while we were waiting if (disposed) throw new Error("Group call disposed"); 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; return callFeed; } 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 oldStream ${oldStream.id} newStream ${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(`Entered group call ${this.groupCallId}`); 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.state !== GroupCallState.Entered) { return; } this.forEachCall((call) => this.disposeCall(call, CallErrorCode.UserHangup)); this.calls.clear(); this.activeSpeaker = undefined; clearInterval(this.activeSpeakerLoopInterval); this.retryCallCounts.clear(); clearInterval(this.retryCallLoopInterval); this.client.removeListener(CallEventHandlerEvent.Incoming, this.onIncomingCall); } 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("Failed to send some metadata updates", e)); }; if (sendUpdatesBefore) await sendUpdates(); if (this.localCallFeed) { logger.log( `groupCall ${this.groupCallId} setMicrophoneMuted stream ${this.localCallFeed.stream.id} muted ${muted}`, ); 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}`); this.initWithAudioMuted = muted; } this.forEachCall((call) => setTracksEnabled(call.localUsermediaFeed!.stream.getAudioTracks(), !muted)); 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) { logger.log( `groupCall ${this.groupCallId} setLocalVideoMuted stream ${this.localCallFeed.stream.id} muted ${muted}`, ); const stream = await this.client.getMediaHandler().getUserMediaStream(true, !muted); await this.updateLocalUsermediaStream(stream); this.localCallFeed.setAudioVideoMuted(null, muted); setTracksEnabled(this.localCallFeed.stream.getVideoTracks(), !muted); } else { logger.log(`groupCall ${this.groupCallId} setLocalVideoMuted no stream muted ${muted}`); this.initWithVideoMuted = muted; } const updates: Promise<unknown>[] = []; this.forEachCall((call) => updates.push(call.setLocalVideoMuted(muted))); await Promise.all(updates); 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("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("Screensharing permissions granted. 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("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("Incoming call no longer in ringing state. Ignoring."); return; } if (!newCall.groupCallId || newCall.groupCallId !== this.groupCallId) { logger.log( `Incoming call with groupCallId ${newCall.groupCallId} ignored because it doesn't match the current group call`, ); newCall.reject(); return; } const opponentUserId = newCall.getOpponentMember()?.userId; if (opponentUserId === undefined) { logger.warn("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: incoming call from ${opponentUserId} with ID ${newCall.callId}`); if (prevCall) this.disposeCall(prevCall, CallErrorCode.Replaced); this.initCall(newCall); newCall.answerWithCallFeeds(this.getLocalFeeds().map((feed) => feed.clone())); 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(`Replacing call ${prevCall.callId} to ${userId} ${deviceId}`); this.disposeCall(prevCall, CallErrorCode.NewSession); } const newCall = createNewMatrixCall(this.client, this.room.roomId, { invitee: userId, opponentDeviceId: deviceId, opponentSessionId: participant.sessionId, groupCallId: this.groupCallId, }); if (newCall === null) { logger.error(`Failed to create call with ${userId} ${deviceId}`); callMap.delete(deviceId); } else { this.initCall(newCall); callMap.set(deviceId, newCall); logger.debug(`Placing call to ${userId} ${deviceId} (session ${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(`Failed to place call to ${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}`, ), ); } this.disposeCall(newCall, CallErrorCode.SignallingFailed); 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)); 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; } if (call.state !== CallState.Ended) { call.hangup(hangupReason, false); } 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 => { 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); } this.disposeCall(prevCall, CallErrorCode.Replaced); 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 { 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) { const localMember = this.room.getMember(this.client.getUserId()!)!; 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 ===