matrix-js-sdk
Version:
Matrix Client-Server SDK for Javascript
1,064 lines (1,030 loc) • 49.2 kB
JavaScript
;
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.OtherUserSpeakingError = exports.GroupCallUnknownDeviceError = exports.GroupCallType = exports.GroupCallTerminationReason = exports.GroupCallState = exports.GroupCallIntent = exports.GroupCallEvent = exports.GroupCallErrorCode = exports.GroupCallError = exports.GroupCall = void 0;
var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
var _typedEventEmitter = require("../models/typed-event-emitter");
var _callFeed = require("./callFeed");
var _call = require("./call");
var _roomState = require("../models/room-state");
var _logger = require("../logger");
var _ReEmitter = require("../ReEmitter");
var _callEventTypes = require("./callEventTypes");
var _event = require("../@types/event");
var _callEventHandler = require("./callEventHandler");
var _groupCallEventHandler = require("./groupCallEventHandler");
var _utils = require("../utils");
function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { (0, _defineProperty2.default)(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
let GroupCallIntent;
exports.GroupCallIntent = GroupCallIntent;
(function (GroupCallIntent) {
GroupCallIntent["Ring"] = "m.ring";
GroupCallIntent["Prompt"] = "m.prompt";
GroupCallIntent["Room"] = "m.room";
})(GroupCallIntent || (exports.GroupCallIntent = GroupCallIntent = {}));
let GroupCallType;
exports.GroupCallType = GroupCallType;
(function (GroupCallType) {
GroupCallType["Video"] = "m.video";
GroupCallType["Voice"] = "m.voice";
})(GroupCallType || (exports.GroupCallType = GroupCallType = {}));
let GroupCallTerminationReason;
exports.GroupCallTerminationReason = GroupCallTerminationReason;
(function (GroupCallTerminationReason) {
GroupCallTerminationReason["CallEnded"] = "call_ended";
})(GroupCallTerminationReason || (exports.GroupCallTerminationReason = GroupCallTerminationReason = {}));
let GroupCallEvent;
exports.GroupCallEvent = GroupCallEvent;
(function (GroupCallEvent) {
GroupCallEvent["GroupCallStateChanged"] = "group_call_state_changed";
GroupCallEvent["ActiveSpeakerChanged"] = "active_speaker_changed";
GroupCallEvent["CallsChanged"] = "calls_changed";
GroupCallEvent["UserMediaFeedsChanged"] = "user_media_feeds_changed";
GroupCallEvent["ScreenshareFeedsChanged"] = "screenshare_feeds_changed";
GroupCallEvent["LocalScreenshareStateChanged"] = "local_screenshare_state_changed";
GroupCallEvent["LocalMuteStateChanged"] = "local_mute_state_changed";
GroupCallEvent["ParticipantsChanged"] = "participants_changed";
GroupCallEvent["Error"] = "error";
})(GroupCallEvent || (exports.GroupCallEvent = GroupCallEvent = {}));
let GroupCallErrorCode;
exports.GroupCallErrorCode = GroupCallErrorCode;
(function (GroupCallErrorCode) {
GroupCallErrorCode["NoUserMedia"] = "no_user_media";
GroupCallErrorCode["UnknownDevice"] = "unknown_device";
GroupCallErrorCode["PlaceCallFailed"] = "place_call_failed";
})(GroupCallErrorCode || (exports.GroupCallErrorCode = GroupCallErrorCode = {}));
class GroupCallError extends Error {
constructor(code, msg, err) {
// Still don't think there's any way to have proper nested errors
if (err) {
super(msg + ": " + err);
(0, _defineProperty2.default)(this, "code", void 0);
} else {
super(msg);
(0, _defineProperty2.default)(this, "code", void 0);
}
this.code = code;
}
}
exports.GroupCallError = GroupCallError;
class GroupCallUnknownDeviceError extends GroupCallError {
constructor(userId) {
super(GroupCallErrorCode.UnknownDevice, "No device found for " + userId);
this.userId = userId;
}
}
exports.GroupCallUnknownDeviceError = GroupCallUnknownDeviceError;
class OtherUserSpeakingError extends Error {
constructor() {
super("Cannot unmute: another user is speaking");
}
}
exports.OtherUserSpeakingError = OtherUserSpeakingError;
let GroupCallState;
exports.GroupCallState = GroupCallState;
(function (GroupCallState) {
GroupCallState["LocalCallFeedUninitialized"] = "local_call_feed_uninitialized";
GroupCallState["InitializingLocalCallFeed"] = "initializing_local_call_feed";
GroupCallState["LocalCallFeedInitialized"] = "local_call_feed_initialized";
GroupCallState["Entered"] = "entered";
GroupCallState["Ended"] = "ended";
})(GroupCallState || (exports.GroupCallState = GroupCallState = {}));
const DEVICE_TIMEOUT = 1000 * 60 * 60; // 1 hour
function getCallUserId(call) {
var _call$getOpponentMemb;
return ((_call$getOpponentMemb = call.getOpponentMember()) === null || _call$getOpponentMemb === void 0 ? void 0 : _call$getOpponentMemb.userId) || call.invitee || null;
}
class GroupCall extends _typedEventEmitter.TypedEventEmitter {
// Config
// user_id -> device_id -> MatrixCall
// user_id -> device_id -> ICallHandlers
// user_id -> device_id -> count
constructor(client, room, type, isPtt, intent, groupCallId, dataChannelsEnabled, dataChannelOptions) {
var _room$currentState$ge, _room$currentState$ge2;
super();
this.client = client;
this.room = room;
this.type = type;
this.isPtt = isPtt;
this.intent = intent;
this.dataChannelsEnabled = dataChannelsEnabled;
this.dataChannelOptions = dataChannelOptions;
(0, _defineProperty2.default)(this, "activeSpeakerInterval", 1000);
(0, _defineProperty2.default)(this, "retryCallInterval", 5000);
(0, _defineProperty2.default)(this, "participantTimeout", 1000 * 15);
(0, _defineProperty2.default)(this, "pttMaxTransmitTime", 1000 * 20);
(0, _defineProperty2.default)(this, "activeSpeaker", void 0);
(0, _defineProperty2.default)(this, "localCallFeed", void 0);
(0, _defineProperty2.default)(this, "localScreenshareFeed", void 0);
(0, _defineProperty2.default)(this, "localDesktopCapturerSourceId", void 0);
(0, _defineProperty2.default)(this, "userMediaFeeds", []);
(0, _defineProperty2.default)(this, "screenshareFeeds", []);
(0, _defineProperty2.default)(this, "groupCallId", void 0);
(0, _defineProperty2.default)(this, "calls", new Map());
(0, _defineProperty2.default)(this, "callHandlers", new Map());
(0, _defineProperty2.default)(this, "activeSpeakerLoopInterval", void 0);
(0, _defineProperty2.default)(this, "retryCallLoopInterval", void 0);
(0, _defineProperty2.default)(this, "retryCallCounts", new Map());
(0, _defineProperty2.default)(this, "reEmitter", void 0);
(0, _defineProperty2.default)(this, "transmitTimer", null);
(0, _defineProperty2.default)(this, "participantsExpirationTimer", null);
(0, _defineProperty2.default)(this, "resendMemberStateTimer", null);
(0, _defineProperty2.default)(this, "initWithAudioMuted", false);
(0, _defineProperty2.default)(this, "initWithVideoMuted", false);
(0, _defineProperty2.default)(this, "_state", GroupCallState.LocalCallFeedUninitialized);
(0, _defineProperty2.default)(this, "_participants", new Map());
(0, _defineProperty2.default)(this, "_creationTs", null);
(0, _defineProperty2.default)(this, "_enteredViaAnotherSession", false);
(0, _defineProperty2.default)(this, "onIncomingCall", newCall => {
var _newCall$getOpponentM, _this$calls$get;
// The incoming calls may be for another room, which we will ignore.
if (newCall.roomId !== this.room.roomId) {
return;
}
if (newCall.state !== _call.CallState.Ringing) {
_logger.logger.warn("Incoming call no longer in ringing state. Ignoring.");
return;
}
if (!newCall.groupCallId || newCall.groupCallId !== this.groupCallId) {
_logger.logger.log(`Incoming call with groupCallId ${newCall.groupCallId} ignored because it doesn't match the current group call`);
newCall.reject();
return;
}
const opponentUserId = (_newCall$getOpponentM = newCall.getOpponentMember()) === null || _newCall$getOpponentM === void 0 ? void 0 : _newCall$getOpponentM.userId;
if (opponentUserId === undefined) {
_logger.logger.warn("Incoming call with no member. Ignoring.");
return;
}
const deviceMap = (_this$calls$get = this.calls.get(opponentUserId)) !== null && _this$calls$get !== void 0 ? _this$calls$get : new Map();
const prevCall = deviceMap.get(newCall.getOpponentDeviceId());
if ((prevCall === null || prevCall === void 0 ? void 0 : prevCall.callId) === newCall.callId) return;
_logger.logger.log(`GroupCall: incoming call from ${opponentUserId} with ID ${newCall.callId}`);
if (prevCall) this.disposeCall(prevCall, _call.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);
});
(0, _defineProperty2.default)(this, "onRetryCallLoop", () => {
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) {
var _retriesMap$get, _retriesMap;
const call = callMap === null || callMap === void 0 ? void 0 : callMap.get(deviceId);
const retries = (_retriesMap$get = (_retriesMap = retriesMap) === null || _retriesMap === void 0 ? void 0 : _retriesMap.get(deviceId)) !== null && _retriesMap$get !== void 0 ? _retriesMap$get : 0;
if ((call === null || call === void 0 ? void 0 : 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();
});
(0, _defineProperty2.default)(this, "onCallFeedsChanged", call => {
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);
}
}
});
(0, _defineProperty2.default)(this, "onCallStateChanged", (call, state, _oldState) => {
var _call$getOpponentMemb2;
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$getOpponentMemb2 = call.getOpponentMember()) === null || _call$getOpponentMemb2 === void 0 ? void 0 : _call$getOpponentMemb2.userId;
if (state === _call.CallState.Connected && opponentUserId) {
const retriesMap = this.retryCallCounts.get(opponentUserId);
retriesMap === null || retriesMap === void 0 ? void 0 : retriesMap.delete(call.getOpponentDeviceId());
if ((retriesMap === null || retriesMap === void 0 ? void 0 : retriesMap.size) === 0) this.retryCallCounts.delete(opponentUserId);
}
});
(0, _defineProperty2.default)(this, "onCallHangup", call => {
var _call$getOpponentMemb3, _call$getOpponentMemb4;
if (call.hangupReason === _call.CallErrorCode.Replaced) return;
const opponentUserId = (_call$getOpponentMemb3 = (_call$getOpponentMemb4 = call.getOpponentMember()) === null || _call$getOpponentMemb4 === void 0 ? void 0 : _call$getOpponentMemb4.userId) !== null && _call$getOpponentMemb3 !== void 0 ? _call$getOpponentMemb3 : 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 === null || deviceMap === void 0 ? void 0 : deviceMap.get(call.getOpponentDeviceId())) === call) {
this.disposeCall(call, call.hangupReason);
deviceMap.delete(call.getOpponentDeviceId());
if (deviceMap.size === 0) this.calls.delete(opponentUserId);
this.emit(GroupCallEvent.CallsChanged, this.calls);
}
});
(0, _defineProperty2.default)(this, "onCallReplaced", (prevCall, newCall) => {
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, _call.CallErrorCode.Replaced);
this.initCall(newCall);
deviceMap.set(prevCall.getOpponentDeviceId(), newCall);
this.emit(GroupCallEvent.CallsChanged, this.calls);
});
(0, _defineProperty2.default)(this, "onActiveSpeakerLoop", () => {
let topAvg = undefined;
let nextActiveSpeaker = 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, _callFeed.SPEAKING_THRESHOLD));
const avg = total / callFeed.speakingVolumeSamples.length;
if (!topAvg || avg > topAvg) {
topAvg = avg;
nextActiveSpeaker = callFeed;
}
}
if (nextActiveSpeaker && this.activeSpeaker !== nextActiveSpeaker && topAvg && topAvg > _callFeed.SPEAKING_THRESHOLD) {
this.activeSpeaker = nextActiveSpeaker;
this.emit(GroupCallEvent.ActiveSpeakerChanged, this.activeSpeaker);
}
});
(0, _defineProperty2.default)(this, "onRoomState", () => this.updateParticipants());
(0, _defineProperty2.default)(this, "onParticipantsChanged", () => {
if (this.state === GroupCallState.Entered) this.placeOutgoingCalls();
});
(0, _defineProperty2.default)(this, "onStateChanged", (newState, oldState) => {
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.logger.error("Failed to update member state devices", e));
}
});
(0, _defineProperty2.default)(this, "onLocalFeedsChanged", () => {
if (this.state === GroupCallState.Entered) {
this.updateMemberState().catch(e => _logger.logger.error("Failed to update member state feeds", e));
}
});
this.reEmitter = new _ReEmitter.ReEmitter(this);
this.groupCallId = groupCallId !== null && groupCallId !== void 0 ? groupCallId : (0, _call.genCallID)();
this.creationTs = (_room$currentState$ge = (_room$currentState$ge2 = room.currentState.getStateEvents(_event.EventType.GroupCallPrefix, this.groupCallId)) === null || _room$currentState$ge2 === void 0 ? void 0 : _room$currentState$ge2.getTs()) !== null && _room$currentState$ge !== void 0 ? _room$currentState$ge : null;
this.updateParticipants();
room.on(_roomState.RoomStateEvent.Update, this.onRoomState);
this.on(GroupCallEvent.ParticipantsChanged, this.onParticipantsChanged);
this.on(GroupCallEvent.GroupCallStateChanged, this.onStateChanged);
this.on(GroupCallEvent.LocalScreenshareStateChanged, this.onLocalFeedsChanged);
}
async create() {
this.creationTs = Date.now();
this.client.groupCallEventHandler.groupCalls.set(this.room.roomId, this);
this.client.emit(_groupCallEventHandler.GroupCallEventHandlerEvent.Outgoing, this);
const groupCallState = {
"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, _event.EventType.GroupCallPrefix, groupCallState, this.groupCallId);
return this;
}
/**
* The group call's state.
*/
get state() {
return this._state;
}
set state(value) {
const prevValue = this._state;
if (value !== prevValue) {
this._state = value;
this.emit(GroupCallEvent.GroupCallStateChanged, value, prevValue);
}
}
/**
* The current participants in the call, as a map from members to device IDs
* to participant info.
*/
get participants() {
return this._participants;
}
set participants(value) {
const prevValue = this._participants;
const participantStateEqual = (x, y) => x.sessionId === y.sessionId && x.screensharing === y.screensharing;
const deviceMapsEqual = (x, y) => (0, _utils.mapsEqual)(x, y, participantStateEqual);
// Only update if the map actually changed
if (!(0, _utils.mapsEqual)(value, prevValue, deviceMapsEqual)) {
this._participants = value;
this.emit(GroupCallEvent.ParticipantsChanged, value);
}
}
/**
* The timestamp at which the call was created, or null if it has not yet
* been created.
*/
get creationTs() {
return this._creationTs;
}
set creationTs(value) {
this._creationTs = value;
}
/**
* Whether the local device has entered this call via another session, such
* as a widget.
*/
get enteredViaAnotherSession() {
return this._enteredViaAnotherSession;
}
set enteredViaAnotherSession(value) {
this._enteredViaAnotherSession = value;
this.updateParticipants();
}
/**
* Executes the given callback on all calls in this group call.
* @param f - The callback.
*/
forEachCall(f) {
for (const deviceMap of this.calls.values()) {
for (const call of deviceMap.values()) f(call);
}
}
getLocalFeeds() {
const feeds = [];
if (this.localCallFeed) feeds.push(this.localCallFeed);
if (this.localScreenshareFeed) feeds.push(this.localScreenshareFeed);
return feeds;
}
hasLocalParticipant() {
var _this$participants$ge, _this$participants$ge2;
return (_this$participants$ge = (_this$participants$ge2 = this.participants.get(this.room.getMember(this.client.getUserId()))) === null || _this$participants$ge2 === void 0 ? void 0 : _this$participants$ge2.has(this.client.getDeviceId())) !== null && _this$participants$ge !== void 0 ? _this$participants$ge : false;
}
async initLocalCallFeed() {
_logger.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;
let disposed = false;
const onState = state => {
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.CallFeed({
client: this.client,
roomId: this.room.roomId,
userId: this.client.getUserId(),
deviceId: this.client.getDeviceId(),
stream,
purpose: _callEventTypes.SDPStreamMetadataPurpose.Usermedia,
audioMuted: this.initWithAudioMuted || stream.getAudioTracks().length === 0 || this.isPtt,
videoMuted: this.initWithVideoMuted || stream.getVideoTracks().length === 0
});
(0, _call.setTracksEnabled)(stream.getAudioTracks(), !callFeed.isAudioMuted());
(0, _call.setTracksEnabled)(stream.getVideoTracks(), !callFeed.isVideoMuted());
this.localCallFeed = callFeed;
this.addUserMediaFeed(callFeed);
this.state = GroupCallState.LocalCallFeedInitialized;
return callFeed;
}
async updateLocalUsermediaStream(stream) {
if (this.localCallFeed) {
const oldStream = this.localCallFeed.stream;
this.localCallFeed.setNewStream(stream);
const micShouldBeMuted = this.localCallFeed.isAudioMuted();
const vidShouldBeMuted = this.localCallFeed.isVideoMuted();
_logger.logger.log(`groupCall ${this.groupCallId} updateLocalUsermediaStream oldStream ${oldStream.id} newStream ${stream.id} micShouldBeMuted ${micShouldBeMuted} vidShouldBeMuted ${vidShouldBeMuted}`);
(0, _call.setTracksEnabled)(stream.getAudioTracks(), !micShouldBeMuted);
(0, _call.setTracksEnabled)(stream.getVideoTracks(), !vidShouldBeMuted);
this.client.getMediaHandler().stopUserMediaStream(oldStream);
}
}
async enter() {
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.logger.log(`Entered group call ${this.groupCallId}`);
this.state = GroupCallState.Entered;
this.client.on(_callEventHandler.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);
}
dispose() {
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, _call.CallErrorCode.UserHangup));
this.calls.clear();
this.activeSpeaker = undefined;
clearInterval(this.activeSpeakerLoopInterval);
this.retryCallCounts.clear();
clearInterval(this.retryCallLoopInterval);
this.client.removeListener(_callEventHandler.CallEventHandlerEvent.Incoming, this.onIncomingCall);
}
leave() {
this.dispose();
this.state = GroupCallState.LocalCallFeedUninitialized;
}
async terminate(emitStateEvent = true) {
this.dispose();
this.room.off(_roomState.RoomStateEvent.Update, this.onRoomState);
this.client.groupCallEventHandler.groupCalls.delete(this.room.roomId);
this.client.emit(_groupCallEventHandler.GroupCallEventHandlerEvent.Ended, this);
this.state = GroupCallState.Ended;
if (emitStateEvent) {
const existingStateEvent = this.room.currentState.getStateEvents(_event.EventType.GroupCallPrefix, this.groupCallId);
await this.client.sendStateEvent(this.room.roomId, _event.EventType.GroupCallPrefix, _objectSpread(_objectSpread({}, existingStateEvent.getContent()), {}, {
"m.terminated": GroupCallTerminationReason.CallEnded
}), this.groupCallId);
}
}
/*
* Local Usermedia
*/
isLocalVideoMuted() {
if (this.localCallFeed) {
return this.localCallFeed.isVideoMuted();
}
return true;
}
isMicrophoneMuted() {
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
*/
async setMicrophoneMuted(muted) {
// 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 => {
var _call$localUsermediaF;
return (_call$localUsermediaF = call.localUsermediaFeed) === null || _call$localUsermediaF === void 0 ? void 0 : _call$localUsermediaF.setAudioVideoMuted(muted, null);
});
const sendUpdates = async () => {
const updates = [];
this.forEachCall(call => updates.push(call.sendMetadataUpdate()));
await Promise.all(updates).catch(e => _logger.logger.info("Failed to send some metadata updates", e));
};
if (sendUpdatesBefore) await sendUpdates();
if (this.localCallFeed) {
_logger.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.
(0, _call.setTracksEnabled)(this.localCallFeed.stream.getAudioTracks(), !muted);
} else {
_logger.logger.log(`groupCall ${this.groupCallId} setMicrophoneMuted no stream muted ${muted}`);
this.initWithAudioMuted = muted;
}
this.forEachCall(call => (0, _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
*/
async setLocalVideoMuted(muted) {
// 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.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);
(0, _call.setTracksEnabled)(this.localCallFeed.stream.getVideoTracks(), !muted);
} else {
_logger.logger.log(`groupCall ${this.groupCallId} setLocalVideoMuted no stream muted ${muted}`);
this.initWithVideoMuted = muted;
}
const updates = [];
this.forEachCall(call => updates.push(call.setLocalVideoMuted(muted)));
await Promise.all(updates);
this.emit(GroupCallEvent.LocalMuteStateChanged, this.isMicrophoneMuted(), muted);
return true;
}
async setScreensharingEnabled(enabled, opts = {}) {
if (enabled === this.isScreensharing()) {
return enabled;
}
if (enabled) {
try {
_logger.logger.log("Asking for screensharing permissions...");
const stream = await this.client.getMediaHandler().getScreensharingStream(opts);
for (const track of stream.getTracks()) {
const onTrackEnded = () => {
this.setScreensharingEnabled(false);
track.removeEventListener("ended", onTrackEnded);
};
track.addEventListener("ended", onTrackEnded);
}
_logger.logger.log("Screensharing permissions granted. Setting screensharing enabled on all calls");
this.localDesktopCapturerSourceId = opts.desktopCapturerSourceId;
this.localScreenshareFeed = new _callFeed.CallFeed({
client: this.client,
roomId: this.room.roomId,
userId: this.client.getUserId(),
deviceId: this.client.getDeviceId(),
stream,
purpose: _callEventTypes.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.logger.error("Enabling screensharing error", error);
this.emit(GroupCallEvent.Error, new GroupCallError(GroupCallErrorCode.NoUserMedia, "Failed to get screen-sharing stream: ", 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;
}
}
isScreensharing() {
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.
*/
/**
* 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.
*/
wantsOutgoingCall(userId, deviceId) {
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.
*/
placeOutgoingCalls() {
let callsChanged = false;
for (const [{
userId
}, participantMap] of this.participants) {
var _this$calls$get2;
const callMap = (_this$calls$get2 = this.calls.get(userId)) !== null && _this$calls$get2 !== void 0 ? _this$calls$get2 : new Map();
for (const [deviceId, participant] of participantMap) {
const prevCall = callMap.get(deviceId);
if ((prevCall === null || prevCall === void 0 ? void 0 : prevCall.getOpponentSessionId()) !== participant.sessionId && this.wantsOutgoingCall(userId, deviceId)) {
callsChanged = true;
if (prevCall !== undefined) {
_logger.logger.debug(`Replacing call ${prevCall.callId} to ${userId} ${deviceId}`);
this.disposeCall(prevCall, _call.CallErrorCode.NewSession);
}
const newCall = (0, _call.createNewMatrixCall)(this.client, this.room.roomId, {
invitee: userId,
opponentDeviceId: deviceId,
opponentSessionId: participant.sessionId,
groupCallId: this.groupCallId
});
if (newCall === null) {
_logger.logger.error(`Failed to create call with ${userId} ${deviceId}`);
callMap.delete(deviceId);
} else {
this.initCall(newCall);
callMap.set(deviceId, newCall);
_logger.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.logger.warn(`Failed to place call to ${userId}`, e);
if (e instanceof _call.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, _call.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
*/
getMemberStateEvents(userId) {
return userId === undefined ? this.room.currentState.getStateEvents(_event.EventType.GroupCallMemberPrefix) : this.room.currentState.getStateEvents(_event.EventType.GroupCallMemberPrefix, userId);
}
initCall(call) {
const opponentMemberId = getCallUserId(call);
if (!opponentMemberId) {
throw new Error("Cannot init call without user id");
}
const onCallFeedsChanged = () => this.onCallFeedsChanged(call);
const onCallStateChanged = (state, oldState) => this.onCallStateChanged(call, state, oldState);
const onCallHangup = this.onCallHangup;
const onCallReplaced = newCall => 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(_call.CallEvent.FeedsChanged, onCallFeedsChanged);
call.on(_call.CallEvent.State, onCallStateChanged);
call.on(_call.CallEvent.Hangup, onCallHangup);
call.on(_call.CallEvent.Replaced, onCallReplaced);
call.isPtt = this.isPtt;
this.reEmitter.reEmit(call, Object.values(_call.CallEvent));
onCallFeedsChanged();
}
disposeCall(call, hangupReason) {
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(_call.CallEvent.FeedsChanged, onCallFeedsChanged);
call.removeListener(_call.CallEvent.State, onCallStateChanged);
call.removeListener(_call.CallEvent.Hangup, onCallHangup);
call.removeListener(_call.CallEvent.Replaced, onCallReplaced);
deviceMap.delete(opponentMemberId);
if (deviceMap.size === 0) this.callHandlers.delete(opponentMemberId);
if (call.hangupReason === _call.CallErrorCode.Replaced) {
return;
}
if (call.state !== _call.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);
}
}
/*
* UserMedia CallFeed Event Handlers
*/
getUserMediaFeed(userId, deviceId) {
return this.userMediaFeeds.find(f => f.userId === userId && f.deviceId === deviceId);
}
addUserMediaFeed(callFeed) {
this.userMediaFeeds.push(callFeed);
callFeed.measureVolumeActivity(true);
this.emit(GroupCallEvent.UserMediaFeedsChanged, this.userMediaFeeds);
}
replaceUserMediaFeed(existingFeed, replacementFeed) {
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);
}
removeUserMediaFeed(callFeed) {
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);
}
}
/*
* Screenshare Call Feed Event Handlers
*/
getScreenshareFeed(userId, deviceId) {
return this.screenshareFeeds.find(f => f.userId === userId && f.deviceId === deviceId);
}
addScreenshareFeed(callFeed) {
this.screenshareFeeds.push(callFeed);
this.emit(GroupCallEvent.ScreenshareFeedsChanged, this.screenshareFeeds);
}
replaceScreenshareFeed(existingFeed, replacementFeed) {
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);
}
removeScreenshareFeed(callFeed) {
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.
*/
updateParticipants() {
if (this.participantsExpirationTimer !== null) {
clearTimeout(this.participantsExpirationTimer);
this.participantsExpirationTimer = null;
}
if (this.state === GroupCallState.Ended) {
this.participants = new Map();
return;
}
const participants = new Map();
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();
const calls = Array.isArray(content["m.calls"]) ? content["m.calls"] : [];
const call = calls.find(call => call["m.call_id"] === this.groupCallId);
const devices = Array.isArray(call === null || call === void 0 ? void 0 : 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));
// Apply local echo for the unentered case
if (!entered && (member === null || member === void 0 ? void 0 : 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 === null || member === void 0 ? void 0 : member.membership) === "join") {
const deviceMap = new Map();
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 === _callEventTypes.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 === _callEventTypes.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.
*/
async updateDevices(fn, keepAlive = false) {
var _event$getContent;
const now = Date.now();
const localUserId = this.client.getUserId();
const event = this.getMemberStateEvents(localUserId);
const content = (_event$getContent = event === null || event === void 0 ? void 0 : event.getContent()) !== null && _event$getContent !== void 0 ? _event$getContent : {};
const calls = Array.isArray(content["m.calls"]) ? content["m.calls"] : [];
let call = null;
const otherCalls = [];
for (const c of calls) {
if (c["m.call_id"] === this.groupCallId) {
call = c;
} else {
otherCalls.push(c);
}
}
if (call === null) call = {};
const devices = 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));
const newDevices = fn(validDevices);
if (newDevices === null) return;
const newCalls = [...otherCalls];
if (newDevices.length > 0) {
newCalls.push(_objectSpread(_objectSpread({}, call), {}, {
"m.call_id": this.groupCallId,
"m.devices": newDevices
}));
}
const newContent = {
"m.calls": newCalls
};
await this.client.sendStateEvent(this.room.roomId, _event.EventType.GroupCallMemberPrefix, newContent, localUserId, {
keepAlive
});
}
async addDeviceToMemberState() {
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
}]);
}
async updateMemberState() {
// 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.logger.log("Resending call member state");
try {
await this.addDeviceToMemberState();
} catch (e) {
_logger.logger.error("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).
*/
async cleanMemberState() {
const {
devices: myDevices
} = await this.client.getDevices();
const deviceMap = new Map(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 === null || device === void 0 ? void 0 : 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;
});
}
}
exports.GroupCall = GroupCall;
//# sourceMappingURL=groupCall.js.map