Files
matrix-js-sdk/src/webrtc/groupCall.ts
T
2021-10-22 11:27:02 -07:00

1001 lines
32 KiB
TypeScript

import EventEmitter from "events";
import { CallFeed, SPEAKING_THRESHOLD } from "./callFeed";
import { MatrixClient } from "../client";
import { CallErrorCode, CallEvent, CallState, genCallID, MatrixCall, setTracksEnabled } from "./call";
import { RoomMember } from "../models/room-member";
import { Room } from "../models/room";
import { logger } from "../logger";
import { ReEmitter } from "../ReEmitter";
import { SDPStreamMetadataPurpose } from "./callEventTypes";
import { createNewMatrixCall } from "./call";
import { ISendEventResponse } from "../@types/requests";
import { MatrixEvent } from "../models/event";
import { EventType } from "../@types/event";
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 enum GroupCallErrorCode {
NoUserMedia = "no_user_media"
}
export class GroupCallError extends Error {
code: string;
constructor(code: GroupCallErrorCode, msg: string, err: Error) {
// Still don't think there's any way to have proper nested errors
super(msg + ": " + err);
this.code = code;
}
}
export interface IGroupCallDataChannelOptions {
ordered: boolean;
maxPacketLifeTime: number;
maxRetransmits: number;
protocol: string;
}
export interface IGroupCallRoomMemberFeed {
purpose: SDPStreamMetadataPurpose;
// TODO: Sources for adaptive bitrate
}
export interface IGroupCallRoomMemberDevice {
"device_id": string;
"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",
Entering = "entering",
Entered = "entered",
Ended = "ended",
}
interface ICallHandlers {
onCallFeedsChanged: (feeds: CallFeed[]) => void;
onCallStateChanged: (state: CallState, oldState: CallState) => void;
onCallHangup: (call: MatrixCall) => void;
onCallReplaced: (newCall: MatrixCall) => void;
}
function getCallUserId(call: MatrixCall): string | null {
return call.getOpponentMember()?.userId || call.invitee || null;
}
export class GroupCall extends EventEmitter {
// Config
public activeSpeakerInterval = 1000;
public participantTimeout = 1000 * 15;
public state = GroupCallState.LocalCallFeedUninitialized;
public activeSpeaker?: string; // userId
public localCallFeed?: CallFeed;
public localScreenshareFeed?: CallFeed;
public localDesktopCapturerSourceId?: string;
public calls: MatrixCall[] = [];
public participants: RoomMember[] = [];
public userMediaFeeds: CallFeed[] = [];
public screenshareFeeds: CallFeed[] = [];
public groupCallId: string;
private callHandlers: Map<string, ICallHandlers> = new Map();
private activeSpeakerLoopTimeout?: number;
private reEmitter: ReEmitter;
constructor(
private client: MatrixClient,
public room: Room,
public type: GroupCallType,
public intent: GroupCallIntent,
groupCallId?: string,
private dataChannelsEnabled?: boolean,
private dataChannelOptions?: IGroupCallDataChannelOptions,
) {
super();
this.reEmitter = new ReEmitter(this);
this.groupCallId = groupCallId || genCallID();
const roomState = this.room.currentState;
const memberStateEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
logger.log("Processing initial members", memberStateEvents);
for (const stateEvent of memberStateEvents) {
this.onMemberStateChanged(stateEvent);
}
}
public async create() {
this.client.groupCallEventHandler.groupCalls.set(this.room.roomId, this);
await this.client.sendStateEvent(
this.room.roomId,
EventType.GroupCallPrefix,
{
"m.intent": this.intent,
"m.type": this.type,
// TODO: Specify datachannels
"dataChannelsEnabled": this.dataChannelsEnabled,
"dataChannelOptions": this.dataChannelOptions,
},
this.groupCallId,
);
return this;
}
private setState(newState: GroupCallState): void {
const oldState = this.state;
this.state = newState;
this.emit(GroupCallEvent.GroupCallStateChanged, newState, oldState);
}
public getLocalFeeds(): CallFeed[] {
const feeds = [];
if (this.localCallFeed) feeds.push(this.localCallFeed);
if (this.localScreenshareFeed) feeds.push(this.localScreenshareFeed);
return feeds;
}
public hasLocalParticipant(): boolean {
const userId = this.client.getUserId();
return this.participants.some((member) => member.userId === userId);
}
public async initLocalCallFeed(): Promise<CallFeed> {
if (this.state !== GroupCallState.LocalCallFeedUninitialized) {
throw new Error(`Cannot initialize local call feed in the "${this.state}" state.`);
}
this.setState(GroupCallState.InitializingLocalCallFeed);
let stream: MediaStream;
try {
stream = await this.client.getMediaHandler().getUserMediaStream(true, this.type === GroupCallType.Video);
} catch (error) {
this.setState(GroupCallState.LocalCallFeedUninitialized);
throw error;
}
const userId = this.client.getUserId();
const callFeed = new CallFeed({
client: this.client,
roomId: this.room.roomId,
userId,
stream,
purpose: SDPStreamMetadataPurpose.Usermedia,
audioMuted: stream.getAudioTracks().length === 0,
videoMuted: stream.getVideoTracks().length === 0,
});
this.localCallFeed = callFeed;
this.addUserMediaFeed(callFeed);
this.setState(GroupCallState.LocalCallFeedInitialized);
return callFeed;
}
public async updateLocalUsermediaStream() {
if (this.localCallFeed) {
const mediaHandler = this.client.getMediaHandler();
const stream = await mediaHandler.getUserMediaStream(true, this.type === GroupCallType.Video, true);
this.localCallFeed.setNewStream(stream);
}
}
public async enter() {
if (!(this.state === GroupCallState.LocalCallFeedUninitialized ||
this.state === GroupCallState.LocalCallFeedInitialized)) {
throw new Error(`Cannot enter call in the "${this.state}" state`);
}
if (this.state === GroupCallState.LocalCallFeedUninitialized) {
await this.initLocalCallFeed();
}
this.addParticipant(this.room.getMember(this.client.getUserId()));
this.sendMemberStateEvent();
this.activeSpeaker = null;
this.setState(GroupCallState.Entered);
logger.log(`Entered group call ${this.groupCallId}`);
logger.log("processing initial calls");
const calls = this.client.callEventHandler.calls.values();
for (const call of calls) {
this.onIncomingCall(call);
}
// Set up participants for the members currently in the room.
// Other members will be picked up by the RoomState.members event.
const roomState = this.room.currentState;
const memberStateEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
logger.log("Processing initial members");
for (const stateEvent of memberStateEvents) {
this.onMemberStateChanged(stateEvent);
}
this.client.on("Call.incoming", this.onIncomingCall);
this.onActiveSpeakerLoop();
}
private dispose() {
if (this.localCallFeed) {
this.removeUserMediaFeed(this.localCallFeed);
this.localCallFeed = null;
}
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.state !== GroupCallState.Entered) {
return;
}
this.removeParticipant(this.room.getMember(this.client.getUserId()));
this.removeMemberStateEvent();
while (this.calls.length > 0) {
this.removeCall(this.calls[this.calls.length - 1], CallErrorCode.UserHangup);
}
this.activeSpeaker = null;
clearTimeout(this.activeSpeakerLoopTimeout);
this.client.removeListener("Call.incoming", this.onIncomingCall);
}
public leave() {
this.dispose();
this.setState(GroupCallState.LocalCallFeedUninitialized);
}
public async terminate(emitStateEvent = true) {
this.dispose();
this.participants = [];
this.client.removeListener(
"RoomState.members",
this.onMemberStateChanged,
);
this.client.groupCallEventHandler.groupCalls.delete(this.room.roomId);
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,
);
}
this.client.emit("GroupCall.ended", this);
this.setState(GroupCallState.Ended);
}
/**
* Local Usermedia
*/
public isLocalVideoMuted() {
if (this.localCallFeed) {
return this.localCallFeed.isVideoMuted();
}
return true;
}
public isMicrophoneMuted() {
if (this.localCallFeed) {
return this.localCallFeed.isAudioMuted();
}
return true;
}
public async setMicrophoneMuted(muted) {
if (!await this.client.getMediaHandler().hasAudioDevice()) {
return false;
}
if (this.localCallFeed) {
this.localCallFeed.setAudioMuted(muted);
setTracksEnabled(this.localCallFeed.stream.getAudioTracks(), !muted);
}
for (const call of this.calls) {
call.setMicrophoneMuted(muted);
}
this.emit(GroupCallEvent.LocalMuteStateChanged, muted, this.isLocalVideoMuted());
}
public async setLocalVideoMuted(muted) {
if (!await this.client.getMediaHandler().hasVideoDevice()) {
return false;
}
if (this.localCallFeed) {
this.localCallFeed.setVideoMuted(muted);
setTracksEnabled(this.localCallFeed.stream.getVideoTracks(), !muted);
}
for (const call of this.calls) {
call.setLocalVideoMuted(muted);
}
this.emit(GroupCallEvent.LocalMuteStateChanged, this.isMicrophoneMuted(), muted);
}
public async setScreensharingEnabled(
enabled: boolean, desktopCapturerSourceId?: string,
): Promise<boolean> {
if (enabled === this.isScreensharing()) {
return enabled;
}
if (enabled) {
try {
logger.log("Asking for screensharing permissions...");
const stream = await this.client.getMediaHandler().getScreensharingStream(desktopCapturerSourceId);
logger.log("Screensharing permissions granted. Setting screensharing enabled on all calls");
this.localDesktopCapturerSourceId = desktopCapturerSourceId;
this.localScreenshareFeed = new CallFeed({
client: this.client,
roomId: this.room.roomId,
userId: this.client.getUserId(),
stream,
purpose: SDPStreamMetadataPurpose.Screenshare,
audioMuted: false,
videoMuted: false,
});
this.addScreenshareFeed(this.localScreenshareFeed);
this.emit(
GroupCallEvent.LocalScreenshareStateChanged,
true,
this.localScreenshareFeed,
this.localDesktopCapturerSourceId,
);
// TODO: handle errors
await Promise.all(this.calls.map(call => call.pushLocalFeed(this.localScreenshareFeed)));
await this.sendMemberStateEvent();
logger.log("screensharing enabled on all calls");
return true;
} catch (error) {
logger.error("enabling screensharing error", error);
this.emit(GroupCallEvent.Error,
new GroupCallError(GroupCallErrorCode.NoUserMedia, "Failed to get screen-sharing stream: ", error),
);
return false;
}
} else {
await Promise.all(this.calls.map(call => call.removeLocalFeed(this.localScreenshareFeed)));
this.client.getMediaHandler().stopScreensharingStream(this.localScreenshareFeed.stream);
this.removeScreenshareFeed(this.localScreenshareFeed);
this.localScreenshareFeed = undefined;
this.localDesktopCapturerSourceId = undefined;
await this.sendMemberStateEvent();
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) => {
// 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 opponentMemberId = newCall.getOpponentMember().userId;
const existingCall = this.getCallByUserId(opponentMemberId);
if (existingCall && existingCall.callId === newCall.callId) {
return;
}
logger.log(`GroupCall: incoming call from: ${opponentMemberId}`);
// Check if the user calling has an existing call and use this call instead.
if (existingCall) {
this.replaceCall(existingCall, newCall);
} else {
this.addCall(newCall);
}
newCall.answerWithCallFeeds(this.getLocalFeeds());
};
/**
* Room Member State
*/
private sendMemberStateEvent(): Promise<ISendEventResponse> {
const deviceId = this.client.getDeviceId();
return this.updateMemberCallState({
"m.call_id": this.groupCallId,
"m.devices": [
{
"device_id": deviceId,
"feeds": this.getLocalFeeds().map((feed) => ({
purpose: feed.purpose,
})),
// TODO: Add data channels
},
],
// TODO "m.foci"
});
}
private removeMemberStateEvent(): Promise<ISendEventResponse> {
return this.updateMemberCallState(undefined);
}
private async updateMemberCallState(memberCallState?: IGroupCallRoomMemberCallState): Promise<ISendEventResponse> {
const localUserId = this.client.getUserId();
const currentStateEvent = this.room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, localUserId);
const memberStateEvent = currentStateEvent?.getContent<IGroupCallRoomMemberState>();
let calls: IGroupCallRoomMemberCallState[] = [];
let existingCallIndex: number;
if (memberStateEvent) {
calls = memberStateEvent["m.calls"] || [];
existingCallIndex = calls.findIndex((call) => call && call["m.call_id"] === this.groupCallId);
} else {
existingCallIndex = -1;
}
if (existingCallIndex === -1) {
calls.push(memberCallState);
} else if (memberCallState) {
calls.splice(existingCallIndex, 1, memberCallState);
} else {
calls.splice(existingCallIndex, 1);
}
const content = {
"m.calls": calls,
};
logger.log("Sending group call member state event", content);
return this.client.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, content, localUserId);
}
public onMemberStateChanged = (event: MatrixEvent) => {
// The member events may be received for another room, which we will ignore.
if (event.getRoomId() !== this.room.roomId) {
return;
}
const member = this.room.getMember(event.getStateKey());
logger.log("Processing member state", member);
if (!member) {
return;
}
const callsState = event.getContent<IGroupCallRoomMemberState>()["m.calls"];
if (!callsState || !Array.isArray(callsState) || callsState.length === 0) {
logger.log(`Ignoring member state from ${member.userId} member not in any calls.`);
this.removeParticipant(member);
return;
}
logger.log(callsState);
// Currently we only support a single call per room. So grab the first call.
const callState = callsState[0];
const callId = callState["m.call_id"];
if (!callId) {
logger.warn(`Room member ${member.userId} does not have a valid m.call_id set. Ignoring.`);
this.removeParticipant(member);
return;
}
if (callId !== this.groupCallId) {
logger.log(`Call id ${callId} does not match group call id ${this.groupCallId}, ignoring.`);
this.removeParticipant(member);
return;
}
this.addParticipant(member);
// Don't process your own member.
const localUserId = this.client.getUserId();
if (member.userId === localUserId) {
return;
}
if (this.state !== GroupCallState.Entered) {
return;
}
const existingCall = this.getCallByUserId(member.userId);
if (existingCall) {
return;
}
const opponentDevice = this.getDeviceForMember(member.userId);
if (!opponentDevice) {
logger.warn(`No opponent device found for ${member.userId}, ignoring.`);
return;
}
const newCall = createNewMatrixCall(
this.client,
this.room.roomId,
{ invitee: member.userId, opponentDeviceId: opponentDevice.device_id, groupCallId: this.groupCallId },
);
const requestScreenshareFeed = opponentDevice.feeds.some(
(feed) => feed.purpose === SDPStreamMetadataPurpose.Screenshare);
newCall.placeCallWithCallFeeds(this.getLocalFeeds(), requestScreenshareFeed);
if (this.dataChannelsEnabled) {
newCall.createDataChannel("datachannel", this.dataChannelOptions);
}
this.addCall(newCall);
};
public getDeviceForMember(userId: string): IGroupCallRoomMemberDevice {
const memberStateEvent = this.room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, userId);
if (!memberStateEvent) {
return undefined;
}
const memberState = memberStateEvent.getContent<IGroupCallRoomMemberState>();
const memberGroupCallState = memberState["m.calls"]?.find((call) => call["m.call_id"] === this.groupCallId);
if (!memberGroupCallState) {
return undefined;
}
const memberDevices = memberGroupCallState["m.devices"];
if (!memberDevices || memberDevices.length === 0) {
return undefined;
}
// NOTE: For now we only support one device so we use the device id in the first source.
return memberDevices[0];
}
/**
* Call Event Handlers
*/
public getCallByUserId(userId: string): MatrixCall {
return this.calls.find((call) => getCallUserId(call) === userId);
}
private addCall(call: MatrixCall) {
this.calls.push(call);
this.initCall(call);
this.emit(GroupCallEvent.CallsChanged, this.calls);
}
private replaceCall(existingCall: MatrixCall, replacementCall: MatrixCall) {
const existingCallIndex = this.calls.indexOf(existingCall);
if (existingCallIndex === -1) {
throw new Error("Couldn't find call to replace");
}
this.calls.splice(existingCallIndex, 1, replacementCall);
this.disposeCall(existingCall, CallErrorCode.Replaced);
this.initCall(replacementCall);
this.emit(GroupCallEvent.CallsChanged, this.calls);
}
private removeCall(call: MatrixCall, hangupReason: CallErrorCode) {
this.disposeCall(call, hangupReason);
const callIndex = this.calls.indexOf(call);
if (callIndex === -1) {
throw new Error("Couldn't find call to remove");
}
this.calls.splice(callIndex, 1);
this.emit(GroupCallEvent.CallsChanged, this.calls);
}
private initCall(call: MatrixCall) {
const opponentMemberId = getCallUserId(call);
if (!opponentMemberId) {
throw new Error("Cannot init call without user id");
}
const onCallFeedsChanged = () => this.onCallFeedsChanged(call);
const onCallStateChanged =
(state: CallState, oldState: CallState) => this.onCallStateChanged(call, state, oldState);
const onCallHangup = this.onCallHangup;
const onCallReplaced = (newCall: MatrixCall) => this.replaceCall(call, newCall);
this.callHandlers.set(opponentMemberId, {
onCallFeedsChanged,
onCallStateChanged,
onCallHangup,
onCallReplaced,
});
call.on(CallEvent.FeedsChanged, onCallFeedsChanged);
call.on(CallEvent.State, onCallStateChanged);
call.on(CallEvent.Hangup, onCallHangup);
call.on(CallEvent.Replaced, onCallReplaced);
this.reEmitter.reEmit(call, Object.values(CallEvent));
onCallFeedsChanged();
}
private disposeCall(call: MatrixCall, hangupReason: CallErrorCode) {
const opponentMemberId = getCallUserId(call);
if (!opponentMemberId) {
throw new Error("Cannot dispose call without user id");
}
const {
onCallFeedsChanged,
onCallStateChanged,
onCallHangup,
onCallReplaced,
} = this.callHandlers.get(opponentMemberId);
call.removeListener(CallEvent.FeedsChanged, onCallFeedsChanged);
call.removeListener(CallEvent.State, onCallStateChanged);
call.removeListener(CallEvent.Hangup, onCallHangup);
call.removeListener(CallEvent.Replaced, onCallReplaced);
this.callHandlers.delete(opponentMemberId);
if (call.state !== CallState.Ended) {
call.hangup(hangupReason, false);
}
const usermediaFeed = this.getUserMediaFeedByUserId(opponentMemberId);
if (usermediaFeed) {
this.removeUserMediaFeed(usermediaFeed);
}
const screenshareFeed = this.getScreenshareFeedByUserId(opponentMemberId);
if (screenshareFeed) {
this.removeScreenshareFeed(screenshareFeed);
}
}
private onCallFeedsChanged = (call: MatrixCall) => {
const opponentMemberId = getCallUserId(call);
if (!opponentMemberId) {
throw new Error("Cannot change call feeds without user id");
}
const currentUserMediaFeed = this.getUserMediaFeedByUserId(opponentMemberId);
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.getScreenshareFeedByUserId(opponentMemberId);
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) => {
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);
}
};
private onCallHangup = (call: MatrixCall) => {
if (call.hangupReason === CallErrorCode.Replaced) {
return;
}
this.removeCall(call, call.hangupReason as CallErrorCode);
};
/**
* UserMedia CallFeed Event Handlers
*/
public getUserMediaFeedByUserId(userId: string) {
return this.userMediaFeeds.find((feed) => feed.userId === userId);
}
private addUserMediaFeed(callFeed: CallFeed) {
this.userMediaFeeds.push(callFeed);
callFeed.measureVolumeActivity(true);
this.emit(GroupCallEvent.UserMediaFeedsChanged, this.userMediaFeeds);
}
private replaceUserMediaFeed(existingFeed: CallFeed, replacementFeed: CallFeed) {
const feedIndex = this.userMediaFeeds.findIndex((feed) => feed.userId === existingFeed.userId);
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) {
const feedIndex = this.userMediaFeeds.findIndex((feed) => feed.userId === callFeed.userId);
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.userId &&
this.userMediaFeeds.length > 0
) {
this.activeSpeaker = this.userMediaFeeds[0].userId;
this.emit(GroupCallEvent.ActiveSpeakerChanged, this.activeSpeaker);
}
}
private onActiveSpeakerLoop = () => {
let topAvg: number;
let nextActiveSpeaker: string;
for (const callFeed of this.userMediaFeeds) {
if (callFeed.userId === this.client.getUserId() && this.userMediaFeeds.length > 1) {
continue;
}
let total = 0;
for (let i = 0; i < callFeed.speakingVolumeSamples.length; i++) {
const volume = callFeed.speakingVolumeSamples[i];
total += Math.max(volume, SPEAKING_THRESHOLD);
}
const avg = total / callFeed.speakingVolumeSamples.length;
if (!topAvg || avg > topAvg) {
topAvg = avg;
nextActiveSpeaker = callFeed.userId;
}
}
if (nextActiveSpeaker && this.activeSpeaker !== nextActiveSpeaker && topAvg > SPEAKING_THRESHOLD) {
this.activeSpeaker = nextActiveSpeaker;
this.emit(GroupCallEvent.ActiveSpeakerChanged, this.activeSpeaker);
}
this.activeSpeakerLoopTimeout = setTimeout(
this.onActiveSpeakerLoop,
this.activeSpeakerInterval,
);
};
/**
* Screenshare Call Feed Event Handlers
*/
public getScreenshareFeedByUserId(userId: string) {
return this.screenshareFeeds.find((feed) => feed.userId === userId);
}
private addScreenshareFeed(callFeed: CallFeed) {
logger.log("added screenshare feed");
this.screenshareFeeds.push(callFeed);
this.emit(GroupCallEvent.ScreenshareFeedsChanged, this.screenshareFeeds);
}
private replaceScreenshareFeed(existingFeed: CallFeed, replacementFeed: CallFeed) {
logger.log("replaced screenshare feed");
const feedIndex = this.screenshareFeeds.findIndex((feed) => feed.userId === existingFeed.userId);
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) {
logger.log("removed screenshare feed");
const feedIndex = this.screenshareFeeds.findIndex((feed) => feed.userId === callFeed.userId);
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);
}
/**
* Participant Management
*/
private addParticipant(member: RoomMember) {
if (this.participants.find((m) => m.userId === member.userId)) {
return;
}
this.participants.push(member);
this.emit(GroupCallEvent.ParticipantsChanged, this.participants);
this.client.emit("GroupCall.participants", this.participants, this);
}
private removeParticipant(member: RoomMember) {
const index = this.participants.findIndex((m) => m.userId === member.userId);
if (index === -1) {
return;
}
this.participants.splice(index, 1);
this.emit(GroupCallEvent.ParticipantsChanged, this.participants);
this.client.emit("GroupCall.participants", this.participants, this);
}
}