diff --git a/meet/server/lib/Room.js b/meet/server/lib/Room.js
index c3d757b1..183bd51e 100644
--- a/meet/server/lib/Room.js
+++ b/meet/server/lib/Room.js
@@ -1,1197 +1,1200 @@
const EventEmitter = require('events').EventEmitter;
const AwaitQueue = require('awaitqueue');
const crypto = require('crypto');
const Logger = require('./Logger');
const { SocketTimeoutError } = require('./errors');
const Roles = require('./userRoles');
const config = require('../config/config');
const logger = new Logger('Room');
const ROUTER_SCALE_SIZE = config.routerScaleSize || 40;
class Room extends EventEmitter {
static calculateLoads(mediasoupWorkers, peers, mediasoupRouters) {
const routerLoads = new Map();
const workerLoads = new Map();
const pipedRoutersIds = new Set();
// Calculate router loads by adding up peers per router, and collected piped routers
Object.values(peers).forEach(peer => {
const routerId = peer.routerId;
if (routerId) {
if (mediasoupRouters.has(routerId)) {
pipedRoutersIds.add(routerId);
}
if (routerLoads.has(routerId)) {
routerLoads.set(routerId, routerLoads.get(routerId) + 1);
} else {
routerLoads.set(routerId, 1);
}
}
});
// Calculate worker loads by adding up router loads per worker
for (const worker of mediasoupWorkers) {
for (const router of worker._routers) {
const routerId = router._internal.routerId;
if (workerLoads.has(worker._pid)) {
workerLoads.set(worker._pid, workerLoads.get(worker._pid) +
(routerLoads.has(routerId)?routerLoads.get(routerId):0));
} else {
workerLoads.set(worker._pid,
(routerLoads.has(routerId)?routerLoads.get(routerId):0));
}
}
}
return {routerLoads, workerLoads, pipedRoutersIds};
}
/*
* Find a router that is on a worker that is least loaded.
*
* A worker with a router that we are already piping to is preferred.
*/
static getLeastLoadedRouter(mediasoupWorkers, peers, mediasoupRouters) {
const {workerLoads, pipedRoutersIds} = Room.calculateLoads(mediasoupWorkers, peers, mediasoupRouters);
const sortedWorkerLoads = new Map([ ...workerLoads.entries() ].sort(
(a, b) => a[1] - b[1]));
// we don't care about if router is piped, just choose the least loaded worker
if (pipedRoutersIds.size === 0 ||
pipedRoutersIds.size === mediasoupRouters.size) {
const workerId = sortedWorkerLoads.keys().next().value;
for (const worker of mediasoupWorkers) {
if (worker._pid === workerId) {
for (const router of worker._routers) {
const routerId = router._internal.routerId;
if (mediasoupRouters.has(routerId)) {
return routerId;
}
}
}
}
} else {
// find if there is a piped router that is on a worker that is below limit
for (const [ workerId, workerLoad ] of sortedWorkerLoads.entries()) {
for (const worker of mediasoupWorkers) {
if (worker._pid === workerId) {
for (const router of worker._routers) {
const routerId = router._internal.routerId;
// on purpose we check if the worker load is below the limit,
// as in reality the worker load is imortant,
// not the router load
if (mediasoupRouters.has(routerId) &&
pipedRoutersIds.has(routerId) &&
workerLoad < ROUTER_SCALE_SIZE) {
return routerId;
}
}
}
}
}
// no piped router found, we need to return router from least loaded worker
const workerId = sortedWorkerLoads.keys().next().value;
for (const worker of mediasoupWorkers) {
if (worker._pid === workerId) {
for (const router of worker._routers) {
const routerId = router._internal.routerId;
if (mediasoupRouters.has(routerId)) {
return routerId;
}
}
}
}
}
}
/**
* Factory function that creates and returns Room instance.
*
* @async
*
* @param {mediasoup.Worker} mediasoupWorkers - The mediasoup Worker in which a new
* mediasoup Router must be created.
* @param {String} roomId - Id of the Room instance.
*/
static async create({ mediasoupWorkers, roomId, peers }) {
logger.info('create() [roomId:"%s"]', roomId);
// Router media codecs.
const mediaCodecs = config.mediasoup.router.mediaCodecs;
const mediasoupRouters = new Map();
for (const worker of mediasoupWorkers) {
const router = await worker.createRouter({ mediaCodecs });
mediasoupRouters.set(router.id, router);
}
const firstRouter = mediasoupRouters.get(Room.getLeastLoadedRouter(
mediasoupWorkers, peers, mediasoupRouters));
// Create a mediasoup AudioLevelObserver on first router
const audioLevelObserver = await firstRouter.createAudioLevelObserver(
{
maxEntries : 1,
threshold : -80,
interval : 800
});
return new Room({
roomId,
mediasoupRouters,
audioLevelObserver,
mediasoupWorkers,
peers
});
}
constructor({
roomId,
mediasoupRouters,
audioLevelObserver,
mediasoupWorkers,
peers
}) {
logger.info('constructor() [roomId:"%s"]', roomId);
super();
this.setMaxListeners(Infinity);
// this._uuid = uuidv4();
this._mediasoupWorkers = mediasoupWorkers;
// Room ID.
this._roomId = roomId;
// Closed flag.
this._closed = false;
// Joining queue
this._queue = new AwaitQueue();
this._peers = peers;
this._selfDestructTimeout = null;
// Array of mediasoup Router instances.
this._mediasoupRouters = mediasoupRouters;
this._audioLevelObserver = audioLevelObserver;
}
dumpStats() {
const peers = this.getPeers();
const {routerLoads, workerLoads, pipedRoutersIds} = Room.calculateLoads(this._mediasoupWorkers, peers, this._mediasoupRouters);
let stats = {
numberOfWorkers: this._mediasoupWorkers.length,
numberOfRouters: this._mediasoupRouters.size,
numberOfPeers: peers.length,
routerLoads: routerLoads,
workerLoads: workerLoads,
pipedRoutersIds: pipedRoutersIds,
};
console.log(stats);
}
close() {
logger.debug('close()');
this._closed = true;
this._queue.close();
this._queue = null;
if (this._selfDestructTimeout)
clearTimeout(this._selfDestructTimeout);
this._selfDestructTimeout = null;
// Close the peers.
Object.values(this._peers).forEach(peer => {
if (!peer.closed)
peer.close();
});
this._peers = {};
// Close the mediasoup Routers.
for (const router of this._mediasoupRouters.values()) {
router.close();
}
this._mediasoupRouters.clear();
this._mediasoupWorkers = null;
this._audioLevelObserver = null;
// Emit 'close' event.
this.emit('close');
}
handlePeer({ peer }) {
logger.info('handlePeer() [peer:"%s", role:%s]', peer.id, peer.role);
// Should not happen
if (this._peers[peer.id]) {
logger.warn(
'handleConnection() | there is already a peer with same peerId [peer:"%s"]',
peer.id);
}
this._peerJoining(peer);
}
logStatus() {
logger.info(
'logStatus() [room id:"%s", peers:"%s"]',
this._roomId,
Object.keys(this._peers).length
);
}
dump() {
return {
roomId : this._roomId,
peers : Object.keys(this._peers).length
};
}
get id() {
return this._roomId;
}
selfDestructCountdown() {
logger.debug('selfDestructCountdown() started');
clearTimeout(this._selfDestructTimeout);
this._selfDestructTimeout = setTimeout(() => {
if (this._closed)
return;
if (this.checkEmpty()) {
logger.info(
'Room deserted for some time, closing the room [roomId:"%s"]',
this._roomId);
this.close();
} else
logger.debug('selfDestructCountdown() aborted; room is not empty!');
}, 10000);
}
checkEmpty() {
return Object.keys(this._peers).length === 0;
}
_getTURNCredentials(name, secret) {
const unixTimeStamp = parseInt(Date.now()/1000) + 24*3600; // this credential would be valid for the next 24 hours
// If there is no name, the timestamp alone can also be used.
const username = name ? `${unixTimeStamp}:${name}` : `${unixTimeStamp}`;
const hmac = crypto.createHmac('sha1', secret);
hmac.setEncoding('base64');
hmac.write(username);
hmac.end();
const password = hmac.read();
return {
username,
password
};
}
_peerJoining(peer) {
this._queue.push(async () => {
peer.socket.join(this._roomId);
this._peers[peer.id] = peer;
// Assign routerId
peer.routerId = await this._getRouterId();
this._handlePeer(peer);
let iceServers;
if (config.turn) {
// Generate time-limited credentials. The name is only relevant for the logs.
const {username, password} = this._getTURNCredentials(peer.id, config.turn.staticSecret);
iceServers = [ {
urls : config.turn.urls,
username : username,
credential : password
} ];
}
this._notification(peer.socket, 'roomReady', { iceServers });
})
.catch((error) => {
logger.error('_peerJoining() [error:"%o"]', error);
});
}
_handlePeer(peer) {
logger.debug('_handlePeer() [peer:"%s"]', peer.id);
peer.on('close', () => {
this._handlePeerClose(peer);
});
peer.on('nicknameChanged', () => {
// Spread to others
const data = { peerId: peer.id, nickname: peer.nickname };
this._notification(peer.socket, 'changeNickname', data, true);
});
peer.on('languageChanged', () => {
// Spread to others (and self)
const data = { peerId: peer.id, language: peer.language };
this._notification(peer.socket, 'changeLanguage', data, true, true);
});
peer.on('roleChanged', () => {
// Spread to others (and self)
const data = { peerId: peer.id, role: peer.role };
this._notification(peer.socket, 'changeRole', data, true, true);
});
peer.socket.on('request', (request, cb) => {
logger.debug(
'Peer "request" event [method:"%s", peerId:"%s"]',
request.method, peer.id);
this._handleSocketRequest(peer, request, cb)
.catch((error) => {
logger.error('"request" failed [error:"%o"]', error);
cb(error);
});
});
// Peer left before we were done joining
if (peer.closed)
this._handlePeerClose(peer);
}
_handlePeerClose(peer) {
logger.debug('_handlePeerClose() [peer:"%s"]', peer.id);
if (this._closed)
return;
this._notification(peer.socket, 'peerClosed', { peerId: peer.id }, true);
delete this._peers[peer.id];
// If this is the last Peer in the room close the room after a while.
if (this.checkEmpty())
this.selfDestructCountdown();
}
async _handleSocketRequest(peer, request, cb) {
const router = this._mediasoupRouters.get(peer.routerId);
switch (request.method) {
case 'getRouterRtpCapabilities':
{
cb(null, router.rtpCapabilities);
break;
}
case 'dumpStats':
{
this.dumpStats()
cb(null);
break;
}
case 'join':
{
const {
nickname,
rtpCapabilities
} = request.data;
// Store client data into the Peer data object.
peer.nickname = nickname;
peer.rtpCapabilities = rtpCapabilities;
// Tell the new Peer about already joined Peers.
const otherPeers = this.getPeers(peer);
const peerInfos = otherPeers.map(otherPeer => otherPeer.peerInfo);
cb(null, {
id: peer.id,
role: peer.role,
peers: peerInfos,
});
// Create Consumers for existing Producers.
for (const otherPeer of otherPeers) {
for (const producer of otherPeer.producers.values()) {
this._createConsumer({
consumerPeer: peer,
producerPeer: otherPeer,
producer
});
}
}
// Notify the new Peer to all other Peers.
this._notification(peer.socket, 'newPeer', peer.peerInfo, true);
logger.debug(
'peer joined [peer: "%s", nickname: "%s"]',
peer.id, nickname);
break;
}
case 'createPlainTransport':
{
const { producing, consuming } = request.data;
const transport = await router.createPlainTransport(
{
//When consuming we manually connect using connectPlainTransport,
//otherwise we let the port autodetection work.
comedia: producing,
// FFmpeg and GStreamer don't support RTP/RTCP multiplexing ("a=rtcp-mux" in SDP)
rtcpMux: false,
listenIp: { ip: "127.0.0.1", announcedIp: null },
appData : { producing, consuming }
}
);
// await transport.enableTraceEvent([ "probation", "bwe" ]);
// transport.on("trace", (trace) => {
// console.log(trace);
// });
peer.addTransport(transport.id, transport);
cb(
null,
{
id : transport.id,
ip : transport.tuple.localIp,
port : transport.tuple.localPort,
rtcpPort : transport.rtcpTuple ? transport.rtcpTuple.localPort : undefined
});
break;
}
case 'connectPlainTransport':
{
const { transportId, ip, port, rtcpPort } = request.data;
const transport = peer.getTransport(transportId);
if (!transport)
throw new Error(`transport with id "${transportId}" not found`);
await transport.connect({
ip: ip,
port: port,
rtcpPort: rtcpPort,
});
cb();
break;
}
case 'createWebRtcTransport':
{
// NOTE: Don't require that the Peer is joined here, so the client can
// initiate mediasoup Transports and be ready when he later joins.
const { forceTcp, producing, consuming } = request.data;
const webRtcTransportOptions =
{
...config.mediasoup.webRtcTransport,
appData : { producing, consuming }
};
webRtcTransportOptions.enableTcp = true;
if (forceTcp)
webRtcTransportOptions.enableUdp = false;
else {
webRtcTransportOptions.enableUdp = true;
webRtcTransportOptions.preferUdp = true;
}
const transport = await router.createWebRtcTransport(
webRtcTransportOptions
);
transport.on('dtlsstatechange', (dtlsState) => {
if (dtlsState === 'failed' || dtlsState === 'closed') {
logger.warn('WebRtcTransport "dtlsstatechange" event [dtlsState:%s]', dtlsState);
}
});
// Store the WebRtcTransport into the Peer data Object.
peer.addTransport(transport.id, transport);
cb(
null,
{
id : transport.id,
iceParameters : transport.iceParameters,
iceCandidates : transport.iceCandidates,
dtlsParameters : transport.dtlsParameters
});
const { maxIncomingBitrate } = config.mediasoup.webRtcTransport;
// If set, apply max incoming bitrate limit.
if (maxIncomingBitrate) {
try {
await transport.setMaxIncomingBitrate(maxIncomingBitrate);
} catch (error) {
logger.info("Setting the incoming bitrate failed")
}
}
break;
}
case 'connectWebRtcTransport':
{
const { transportId, dtlsParameters } = request.data;
const transport = peer.getTransport(transportId);
if (!transport)
throw new Error(`transport with id "${transportId}" not found`);
await transport.connect({ dtlsParameters });
cb();
break;
}
- /*
+/*
case 'restartIce':
{
const { transportId } = request.data;
const transport = peer.getTransport(transportId);
if (!transport)
throw new Error(`transport with id "${transportId}" not found`);
const iceParameters = await transport.restartIce();
cb(null, iceParameters);
break;
}
*/
case 'produce':
{
let { appData } = request.data;
if (!appData.source || ![ 'mic', 'webcam', 'screen' ].includes(appData.source))
throw new Error('invalid producer source');
if (appData.source === 'mic' && !peer.hasRole(Roles.PUBLISHER))
throw new Error('peer not authorized');
if (appData.source === 'webcam' && !peer.hasRole(Roles.PUBLISHER))
throw new Error('peer not authorized');
if (appData.source === 'screen' && !peer.hasRole(Roles.PUBLISHER))
throw new Error('peer not authorized');
const { transportId, kind, rtpParameters } = request.data;
const transport = peer.getTransport(transportId);
if (!transport)
throw new Error(`transport with id "${transportId}" not found`);
// Add peerId into appData to later get the associated Peer during
// the 'loudest' event of the audioLevelObserver.
appData = { ...appData, peerId: peer.id };
- const producer =
- await transport.produce({ kind, rtpParameters, appData });
+ const producer = await transport.produce({ kind, rtpParameters, appData });
const pipeRouters = this._getRoutersToPipeTo(peer.routerId);
for (const [ routerId, destinationRouter ] of this._mediasoupRouters) {
if (pipeRouters.includes(routerId)) {
await router.pipeToRouter({
producerId : producer.id,
router : destinationRouter
});
}
}
// Store the Producer into the Peer data Object.
peer.addProducer(producer.id, producer);
producer.on('videoorientationchange', (videoOrientation) => {
logger.debug(
'producer "videoorientationchange" event [producerId:"%s", videoOrientation:"%o"]',
producer.id, videoOrientation);
});
// Trace individual packets for debugging
// await producer.enableTraceEvent([ "rtp", "pli", "keyframe", "nack" ]);
// producer.on("trace", (trace) => {
// console.log(`Trace on ${producer.id}`, trace);
// });
cb(null, { id: producer.id });
// Optimization: Create a server-side Consumer for each Peer.
for (const otherPeer of this.getPeers(peer)) {
this._createConsumer({
consumerPeer: otherPeer,
producerPeer: peer,
producer
});
}
// Add into the audioLevelObserver.
if (kind === 'audio') {
this._audioLevelObserver.addProducer({ producerId: producer.id })
.catch(() => {});
}
break;
}
case 'closeProducer':
{
const { producerId } = request.data;
const producer = peer.getProducer(producerId);
if (!producer)
throw new Error(`producer with id "${producerId}" not found`);
producer.close();
// Remove from its map.
peer.removeProducer(producer.id);
cb();
break;
}
case 'pauseProducer':
{
const { producerId } = request.data;
const producer = peer.getProducer(producerId);
if (!producer)
throw new Error(`producer with id "${producerId}" not found`);
await producer.pause();
cb();
break;
}
case 'resumeProducer':
{
const { producerId } = request.data;
const producer = peer.getProducer(producerId);
if (!producer)
throw new Error(`producer with id "${producerId}" not found`);
await producer.resume();
cb();
break;
}
case 'pauseConsumer':
{
const { consumerId } = request.data;
const consumer = peer.getConsumer(consumerId);
if (!consumer)
throw new Error(`consumer with id "${consumerId}" not found`);
await consumer.pause();
cb();
break;
}
case 'resumeConsumer':
{
const { consumerId } = request.data;
const consumer = peer.getConsumer(consumerId);
if (!consumer)
throw new Error(`consumer with id "${consumerId}" not found`);
await consumer.resume();
cb();
break;
}
case 'changeNickname':
{
const { nickname } = request.data;
peer.nickname = nickname;
// This will be spread through events from the peer object
// Return no error
cb();
break;
}
case 'chatMessage':
{
const { message } = request.data;
// Spread to others
this._notification(peer.socket, 'chatMessage', {
peerId: peer.id,
nickname: peer.nickname,
message: message
}, true, true);
// Return no error
cb();
break;
}
case 'moderator:addRole':
{
if (!peer.hasRole(Roles.MODERATOR))
throw new Error('peer not authorized');
const { peerId, role } = request.data;
const rolePeer = this._peers[peerId];
if (!rolePeer)
throw new Error(`peer with id "${peerId}" not found`);
if (!rolePeer.isValidRole(role))
throw new Error('invalid role');
if (!rolePeer.hasRole(role)) {
// The 'owner' role is not assignable
if (role & Roles.OWNER)
throw new Error('the OWNER role is not assignable');
// Promotion to publisher? Put the user hand down
if (role & Roles.PUBLISHER && !(rolePeer.role & Roles.PUBLISHER))
rolePeer.raisedHand = false;
// This will propagate the event automatically
rolePeer.setRole(rolePeer.role | role);
}
// Return no error
cb();
break;
}
case 'moderator:removeRole':
{
if (!peer.hasRole(Roles.MODERATOR))
throw new Error('peer not authorized');
const { peerId, role } = request.data;
const rolePeer = this._peers[peerId];
if (!rolePeer)
throw new Error(`peer with id "${peerId}" not found`);
if (!rolePeer.isValidRole(role))
throw new Error('invalid role');
if (rolePeer.hasRole(role)) {
if (role & Roles.OWNER)
throw new Error('the OWNER role is not removable');
if (role & Roles.MODERATOR && rolePeer.role & Roles.OWNER)
throw new Error('the MODERATOR role cannot be removed from the OWNER');
// Non-publisher cannot be a language interpreter
if (role & Roles.PUBLISHER)
rolePeer.language = null;
// This will propagate the event automatically
rolePeer.setRole(rolePeer.role ^ role);
}
// Return no error
cb();
break;
}
case 'moderator:changeLanguage':
{
if (!peer.hasRole(Roles.MODERATOR))
throw new Error('peer not authorized');
const { language } = request.data;
if (language && !/^[a-z]{2}$/.test(language))
throw new Error('invalid language code');
peer.language = language;
// This will be spread through events from the peer object
// Return no error
cb();
break;
}
case 'moderator:closeRoom':
{
if (!peer.hasRole(Roles.OWNER))
throw new Error('peer not authorized');
this._notification(peer.socket, 'moderator:closeRoom', null, true);
cb();
// Close the room
this.close();
// TODO: remove the room?
break;
}
case 'moderator:kickPeer':
{
if (!peer.hasRole(Roles.MODERATOR))
throw new Error('peer not authorized');
const { peerId } = request.data;
const kickPeer = this._peers[peerId];
if (!kickPeer)
throw new Error(`peer with id "${peerId}" not found`);
this._notification(kickPeer.socket, 'moderator:kickPeer');
kickPeer.close();
cb();
break;
}
case 'raisedHand':
{
const { raisedHand } = request.data;
peer.raisedHand = raisedHand;
// Spread to others
this._notification(peer.socket, 'raisedHand', {
peerId: peer.id,
raisedHand: raisedHand,
}, true);
// Return no error
cb();
break;
}
default:
{
logger.error('unknown request.method "%s"', request.method);
cb(500, `unknown request.method "${request.method}"`);
}
}
}
/**
* Creates a mediasoup Consumer for the given mediasoup Producer.
*
* @async
*/
async _createConsumer({ consumerPeer, producerPeer, producer }) {
logger.debug(
'_createConsumer() [consumerPeer:"%s", producerPeer:"%s", producer:"%s"]',
consumerPeer.id,
producerPeer.id,
producer.id
);
const router = this._mediasoupRouters.get(producerPeer.routerId);
// Optimization:
// - Create the server-side Consumer. If video, do it paused.
// - Tell its Peer about it and wait for its response.
// - Upon receipt of the response, resume the server-side Consumer.
// - If video, this will mean a single key frame requested by the
// server-side Consumer (when resuming it).
// NOTE: Don't create the Consumer if the remote Peer cannot consume it.
if (
!consumerPeer.rtpCapabilities ||
!router.canConsume(
{
producerId : producer.id,
rtpCapabilities : consumerPeer.rtpCapabilities
})
) {
return;
}
// Must take the Transport the remote Peer is using for consuming.
const transport = consumerPeer.getConsumerTransport();
// This should not happen.
if (!transport) {
logger.warn('_createConsumer() | Transport for consuming not found');
return;
}
// Create the Consumer in paused mode.
let consumer;
try {
consumer = await transport.consume(
{
producerId : producer.id,
rtpCapabilities : consumerPeer.rtpCapabilities,
paused : producer.kind === 'video'
});
if (producer.kind === 'audio')
await consumer.setPriority(255);
} catch (error) {
logger.warn('_createConsumer() | [error:"%o"]', error);
return;
}
// Trace individual packets for debugging
// await consumer.enableTraceEvent([ "rtp", "pli", "fir" ]);
// consumer.on("trace", (trace) => {
// console.log(`Trace on ${consumer.id}`, trace);
// });
// Store the Consumer into the consumerPeer data Object.
consumerPeer.addConsumer(consumer.id, consumer);
// Set Consumer events.
consumer.on('transportclose', () => {
// Remove from its map.
consumerPeer.removeConsumer(consumer.id);
});
consumer.on('producerclose', () => {
// Remove from its map.
consumerPeer.removeConsumer(consumer.id);
this._notification(consumerPeer.socket, 'consumerClosed', { consumerId: consumer.id });
});
+ // TODO: We don't have to send websocket signals on producerpause/producerresume
+ // The same can be achieved on the client-side using consumer.observer.on('pause')
+ // and consumer.observer.on('resume')
+
consumer.on('producerpause', () => {
this._notification(consumerPeer.socket, 'consumerPaused', { consumerId: consumer.id });
});
consumer.on('producerresume', () => {
this._notification(consumerPeer.socket, 'consumerResumed', { consumerId: consumer.id });
});
// Send a request to the remote Peer with Consumer parameters.
try {
await this._request(
consumerPeer.socket,
'newConsumer',
{
peerId : producerPeer.id,
kind : consumer.kind,
producerId : producer.id,
id : consumer.id,
rtpParameters : consumer.rtpParameters,
type : consumer.type,
appData : producer.appData,
producerPaused : consumer.producerPaused
}
);
// Now that we got the positive response from the remote Peer and, if
// video, resume the Consumer to ask for an efficient key frame.
await consumer.resume();
} catch (error) {
logger.warn('_createConsumer() | [error:"%o"]', error);
}
}
/**
* Get the list of peers.
*/
getPeers(excludePeer = undefined) {
return Object.values(this._peers)
.filter((peer) => peer !== excludePeer);
}
_timeoutCallback(callback) {
let called = false;
const interval = setTimeout(
() => {
if (called)
return;
called = true;
callback(new SocketTimeoutError('Request timed out'));
},
config.requestTimeout || 20000
);
return (...args) => {
if (called)
return;
called = true;
clearTimeout(interval);
callback(...args);
};
}
_sendRequest(socket, method, data = {}) {
return new Promise((resolve, reject) => {
socket.emit(
'request',
{ method, data },
this._timeoutCallback((err, response) => {
if (err) {
reject(err);
} else {
resolve(response);
}
})
);
});
}
async _request(socket, method, data) {
logger.debug('_request() [method:"%s", data:"%o"]', method, data);
const {
requestRetries = 3
} = config;
for (let tries = 0; tries < requestRetries; tries++) {
try {
return await this._sendRequest(socket, method, data);
} catch (error) {
if (
error instanceof SocketTimeoutError &&
tries < requestRetries
)
logger.warn('_request() | timeout, retrying [attempt:"%s"]', tries);
else
throw error;
}
}
}
_notification(socket, method, data = {}, broadcast = false, includeSender = false) {
if (broadcast) {
socket.broadcast.to(this._roomId).emit(
'notification', { method, data }
);
if (includeSender)
socket.emit('notification', { method, data });
} else {
socket.emit('notification', { method, data });
}
}
/*
* Pipe producers of peers that are running under another router to this router.
*/
async _pipeProducersToRouter(routerId) {
const router = this._mediasoupRouters.get(routerId);
// All peers that have a different router
const peersToPipe =
Object.values(this._peers)
.filter((peer) => peer.routerId !== routerId && peer.routerId !== null);
for (const peer of peersToPipe) {
const srcRouter = this._mediasoupRouters.get(peer.routerId);
for (const producerId of peer.producers.keys()) {
if (router._producers.has(producerId)) {
continue;
}
await srcRouter.pipeToRouter({
producerId : producerId,
router : router
});
}
}
}
async _getRouterId() {
const routerId = Room.getLeastLoadedRouter(
this._mediasoupWorkers, this._peers, this._mediasoupRouters);
await this._pipeProducersToRouter(routerId);
return routerId;
}
// Returns an array of router ids we need to pipe to:
// The combined set of routers of all peers, exluding the router of the peer itself.
_getRoutersToPipeTo(originRouterId) {
return Object.values(this._peers)
.map((peer) => peer.routerId)
.filter((routerId, index, self) =>
routerId !== originRouterId && self.indexOf(routerId) === index
);
}
}
module.exports = Room;
diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js
deleted file mode 100644
index 91741595..00000000
--- a/src/resources/js/meet/app.js
+++ /dev/null
@@ -1,1726 +0,0 @@
-import anchorme from 'anchorme'
-import { Dropdown } from 'bootstrap'
-import { library } from '@fortawesome/fontawesome-svg-core'
-import { OpenVidu } from 'openvidu-browser'
-
-class Roles {
- static get SUBSCRIBER() { return 1 << 0; }
- static get PUBLISHER() { return 1 << 1; }
- static get MODERATOR() { return 1 << 2; }
- static get SCREEN() { return 1 << 3; }
- static get OWNER() { return 1 << 4; }
-}
-
-// Disable jsnlog's error handlers added in OpenVidu 2.18
-// https://github.com/OpenVidu/openvidu/issues/631
-window.onerror = () => { return false }
-window.onunhandledrejection = () => { return false }
-
-function Meet(container)
-{
- let OV // OpenVidu object to initialize a session
- let session // Session object where the user will connect
- let publisher // Publisher object which the user will publish
- let audioActive = false // True if the audio track of the publisher is active
- let videoActive = false // True if the video track of the publisher is active
- let audioSource = '' // Currently selected microphone
- let videoSource = '' // Currently selected camera
- let sessionData // Room session metadata
-
- let screenOV // OpenVidu object to initialize a screen sharing session
- let screenSession // Session object where the user will connect for screen sharing
- let screenPublisher // Publisher object which the user will publish the screen sharing
-
- let publisherDefaults = {
- publishAudio: true, // Whether to start publishing with your audio unmuted or not
- publishVideo: true, // Whether to start publishing with your video enabled or not
- resolution: '640x480', // The resolution of your video
- frameRate: 30, // The frame rate of your video
- mirror: true // Whether to mirror your local video or not
- }
-
- let cameras = [] // List of user video devices
- let microphones = [] // List of user audio devices
- let connections = {} // Connected users in the session
-
- let chatCount = 0
- let volumeElement
- let publishersContainer
- let subscribersContainer
- let scrollStop
- let $t
-
- OV = ovInit()
-
- // Disconnect participant when browser's window close
- window.addEventListener('beforeunload', () => {
- leaveRoom()
- })
-
- window.addEventListener('resize', resize)
-
- // Public methods
- this.isScreenSharingSupported = isScreenSharingSupported
- this.joinRoom = joinRoom
- this.leaveRoom = leaveRoom
- this.setupStart = setupStart
- this.setupStop = setupStop
- this.setupSetAudioDevice = setupSetAudioDevice
- this.setupSetVideoDevice = setupSetVideoDevice
- this.switchAudio = switchAudio
- this.switchChannel = switchChannel
- this.switchScreen = switchScreen
- this.switchVideo = switchVideo
- this.updateSession = updateSession
-
- /**
- * Initialize OpenVidu instance
- */
- function ovInit()
- {
- let ov = new OpenVidu()
-
- // If there's anything to do, do it here.
- //ov.setAdvancedConfiguration(config)
-
- // Disable all logging except errors
- // ov.enableProdMode()
-
- return ov
- }
-
- /**
- * Join the room session
- *
- * @param data Session metadata and event handlers:
- * token - OpenVidu token for the main connection,
- * shareToken - OpenVidu token for screen-sharing connection,
- * nickname - Participant name,
- * role - connection (participant) role(s),
- * connections - Optional metadata for other users connections (current state),
- * channel - Selected interpreted language channel (two-letter language code)
- * languages - Supported languages (code-to-label map)
- * chatElement - DOM element for the chat widget,
- * counterElement - DOM element for the participants counter,
- * menuElement - DOM element of the room toolbar,
- * queueElement - DOM element for the Q&A queue (users with a raised hand)
- * onSuccess - Callback for session connection (join) success
- * onError - Callback for session connection (join) error
- * onDestroy - Callback for session disconnection event,
- * onDismiss - Callback for Dismiss action,
- * onJoinRequest - Callback for join request,
- * onConnectionChange - Callback for participant changes, e.g. role update,
- * onSessionDataUpdate - Callback for current user connection update,
- * onMediaSetup - Called when user clicks the Media setup button
- * translate - Translation function
- */
- function joinRoom(data) {
- // Create a container for subscribers and publishers
- publishersContainer = $('
').appendTo(container).get(0)
-
- resize();
- volumeMeterStop()
-
- data.params = {
- nickname: data.nickname, // user nickname
- // avatar: undefined // avatar image
- }
-
- $t = data.translate
-
- // Make sure all supported callbacks exist, so we don't have to check
- // their existence everywhere anymore
- let events = ['Success', 'Error', 'Destroy', 'Dismiss', 'JoinRequest', 'ConnectionChange',
- 'SessionDataUpdate', 'MediaSetup']
-
- events.map(event => 'on' + event).forEach(event => {
- if (!data[event]) {
- data[event] = () => {}
- }
- })
-
- sessionData = data
-
- // Init a session
- session = OV.initSession()
-
- // Handle connection creation events
- session.on('connectionCreated', event => {
- // Ignore the current user connection
- if (event.connection.role) {
- return
- }
-
- // This is the first event executed when a user joins in.
- // We'll create the video wrapper here, which can be re-used
- // in 'streamCreated' event handler.
-
- let metadata = connectionData(event.connection)
- const connId = metadata.connectionId
-
- // The connection metadata here is the initial metadata set on
- // connection initialization. There's no way to update it via OpenVidu API.
- // So, we merge the initial connection metadata with up-to-dated one that
- // we got from our database.
- if (sessionData.connections && connId in sessionData.connections) {
- Object.assign(metadata, sessionData.connections[connId])
- }
-
- metadata.element = participantCreate(metadata)
-
- connections[connId] = metadata
- })
-
- session.on('connectionDestroyed', event => {
- let connectionId = event.connection.connectionId
- let conn = connections[connectionId]
-
- if (conn) {
- // Remove elements related to the participant
- connectionHandDown(connectionId)
- $(conn.element).remove()
- delete connections[connectionId]
- }
-
- resize()
- })
-
- // On every new Stream received...
- session.on('streamCreated', event => {
- let connectionId = event.stream.connection.connectionId
- let metadata = connections[connectionId]
- let props = {
- // Prepend the video element so it is always before the watermark element
- insertMode: 'PREPEND'
- }
-
- // Subscribe to the Stream to receive it
- let subscriber = session.subscribe(event.stream, metadata.element, props);
-
- Object.assign(metadata, {
- audioActive: event.stream.audioActive,
- videoActive: event.stream.videoActive,
- videoDimensions: event.stream.videoDimensions
- })
-
- subscriber.on('videoElementCreated', event => {
- $(event.element).prop({
- tabindex: -1
- })
-
- resize()
- })
-
- // Update the wrapper controls/status
- participantUpdate(metadata.element, metadata)
-
- // Send the current user status to the connecting user
- // otherwise e.g. nickname might be not up to date
- signalUserUpdate(event.stream.connection)
- })
-
- // Stream properties changes e.g. audio/video muted/unmuted
- session.on('streamPropertyChanged', event => {
- let connectionId = event.stream.connection.connectionId
- let metadata = connections[connectionId]
-
- if (session.connection.connectionId == connectionId) {
- metadata = sessionData
- metadata.audioActive = audioActive
- metadata.videoActive = videoActive
- }
-
- if (metadata) {
- metadata[event.changedProperty] = event.newValue
-
- if (event.changedProperty == 'videoDimensions') {
- resize()
- } else {
- participantUpdate(metadata.element, metadata)
- }
- }
- })
-
- // Handle session disconnection events
- session.on('sessionDisconnected', event => {
- data.onDestroy(event)
- session = null
- resize()
- })
-
- // Handle signals from all participants
- session.on('signal', signalEventHandler)
-
- // Connect with the token
- session.connect(data.token, data.params)
- .then(() => {
- data.onSuccess()
-
- let params = {
- connectionId: session.connection.connectionId,
- role: data.role,
- audioActive,
- videoActive
- }
-
- params = Object.assign({}, data.params, params)
-
- publisher.on('videoElementCreated', event => {
- $(event.element).prop({
- muted: true, // Mute local video to avoid feedback
- disablePictureInPicture: true, // this does not work in Firefox
- tabindex: -1
- })
- resize()
- })
-
- let wrapper = participantCreate(params)
-
- if (data.role & Roles.PUBLISHER) {
- publisher.createVideoElement(wrapper, 'PREPEND')
- session.publish(publisher)
- }
-
- sessionData.element = wrapper
-
- // Create Q&A queue from the existing connections with rised hand.
- // Here we expect connections in a proper queue order
- Object.keys(data.connections || {}).forEach(key => {
- let conn = data.connections[key]
-
- if (conn.hand) {
- conn.connectionId = key
- connectionHandUp(conn)
- }
- })
-
- sessionData.channels = getChannels(data.connections)
-
- // Inform the vue component, so it can update some UI controls
- if (sessionData.channels.length) {
- sessionData.onSessionDataUpdate(sessionData)
- }
- })
- .catch(error => {
- console.error('There was an error connecting to the session: ', error.message);
- data.onError(error)
- })
-
- // Prepare the chat
- setupChat()
- }
-
- /**
- * Leave the room (disconnect)
- */
- function leaveRoom() {
- if (publisher) {
- volumeMeterStop()
-
- // Release any media
- let mediaStream = publisher.stream.getMediaStream()
- if (mediaStream) {
- mediaStream.getTracks().forEach(track => track.stop())
- }
-
- publisher = null
- }
-
- if (session) {
- session.disconnect();
- session = null
- }
-
- if (screenSession) {
- screenSession.disconnect();
- screenSession = null
- }
- }
-
- /**
- * Sets the audio and video devices for the session.
- * This will ask user for permission to access media devices.
- *
- * @param props Setup properties (videoElement, volumeElement, onSuccess, onError)
- */
- function setupStart(props) {
- // Note: After changing media permissions in Chrome/Firefox a page refresh is required.
- // That means that in a scenario where you first blocked access to media devices
- // and then allowed it we can't ask for devices list again and expect a different
- // result than before.
- // That's why we do not bother, and return ealy when we open the media setup dialog.
- if (publisher) {
- volumeMeterStart()
- return
- }
-
- publisher = OV.initPublisher(undefined, publisherDefaults)
-
- publisher.once('accessDenied', error => {
- props.onError(error)
- })
-
- publisher.once('accessAllowed', async () => {
- let mediaStream = publisher.stream.getMediaStream()
- let videoStream = mediaStream.getVideoTracks()[0]
- let audioStream = mediaStream.getAudioTracks()[0]
-
- audioActive = !!audioStream
- videoActive = !!videoStream
- volumeElement = props.volumeElement
-
- publisher.addVideoElement(props.videoElement)
-
- volumeMeterStart()
-
- const devices = await OV.getDevices()
-
- devices.forEach(device => {
- // device's props: deviceId, kind, label
- if (device.kind == 'videoinput') {
- cameras.push(device)
- if (videoStream && videoStream.label == device.label) {
- videoSource = device.deviceId
- }
- } else if (device.kind == 'audioinput') {
- microphones.push(device)
- if (audioStream && audioStream.label == device.label) {
- audioSource = device.deviceId
- }
- }
- })
-
- props.onSuccess({
- microphones,
- cameras,
- audioSource,
- videoSource,
- audioActive,
- videoActive
- })
- })
- }
-
- /**
- * Stop the setup "process", cleanup after it.
- */
- function setupStop() {
- volumeMeterStop()
- }
-
- /**
- * Change the publisher audio device
- *
- * @param deviceId Device identifier string
- */
- async function setupSetAudioDevice(deviceId) {
- if (!deviceId) {
- publisher.publishAudio(false)
- volumeMeterStop()
- audioActive = false
- } else if (deviceId == audioSource) {
- publisher.publishAudio(true)
- volumeMeterStart()
- audioActive = true
- } else {
- const mediaStream = publisher.stream.mediaStream
- const properties = Object.assign({}, publisherDefaults, {
- publishAudio: true,
- publishVideo: videoActive,
- audioSource: deviceId,
- videoSource: videoSource
- })
-
- volumeMeterStop()
-
- // Stop and remove the old track, otherwise you get "Concurrent mic process limit." error
- mediaStream.getAudioTracks().forEach(track => {
- track.stop()
- mediaStream.removeTrack(track)
- })
-
- // TODO: Handle errors
-
- await OV.getUserMedia(properties)
- .then(async (newMediaStream) => {
- await replaceTrack(newMediaStream.getAudioTracks()[0])
- volumeMeterStart()
- audioActive = true
- audioSource = deviceId
- })
- }
-
- return audioActive
- }
-
- /**
- * Change the publisher video device
- *
- * @param deviceId Device identifier string
- */
- async function setupSetVideoDevice(deviceId) {
- if (!deviceId) {
- publisher.publishVideo(false)
- videoActive = false
- } else if (deviceId == videoSource) {
- publisher.publishVideo(true)
- videoActive = true
- } else {
- const mediaStream = publisher.stream.mediaStream
- const properties = Object.assign({}, publisherDefaults, {
- publishAudio: audioActive,
- publishVideo: true,
- audioSource: audioSource,
- videoSource: deviceId
- })
-
- volumeMeterStop()
-
- // Stop and remove the old track, otherwise you get "Concurrent mic process limit." error
- mediaStream.getVideoTracks().forEach(track => {
- track.stop()
- mediaStream.removeTrack(track)
- })
-
- // TODO: Handle errors
-
- await OV.getUserMedia(properties)
- .then(async (newMediaStream) => {
- await replaceTrack(newMediaStream.getVideoTracks()[0])
- volumeMeterStart()
- videoActive = true
- videoSource = deviceId
- })
- }
-
- return videoActive
- }
-
- /**
- * A way to switch tracks in a stream.
- * Note: This is close to what publisher.replaceTrack() does but it does not
- * require the session.
- * Note: The old track needs to be removed before OV.getUserMedia() call,
- * otherwise we get "Concurrent mic process limit" error.
- */
- function replaceTrack(track) {
- const stream = publisher.stream
-
- const replaceMediaStreamTrack = () => {
- stream.mediaStream.addTrack(track);
-
- if (session) {
- session.sendVideoData(publisher.stream.streamManager, 5, true, 5);
- }
- }
-
- // Fix a bug in Chrome where you would start hearing yourself after audio device change
- // https://github.com/OpenVidu/openvidu/issues/449
- publisher.videoReference.muted = true
-
- return new Promise((resolve, reject) => {
- if (stream.isLocalStreamPublished) {
- // Only if the Publisher has been published it is necessary to call the native
- // Web API RTCRtpSender.replaceTrack()
- const senders = stream.getRTCPeerConnection().getSenders()
- let sender
-
- if (track.kind === 'video') {
- sender = senders.find(s => !!s.track && s.track.kind === 'video')
- } else {
- sender = senders.find(s => !!s.track && s.track.kind === 'audio')
- }
-
- if (!sender) return
-
- sender.replaceTrack(track).then(() => {
- replaceMediaStreamTrack()
- resolve()
- }).catch(error => {
- reject(error)
- })
- } else {
- // Publisher not published. Simply modify local MediaStream tracks
- replaceMediaStreamTrack()
- resolve()
- }
- })
- }
-
- /**
- * Setup the chat UI
- */
- function setupChat() {
- // The UI elements are created in the vue template
- // Here we add a logic for how they work
-
- const chat = $(sessionData.chatElement).find('.chat').get(0)
- const textarea = $(sessionData.chatElement).find('textarea')
- const button = $(sessionData.menuElement).find('.link-chat')
-
- textarea.on('keydown', e => {
- if (e.keyCode == 13 && !e.shiftKey) {
- if (textarea.val().length) {
- signalChat(textarea.val())
- textarea.val('')
- }
-
- return false
- }
- })
-
- // Add an element for the count of unread messages on the chat button
- button.append('')
- .on('click', () => {
- button.find('.badge').text('')
- chatCount = 0
- // When opening the chat scroll it to the bottom, or we shouldn't?
- scrollStop = false
- chat.scrollTop = chat.scrollHeight
- })
-
- $(chat).on('scroll', event => {
- // Detect manual scrollbar moves, disable auto-scrolling until
- // the scrollbar is positioned on the element bottom again
- scrollStop = chat.scrollTop + chat.offsetHeight < chat.scrollHeight
- })
- }
-
- /**
- * Signal events handler
- */
- function signalEventHandler(signal) {
- let conn, data
- let connId = signal.from ? signal.from.connectionId : null
-
- switch (signal.type) {
- case 'signal:userChanged':
- // TODO: Use 'signal:connectionUpdate' for nickname updates?
- if (conn = connections[connId]) {
- data = JSON.parse(signal.data)
-
- conn.nickname = data.nickname
- participantUpdate(conn.element, conn)
- nicknameUpdate(data.nickname, connId)
- }
- break
-
- case 'signal:chat':
- data = JSON.parse(signal.data)
- data.id = connId
- pushChatMessage(data)
- break
-
- case 'signal:joinRequest':
- // accept requests from the server only
- if (!connId) {
- sessionData.onJoinRequest(JSON.parse(signal.data))
- }
- break
-
- case 'signal:connectionUpdate':
- // accept requests from the server only
- if (!connId) {
- data = JSON.parse(signal.data)
-
- connectionUpdate(data)
- }
- break
- }
- }
-
- /**
- * Send the chat message to other participants
- *
- * @param message Message string
- */
- function signalChat(message) {
- let data = {
- nickname: sessionData.params.nickname,
- message
- }
-
- session.signal({
- data: JSON.stringify(data),
- type: 'chat'
- })
- }
-
- /**
- * Add a message to the chat
- *
- * @param data Object with a message, nickname, id (of the connection, empty for self)
- */
- function pushChatMessage(data) {
- let message = $('').text(data.message).text() // make the message secure
-
- // Format the message, convert emails and urls to links
- message = anchorme({
- input: message,
- options: {
- attributes: {
- target: "_blank"
- },
- // any link above 20 characters will be truncated
- // to 20 characters and ellipses at the end
- truncate: 20,
- // characters will be taken out of the middle
- middleTruncation: true
- }
- // TODO: anchorme is extensible, we could support
- // github/phabricator's markup e.g. backticks for code samples
- })
-
- message = message.replace(/\r?\n/, ' ')
-
- // Display the message
- let isSelf = data.id == session.connectionId
- let chat = $(sessionData.chatElement).find('.chat')
- let box = chat.find('.message').last()
-
- message = $('
').html(message)
-
- message.find('a').attr('rel', 'noreferrer')
-
- if (box.length && box.data('id') == data.id) {
- // A message from the same user as the last message, no new box needed
- message.appendTo(box)
- } else {
- box = $('
').data('id', data.id)
- .append($('
').text(data.nickname || ''))
- .append(message)
- .appendTo(chat)
-
- if (isSelf) {
- box.addClass('self')
- }
- }
-
- // Count unread messages
- if (!$(sessionData.chatElement).is('.open')) {
- if (!isSelf) {
- chatCount++
- }
- } else {
- chatCount = 0
- }
-
- $(sessionData.menuElement).find('.link-chat .badge').text(chatCount ? chatCount : '')
-
- // Scroll the chat element to the end
- if (!scrollStop) {
- chat.get(0).scrollTop = chat.get(0).scrollHeight
- }
- }
-
- /**
- * Send the user properties update signal to other participants
- *
- * @param connection Optional connection to which the signal will be sent
- * If not specified the signal is sent to all participants
- */
- function signalUserUpdate(connection) {
- let data = {
- nickname: sessionData.params.nickname
- }
-
- session.signal({
- data: JSON.stringify(data),
- type: 'userChanged',
- to: connection ? [connection] : undefined
- })
-
- // The same nickname for screen sharing session
- if (screenSession) {
- screenSession.signal({
- data: JSON.stringify(data),
- type: 'userChanged',
- to: connection ? [connection] : undefined
- })
- }
- }
-
- /**
- * Switch interpreted language channel
- *
- * @param channel Two-letter language code
- */
- function switchChannel(channel) {
- sessionData.channel = channel
-
- // Mute/unmute all connections depending on the selected channel
- participantUpdateAll()
- }
-
- /**
- * Mute/Unmute audio for current session publisher
- */
- function switchAudio() {
- // TODO: If user has no devices or denied access to them in the setup,
- // the button will just not work. Find a way to make it working
- // after user unlocks his devices. For now he has to refresh
- // the page and join the room again.
- if (microphones.length) {
- try {
- publisher.publishAudio(!audioActive)
- audioActive = !audioActive
- } catch (e) {
- console.error(e)
- }
- }
-
- return audioActive
- }
-
- /**
- * Mute/Unmute video for current session publisher
- */
- function switchVideo() {
- // TODO: If user has no devices or denied access to them in the setup,
- // the button will just not work. Find a way to make it working
- // after user unlocks his devices. For now he has to refresh
- // the page and join the room again.
- if (cameras.length) {
- try {
- publisher.publishVideo(!videoActive)
- videoActive = !videoActive
- } catch (e) {
- console.error(e)
- }
- }
-
- return videoActive
- }
-
- /**
- * Switch on/off screen sharing
- */
- function switchScreen(callback) {
- if (screenPublisher) {
- // Note: This is what the original openvidu-call app does.
- // It is probably better for performance reasons to close the connection,
- // than to use unpublish() and keep the connection open.
- screenSession.disconnect()
- screenSession = null
- screenPublisher = null
-
- if (callback) {
- // Note: Disconnecting invalidates the token, we have to inform the vue component
- // to update UI state (and be prepared to request a new token).
- callback(false)
- }
-
- return
- }
-
- screenConnect(callback)
- }
-
- /**
- * Detect if screen sharing is supported by the browser
- */
- function isScreenSharingSupported() {
- return !!OV.checkScreenSharingCapabilities();
- }
-
- /**
- * Update participant connection state
- */
- function connectionUpdate(data) {
- let conn = connections[data.connectionId]
- let refresh = false
- let handUpdate = conn => {
- if ('hand' in data && data.hand != conn.hand) {
- if (data.hand) {
- connectionHandUp(conn)
- } else {
- connectionHandDown(data.connectionId)
- }
- }
- }
-
- // It's me
- if (session.connection.connectionId == data.connectionId) {
- const rolePublisher = data.role && data.role & Roles.PUBLISHER
- const roleModerator = data.role && data.role & Roles.MODERATOR
- const isPublisher = sessionData.role & Roles.PUBLISHER
- const isModerator = sessionData.role & Roles.MODERATOR
-
- // demoted to a subscriber
- if ('role' in data && isPublisher && !rolePublisher) {
- session.unpublish(publisher)
- // FIXME: There's a reference in OpenVidu to a video element that should not
- // exist anymore. It causes issues when we try to do publish/unpublish
- // sequence multiple times in a row. So, we're clearing the reference here.
- let videos = publisher.stream.streamManager.videos
- publisher.stream.streamManager.videos = videos.filter(video => video.video.parentNode != null)
- }
-
- handUpdate(sessionData)
-
- // merge the changed data into internal session metadata object
- sessionData = Object.assign({}, sessionData, data, { audioActive, videoActive })
-
- // update the participant element
- sessionData.element = participantUpdate(sessionData.element, sessionData)
-
- // promoted/demoted to/from a moderator
- if ('role' in data) {
- // Update all participants, to enable/disable the popup menu
- refresh = (!isModerator && roleModerator) || (isModerator && !roleModerator)
- }
-
- // promoted to a publisher
- if ('role' in data && !isPublisher && rolePublisher) {
- publisher.createVideoElement(sessionData.element, 'PREPEND')
- session.publish(publisher).then(() => {
- sessionData.audioActive = publisher.stream.audioActive
- sessionData.videoActive = publisher.stream.videoActive
-
- sessionData.onSessionDataUpdate(sessionData)
- })
-
- // Open the media setup dialog
- // Note: If user didn't give permission to media before joining the room
- // he will not be able to use them now. Changing permissions requires
- // a page refresh.
- // Note: In Firefox I'm always being asked again for media permissions.
- // It does not happen in Chrome. In Chrome the cam/mic will be just re-used.
- // I.e. streaming starts automatically.
- // It might make sense to not start streaming automatically in any cirmustances,
- // display the dialog and wait until user closes it, but this would be
- // a bigger refactoring.
- sessionData.onMediaSetup()
- }
- } else if (conn) {
- handUpdate(conn)
-
- // merge the changed data into internal session metadata object
- Object.keys(data).forEach(key => { conn[key] = data[key] })
-
- conn.element = participantUpdate(conn.element, conn)
- }
-
- // Update channels list
- sessionData.channels = getChannels(connections)
-
- // The channel user was using has been removed (or rather the participant stopped being an interpreter)
- if (sessionData.channel && !sessionData.channels.includes(sessionData.channel)) {
- sessionData.channel = null
- refresh = true
- }
-
- if (refresh) {
- participantUpdateAll()
- }
-
- // Inform the vue component, so it can update some UI controls
- sessionData.onSessionDataUpdate(sessionData)
- }
-
- /**
- * Handler for Hand-Up "signal"
- */
- function connectionHandUp(connection) {
- connection.isSelf = session.connection.connectionId == connection.connectionId
-
- let element = $(nicknameWidget(connection))
-
- participantUpdate(element, connection)
-
- element.attr('id', 'qa' + connection.connectionId)
- .appendTo($(sessionData.queueElement).show())
-
- setTimeout(() => element.addClass('widdle'), 50)
- }
-
- /**
- * Handler for Hand-Down "signal"
- */
- function connectionHandDown(connectionId) {
- let list = $(sessionData.queueElement)
-
- list.find('#qa' + connectionId).remove();
-
- if (!list.find('.meet-nickname').length) {
- list.hide();
- }
- }
-
- /**
- * Update participant nickname in the UI
- *
- * @param nickname Nickname
- * @param connectionId Connection identifier of the user
- */
- function nicknameUpdate(nickname, connectionId) {
- if (connectionId) {
- $(sessionData.chatElement).find('.chat').find('.message').each(function() {
- let elem = $(this)
- if (elem.data('id') == connectionId) {
- elem.find('.nickname').text(nickname || '')
- }
- })
-
- $(sessionData.queueElement).find('#qa' + connectionId + ' .content').text(nickname || '')
- }
- }
-
- /**
- * Create a participant element in the matrix. Depending on the connection role
- * parameter it will be a video element wrapper inside the matrix or a simple
- * tag-like element on the subscribers list.
- *
- * @param params Connection metadata/params
- * @param content Optional content to prepend to the element
- *
- * @return The element
- */
- function participantCreate(params, content) {
- let element
-
- params.isSelf = params.isSelf || session.connection.connectionId == params.connectionId
-
- if ((!params.language && params.role & Roles.PUBLISHER) || params.role & Roles.SCREEN) {
- // publishers and shared screens
- element = publisherCreate(params, content)
- } else {
- // subscribers and language interpreters
- element = subscriberCreate(params, content)
- }
-
- setTimeout(resize, 50);
-
- return element
- }
-
- /**
- * Create a