/**
* @namespace Room
* @memberOf FlashphonerSFU
*/
const { v4: uuidv4 } = require("uuid");
const constants = require("./constants");
const promises = require("./promise");
const logger = require("./logger");
const room = function(options) {
if (!options) {
throw new TypeError("No options provided");
}
const room = {};
let state = constants.SFU_ROOM_STATE.NEW;
let role = constants.SFU_PARTICIPANT_ROLE.PARTICIPANT;
let inviteId;
const callbacks = {};
const connection = options.connection;
const pc = options.pc;
const name = options.name;
const pin = options.pin;
let incomingMessageQueue = {};
//data channel
const dChannel = options.pc.createDataChannel("control");
dChannel.onopen = function() {
logger.log("DataChannel opened");
};
dChannel.onclose = function() {
logger.log("DataChannel closed");
};
dChannel.onerror = function(e) {
//console.error("Got error in data channel ", e);
};
dChannel.onmessage = function(msg) {
logger.log("dchannel ", "<==", msg);
const message = JSON.parse(msg.data);
if (message.type === constants.SFU_ROOM_EVENT.MESSAGE && message.message.message.indexOf("\"payload\":") !== -1) {
try {
let innerMessage = JSON.parse(message.message.message);
if (!incomingMessageQueue[innerMessage.id]) {
incomingMessageQueue[innerMessage.id] = [];
}
incomingMessageQueue[innerMessage.id].push(innerMessage);
if (innerMessage.last) {
let wholeMessage = "";
for (let i = 0; i < incomingMessageQueue[innerMessage.id].length; i++) {
wholeMessage += incomingMessageQueue[innerMessage.id][i].payload;
}
delete incomingMessageQueue[innerMessage.id];
message.message.message = wholeMessage;
notify(message.type, message.message);
}
} catch (e) {
window.log.info("Failed to process inner message: " + message.message);
notify(message.type, message.message);
}
} else if (message.type === constants.SFU_EVENT.ACK && promises.promised(message.internalMessageId)) {
promises.resolve(message.internalMessageId);
} else {
notify(message.type, message.message);
}
};
const dChannelSend = (msg) => {
logger.log("dchannel ", "==>", msg);
dChannel.send(msg);
};
const processEvent = function(e) {
logger.log("<==", e);
if (e.type === constants.SFU_ROOM_EVENT.REMOTE_SDP) {
if (state !== constants.SFU_ROOM_STATE.FAILED && state !== constants.SFU_ROOM_STATE.DISPOSED) {
switch (e.info.type) {
case "offer":
pc.setRemoteDescription(e.info).then(() => pc.createAnswer())
.then(answer => pc.setLocalDescription(answer))
.then(() => {
connection.send(constants.SFU_INTERNAL_API.UPDATE_ROOM_STATE, {
name: name,
pin: pin,
sdp: pc.localDescription.sdp
});
});
break;
case "answer":
pc.setRemoteDescription(e.info);
break;
}
}
} else if (e.type === constants.SFU_ROOM_EVENT.ROLE_ASSIGNED) {
role = e.role;
notify(e.type, e);
} else if (e.type === constants.SFU_ROOM_EVENT.CREATED) {
inviteId = e.inviteId;
if (!promises.resolve(e.internalMessageId, e)) {
notify(e.type, e);
}
} else if (e.type === constants.SFU_ROOM_EVENT.OPERATION_FAILED && promises.promised(e.internalMessageId)) {
promises.reject(e.internalMessageId, e);
} else if (e.type === constants.SFU_ROOM_EVENT.JOINED) {
if (!promises.resolve(e.internalMessageId, e)) {
notify(e.type, e);
}
} else if (e.type === constants.SFU_ROOM_EVENT.LEFT) {
if (!promises.resolve(e.internalMessageId, e)) {
notify(e.type, e);
}
} else if (e.type === constants.SFU_ROOM_EVENT.ADD_TRACKS) {
if (!promises.resolve(e.internalMessageId, e.info)) {
notify(e.type, e);
}
} else if (e.type === constants.SFU_ROOM_EVENT.REMOVE_TRACKS) {
if (!promises.resolve(e.internalMessageId, e)) {
notify(e.type, e);
}
} else if (e.type === constants.SFU_ROOM_EVENT.WAITING_ROOM_UPDATE && promises.promised(e.internalMessageId)) {
promises.resolve(e.internalMessageId, e.enabled);
} else if (e.type === constants.SFU_EVENT.ACK && promises.promised(e.internalMessageId)) {
promises.resolve(e.internalMessageId);
} else {
notify(e.type, e);
}
};
/**
* Create room at server side.
*/
const createRoom = function() {
return new Promise((resolve, reject) => {
const id = uuidv4();
promises.add(id, resolve, reject);
connection.send(constants.SFU_INTERNAL_API.CREATE_ROOM, {
name: name,
pin: pin,
internalMessageId: id
});
});
};
/**
* Join room.
* @param {Object=} config Config for track marking. Key is a track id and value is a String (e.g. screen_sharing, camera, front_camera).
* The specified value will be available for other participants as contentType in {@link FlashphonerSFU.Room.TrackInfo}
* @memberOf FlashphonerSFU.Room
*/
const join = function(config) {
return new Promise((resolve, reject) => {
if (state === constants.SFU_ROOM_STATE.NEW) {
state = constants.SFU_ROOM_STATE.JOINED;
pc.createOffer().then(function (offer) {
if (config) {
offer.sdp = applyContentTypeConfig(offer.sdp, config);
}
pc.setLocalDescription(offer).then(function () {
const id = uuidv4();
promises.add(id, resolve, reject);
connection.send(constants.SFU_INTERNAL_API.JOIN_ROOM, {
name: name,
pin: pin,
sdp: offer.sdp,
internalMessageId: id
});
}, reject);
}, reject);
} else {
reject("Can't joined room with state " + state);
}
});
};
/**
* Update state after adding tracks to PeerConnection.
* This method kicks off sdp renegotiation.
* @param {Object=} config Config for track marking. Key is a track id and value is a String (e.g. screen_sharing, camera, front_camera).
* The specified value will be available for other participants as contentType in {@link FlashphonerSFU.Room.TrackInfo}
* @throws {Error} Error if peer connection is being negotiated
* @memberOf FlashphonerSFU.Room
*/
const updateState = function(config) {
return new Promise((resolve, reject) => {
if (pc.signalingState !== "stable") {
reject("Peer connection signaling state is " + pc.signalingState + ". Can't update room while negotiation is in progress");
}
pc.createOffer().then(function(offer) {
pc.setLocalDescription(offer).then(function() {
if (config) {
offer.sdp = applyContentTypeConfig(offer.sdp, config);
}
const id = uuidv4();
promises.add(id, resolve, reject);
connection.send(constants.SFU_INTERNAL_API.UPDATE_ROOM_STATE, {
name: name,
pin: pin,
sdp: offer.sdp,
internalMessageId: id
});
}, reject);
}, reject);
})
};
const applyContentTypeConfig = function(sdp, config) {
let ret = "";
for (const str of sdp.split("\n")) {
if (str && str.length > 0) {
ret += str + "\n";
if (str.indexOf("a=msid:") > -1) {
const msid = str.substring(str.indexOf(" ") + 1).trim();
if (config[msid]) {
ret += constants.SFU_INTERNAL_API.TRACK_CONTENT_HEADER + config[msid] + "\r\n";
}
}
}
}
return ret;
};
/**
* Destroys room instance at server side.
* @memberOf FlashphonerSFU.Room
*/
const destroyRoom = function() {
return new Promise((resolve, reject) => {
state = constants.SFU_ROOM_STATE.DISPOSED;
const id = uuidv4();
promises.add(id, resolve, reject);
connection.send(constants.SFU_INTERNAL_API.DESTROY_ROOM, {
name: name,
pin: pin,
internalMessageId: id
});
});
};
/**
* Leave room.
* @memberOf FlashphonerSFU.Room
*/
const leaveRoom = function() {
return new Promise((resolve, reject) => {
state = constants.SFU_ROOM_STATE.DISPOSED;
const id = uuidv4();
promises.add(id, resolve, reject);
connection.send(constants.SFU_INTERNAL_API.LEAVE_ROOM, {
name: name,
pin: pin,
internalMessageId: id
});
pc.close();
pc.dispatchEvent(new Event("connectionstatechange"));
})
};
/**
* Broadcast message to participants currently in the Room.
* This method will use DataChannels.
* @param {String} msg
* @memberOf FlashphonerSFU.Room
*/
const sendMessage = function(msg) {
return new Promise((resolve, reject) => {
//throttle messages
const id = uuidv4();
/**
* Note that in case of chunked message promise will be resolved only after sending last message (last: true)
*/
promises.add(id, resolve, reject);
const chunkSize = 16384;
if (msg.length > chunkSize) {
const chunks = msg.match(new RegExp("(.|[\r\n]){1,"+chunkSize+"}", "g"));
for (let i = 0; i < chunks.length; i++) {
dChannelSend(JSON.stringify({
id: id,
last: i === chunks.length - 1,
payload: chunks[i]
}));
}
} else {
dChannelSend(JSON.stringify({
id: id,
payload: msg
}));
}
})
};
/**
* Send control message.
* This method will use WSS to send the message.
* @param {String} to recipient's nickname
* @param {String} msg message
* @param {Boolean} broadcast ignore 'to' and broadcast the message (send it to all participants)
*/
const sendControlMessage = function(to, msg, broadcast) {
return new Promise((resolve, reject) => {
const id = uuidv4();
promises.add(id, resolve, reject);
connection.send(constants.SFU_INTERNAL_API.SEND_CONTROL_MESSAGE, {
broadcast: broadcast,
from: "",
to: to,
body: msg,
internalMessageId: id
});
})
};
/**
* Change receiving quality of the simulcast track.
* @param {String} trackId Id of the track
* @param {String} quality one of qualities advertised in {@link FlashphonerSFU.Room.TrackInfo}
* @param {String} tid In some tracks (Such as WebRTC simulcast VP8 track) there is an option of changing FPS of the
* track by changing TID. At the time of writing there were 3 TIDs (lower TID = lower FPS) 0|1|2
* @memberOf FlashphonerSFU.Room
*/
const changeQuality = function(trackId, quality, tid) {
return new Promise((resolve, reject) => {
const id = uuidv4();
promises.add(id, resolve, reject);
connection.send("changeQuality", {
roomName: name,
id: trackId,
quality: quality,
tid: tid,
internalMessageId: id
});
})
};
/**
* Authorize user that is currently resides in waiting room. Note that this will only work with {@link FlashphonerSFUExtended}
* @param {String} userId User id
* @param {Boolean} authorized If true participant will move to the main room otherwise participant will be evicted.
* @memberOf FlashphonerSFU.Room
*/
const authorizeWaitingList = function(userId, authorized) {
return new Promise((resolve, reject) => {
const id = uuidv4();
promises.add(id, resolve, reject);
connection.send(constants.SFU_INTERNAL_API.AUTHORIZE_WAITING_LIST, {
name: name,
userId: userId,
authorized: authorized,
internalMessageId: id
});
})
};
/**
* Move user from room into waiting room. Note that this will only work with {@link FlashphonerSFUExtended}
* @param {String} nickname Participant nickname
* @memberOf FlashphonerSFU.Room
*/
const moveToWaitingRoom = function(nickname) {
return new Promise((resolve, reject) => {
const id = uuidv4();
promises.add(id, resolve, reject);
connection.send(constants.SFU_INTERNAL_API.MOVE_TO_WAITING_ROOM, {
roomName: name,
nickname: nickname,
internalMessageId: id
});
});
};
/**
* Turn waiting room on/off
* @param {Boolean} enabled Boolean flag
* @memberOf FlashphonerSFU.Room
*/
const configureWaitingRoom = function(enabled) {
return new Promise((resolve, reject) => {
const id = uuidv4();
promises.add(id, resolve, reject);
connection.send(constants.SFU_INTERNAL_API.CONFIGURE_WAITING_ROOM, {
name: name,
enabled: enabled,
internalMessageId: id
});
});
};
/**
* Mute track. Mute takes place at server side - server stops forwarding this track.
* @param {String} trackId Id of the track
* @param {Boolean} mute Mute flag
* @memberOf FlashphonerSFU.Room
*/
const muteTrack = function(trackId, mute) {
return new Promise((resolve, reject) => {
const id = uuidv4();
promises.add(id, resolve, reject);
connection.send(constants.SFU_INTERNAL_API.MUTE_TRACK, {
roomName: name,
id: trackId,
mute: mute
});
})
};
/**
* Assign user a role. Note that this will only work with {@link FlashphonerSFUExtended}
* @param {String} nickname Nickname of the participant
* @param {FlashphonerSFU.SFU_PARTICIPANT_ROLE} role Role to assign
* @memberOf FlashphonerSFU.Room
*/
const assignRole = function(nickname, role) {
return new Promise((resolve, reject) => {
const id = uuidv4();
promises.add(id, resolve, reject);
connection.send(constants.SFU_INTERNAL_API.ASSIGN_ROLE, {
roomName: name,
nickname: nickname,
role: role,
internalMessageId: id
});
})
};
/**
* Subscribe to tracks of participant that is currently resides in waiting room.
* New tracks will have `info.waitingRoom` flag set to true, see {@link FlashphonerSFU.Room.TracksInfo}.
* Note that this will only work with {@link FlashphonerSFUExtended}
* @param {String} nickname Nickname of the participant
* @memberOf FlashphonerSFU.Room
*/
const subscribeToWaitingParticipant = function(nickname) {
return new Promise((resolve, reject) => {
const id = uuidv4();
promises.add(id, resolve, reject);
connection.send(constants.SFU_INTERNAL_API.SUBSCRIBE_TO_WAITING_PARTICIPANT, {
roomName: name,
nickname: nickname,
internalMessageId: id
});
})
};
/**
* Unsubscribe from tracks of participant that is currently resides in waiting room.
* Note that this will only work with {@link FlashphonerSFUExtended}
* @param {String} nickname Nickname of the participant
* @memberOf FlashphonerSFU.Room
*/
const unsubscribeFromWaitingParticipant = function(nickname) {
return new Promise((resolve, reject) => {
const id = uuidv4();
promises.add(id, resolve, reject);
connection.send(constants.SFU_INTERNAL_API.UNSUBSCRIBE_FROM_WAITING_PARTICIPANT, {
roomName: name,
nickname: nickname,
internalMessageId: id
});
})
};
/**
* Room event callback.
*
* @callback FlashphonerSFU.Room~eventCallback
* @param {FlashphonerSFU.Room} sdk instance FlashphonerSFU.Room
*/
/**
* Add room event callback.
*
* @param {String} event One of {@link FlashphonerSFU.SFU_ROOM_EVENT} events
* @param {FlashphonerSFU.Room~eventCallback} callback Callback function
* @returns {FlashphonerSFU.Room} SDK instance callback was attached to
* @throws {TypeError} Error if event is not specified
* @throws {Error} Error if callback is not a valid function
* @memberOf FlashphonerSFU.Room
*/
const on = function (event, callback) {
if (!event) {
throw new TypeError("Event can't be null");
}
if (!callback || typeof callback !== "function") {
throw new Error("Callback needs to be a valid function");
}
if (!callbacks[event]) {
callbacks[event] = [];
}
callbacks[event].push(callback);
return room;
};
/**
* Remove room event callback.
*
* @param {String} event One of {@link FlashphonerSFU.SFU_ROOM_EVENT} events
* @param {FlashphonerSFU.Room~eventCallback} callback Callback function
* @returns {FlashphonerSFU.Room} SDK instance callback was removed from
* @throws {TypeError} Error if event is not specified
* @throws {Error} Error if callback is not a valid function
* @memberOf FlashphonerSFU.Room
*/
const off = function (event, callback) {
if (!event) {
throw new TypeError("Event can't be null");
}
if (!callback || typeof callback !== "function") {
throw new Error("Callback needs to be a valid function");
}
if (!callbacks[event]) {
callbacks[event] = [];
}
let index = callbacks[event].findIndex(element => element === callback);
if (index !== -1) {
console.log("off ", event);
callbacks[event] = callbacks[event].slice(index, 1);
}
return room;
};
const notify = function(event, msg) {
if (callbacks[event]) {
for (const callback of callbacks[event]) {
callback(msg);
}
}
};
room.processEvent = processEvent;
room.createRoom = createRoom;//done
room.join = join;//done
room.updateState = updateState;//done
room.destroyRoom = destroyRoom;//done
room.leaveRoom = leaveRoom;//done
room.sendMessage = sendMessage;//done
room.sendControlMessage = sendControlMessage;//done
room.changeQuality = changeQuality;//done
room.authorizeWaitingList = authorizeWaitingList;//done
room.muteTrack = muteTrack;//done
room.assignRole = assignRole;//done
room.subscribeToWaitingParticipant = subscribeToWaitingParticipant;//done
room.unsubscribeFromWaitingParticipant = unsubscribeFromWaitingParticipant;//done
room.moveToWaitingRoom = moveToWaitingRoom;//done
room.configureWaitingRoom = configureWaitingRoom;//done
/**
* Room name
* @returns {String} room name
*/
room.name = function() {
return name;
};
/**
* Room pin
* @returns {String} room pin
*/
room.pin = function() {
return pin;
};
/**
* Room underlying PeerConnection
* @returns {RTCPeerConnection} peer connection
*/
room.pc = function() {
return pc;
};
/**
* Local user role
* @returns {FlashphonerSFU.SFU_PARTICIPANT_ROLE}
*/
room.role = function() {
return role;
};
/**
* HTTP address of the invite. Note that this will only work with {@link FlashphonerSFUExtended}
* @returns {String}
*/
room.invite = function() {
return inviteId;
};
room.on = on;
room.off = off;
return room;
};
module.exports = {
room: room
};