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) - subscribersContainer = $('
').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