diff options
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.ts | 1598 |
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; - } -} |