diff --git a/meet/server/lib/Peer.js b/meet/server/lib/Peer.js
index 96a95f55..eb0984e6 100644
--- a/meet/server/lib/Peer.js
+++ b/meet/server/lib/Peer.js
@@ -1,295 +1,281 @@
const EventEmitter = require('events').EventEmitter;
const Logger = require('./Logger');
const logger = new Logger('Peer');
class Peer extends EventEmitter
{
constructor({ id, roomId })
{
logger.info('constructor() [id:"%s"]', id);
super();
this._id = id;
this._roomId = roomId;
this._socket = null;
this._closed = false;
this._role = 0;
this._nickname = false;
this._picture = null;
this._email = null;
this._routerId = null;
this._rtpCapabilities = null;
this._raisedHand = false;
- this._raisedHandTimestamp = null;
-
this._transports = new Map();
this._producers = new Map();
this._consumers = new Map();
}
close()
{
logger.info('close()');
this._closed = true;
// Iterate and close all mediasoup Transport associated to this Peer, so all
// its Producers and Consumers will also be closed.
for (const transport of this.transports.values())
{
transport.close();
}
if (this.socket)
this.socket.disconnect(true);
this.emit('close');
}
get id()
{
return this._id;
}
set id(id)
{
this._id = id;
}
get roomId()
{
return this._roomId;
}
set roomId(roomId)
{
this._roomId = roomId;
}
get socket()
{
return this._socket;
}
set socket(socket)
{
this._socket = socket;
if (this.socket)
{
this.socket.on('disconnect', () =>
{
if (this.closed)
return;
logger.debug('"disconnect" event [id:%s]', this.id);
this.close();
});
}
}
get closed()
{
return this._closed;
}
get role()
{
return this._role;
}
get nickname()
{
return this._nickname;
}
set nickname(nickname)
{
if (nickname !== this._nickname)
{
this._nickname = nickname;
this.emit('nicknameChanged', {});
}
}
get picture()
{
return this._picture;
}
set picture(picture)
{
if (picture !== this._picture)
{
const oldPicture = this._picture;
this._picture = picture;
this.emit('pictureChanged', { oldPicture });
}
}
get email()
{
return this._email;
}
set email(email)
{
this._email = email;
}
get routerId()
{
return this._routerId;
}
set routerId(routerId)
{
this._routerId = routerId;
}
get rtpCapabilities()
{
return this._rtpCapabilities;
}
set rtpCapabilities(rtpCapabilities)
{
this._rtpCapabilities = rtpCapabilities;
}
get raisedHand()
{
return this._raisedHand;
}
set raisedHand(raisedHand)
{
- raisedHand ?
- this._raisedHandTimestamp = Date.now() :
- this._raisedHandTimestamp = null;
-
this._raisedHand = raisedHand;
}
- get raisedHandTimestamp()
- {
- return this._raisedHandTimestamp;
- }
-
get transports()
{
return this._transports;
}
get producers()
{
return this._producers;
}
get consumers()
{
return this._consumers;
}
setRole(newRole)
{
if (this._role != newRole) {
this._role = newRole;
- logger.info('setRole() | [newRole:%s]', newRole);
-
this.emit('gotRole', { newRole });
}
}
hasRole(role)
{
return !!(this._role & role);
}
addTransport(id, transport)
{
this.transports.set(id, transport);
}
getTransport(id)
{
return this.transports.get(id);
}
getConsumerTransport()
{
return Array.from(this.transports.values())
.find((t) => t.appData.consuming);
}
removeTransport(id)
{
this.transports.delete(id);
}
addProducer(id, producer)
{
this.producers.set(id, producer);
}
getProducer(id)
{
return this.producers.get(id);
}
removeProducer(id)
{
this.producers.delete(id);
}
addConsumer(id, consumer)
{
this.consumers.set(id, consumer);
}
getConsumer(id)
{
return this.consumers.get(id);
}
removeConsumer(id)
{
this.consumers.delete(id);
}
get peerInfo()
{
const peerInfo =
{
id: this.id,
nickname: this.nickname,
// picture: this.picture,
- role: this._role,
- raisedHand: this.raisedHand,
- // raisedHandTimestamp: this.raisedHandTimestamp
+ role: this.role,
+ raisedHand: this.raisedHand
};
return peerInfo;
}
}
module.exports = Peer;
diff --git a/meet/server/lib/Room.js b/meet/server/lib/Room.js
index 62379ac7..ff62e651 100644
--- a/meet/server/lib/Room.js
+++ b/meet/server/lib/Room.js
@@ -1,1256 +1,1175 @@
const EventEmitter = require('events').EventEmitter;
const AwaitQueue = require('awaitqueue');
const axios = require('axios');
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
{
/*
* 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 routerLoads = new Map();
const workerLoads = new Map();
const pipedRoutersIds = new Set();
// Calculate router loads by adding up peers per router,
// and collected piped routers
for (const peer of peers.values())
{
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));
}
}
}
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;
this._allPeers = peers;
// Room ID.
this._roomId = roomId;
// Closed flag.
this._closed = false;
// Joining queue
this._queue = new AwaitQueue();
this._lastN = [];
this._peers = {};
this._selfDestructTimeout = null;
// Array of mediasoup Router instances.
this._mediasoupRouters = mediasoupRouters;
-
- // mediasoup AudioLevelObserver.
- this._audioLevelObserver = audioLevelObserver;
-
- // Current active speaker.
- this._currentActiveSpeaker = null;
-
- this._handleAudioLevelObserver();
}
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.
for (const peer in this._peers)
{
if (!this._peers[peer].closed)
this._peers[peer].close();
}
this._peers = null;
// Close the mediasoup Routers.
for (const router of this._mediasoupRouters.values())
{
router.close();
}
this._allPeers = null;
this._mediasoupWorkers = null;
this._mediasoupRouters.clear();
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);
}
- _handleAudioLevelObserver()
- {
-/*
- // Set audioLevelObserver events.
- this._audioLevelObserver.on('volumes', (volumes) =>
- {
- const { producer, volume } = volumes[0];
-
- // Notify all Peers.
- for (const peer of this.getPeers())
- {
- this._notification(
- peer.socket,
- 'activeSpeaker',
- {
- peerId : producer.appData.peerId,
- volume : volume
- });
- }
- });
- this._audioLevelObserver.on('silence', () =>
- {
- // Notify all Peers.
- for (const peer of this.getPeers())
- {
- this._notification(
- peer.socket,
- 'activeSpeaker',
- { peerId: null }
- );
- }
- });
-*/
- }
-
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');
if (this._selfDestructTimeout)
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;
}
_peerJoining(peer)
{
this._queue.push(async () =>
{
peer.socket.join(this._roomId);
// If we don't have this peer, add to end
!this._lastN.includes(peer.id) && this._lastN.push(peer.id);
this._peers[peer.id] = peer;
// Assign routerId
peer.routerId = await this._getRouterId();
this._handlePeer(peer);
let turnServers;
if ('turnAPIURI' in config)
{
try
{
const { data } = await axios.get(
config.turnAPIURI,
{
timeout : config.turnAPITimeout || 2000,
params : {
...config.turnAPIparams,
'api_key' : config.turnAPIKey,
'ip' : peer.socket.request.connection.remoteAddress
}
});
turnServers = [ {
urls : data.uris,
username : data.username,
credential : data.password
} ];
}
catch (error)
{
if ('backupTurnServers' in config && config.backupTurnServers.length)
turnServers = config.backupTurnServers;
logger.error('_peerJoining() | error on REST turn [error:"%o"]', error);
}
}
else if ('backupTurnServers' in config && config.backupTurnServers.length)
{
turnServers = config.backupTurnServers;
}
this._notification(peer.socket, 'roomReady', { turnServers });
})
.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
this._notification(peer.socket, 'changeNickname', {
peerId: peer.id,
nickname: peer.nickname
}, true);
});
peer.on('gotRole', ({ newRole }) =>
{
// Spread to others
this._notification(peer.socket, 'gotRole', {
peerId: peer.id,
role: newRole
}, 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);
// Remove from lastN
this._lastN = this._lastN.filter((id) => id !== peer.id);
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);
console.log(request.method);
switch (request.method)
{
case 'getRouterRtpCapabilities':
{
cb(null, router.rtpCapabilities);
break;
}
case 'join':
{
const {
nickname,
picture,
rtpCapabilities
} = request.data;
// Store client data into the Peer data object.
peer.nickname = nickname;
peer.picture = picture;
peer.rtpCapabilities = rtpCapabilities;
// Tell the new Peer about already joined Peers.
// And also create Consumers for existing Producers.
const joinedPeers = this.getPeers(peer);
const peerInfos = joinedPeers
.map((joinedPeer) => (joinedPeer.peerInfo));
cb(null, {
id: peer.id,
role: peer.role,
peers: peerInfos,
});
for (const joinedPeer of joinedPeers)
{
// Create Consumers for existing Producers.
for (const producer of joinedPeer.producers.values())
{
this._createConsumer(
{
consumerPeer : peer,
producerPeer : joinedPeer,
producer
});
}
}
// Notify the new Peer to all other Peers.
for (const otherPeer of this.getPeers(peer))
{
this._notification(
otherPeer.socket,
'newPeer',
peer.peerInfo
);
}
logger.debug(
'peer joined [peer: "%s", nickname: "%s", picture: "%s"]',
peer.id, nickname, picture);
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)
- )
+ if (!appData.source || ![ 'mic', 'webcam', 'screen' ].includes(appData.source))
throw new Error('invalid producer source');
- if (
- appData.source === 'mic' &&
- !this._hasPermission(peer, Roles.PUBLISHER)
- )
+ if (appData.source === 'mic' && !peer.hasRole(Roles.PUBLISHER))
throw new Error('peer not authorized');
- if (
- appData.source === 'webcam' &&
- !this._hasPermission(peer, Roles.PUBLISHER)
- )
+ if (appData.source === 'webcam' && !peer.hasRole(Roles.PUBLISHER))
throw new Error('peer not authorized');
- if (
- appData.source === 'screen' &&
- !this._hasPermission(peer, Roles.PUBLISHER)
- )
+ 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 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);
});
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:setRole':
{
- if (!this._hasPermission(peer, Roles.MODERATOR))
+ if (!peer.hasRole(Roles.MODERATOR))
throw new Error('peer not authorized');
const { peerId, role } = request.data;
const giveRolePeer = this._peers[peerId];
if (!giveRolePeer)
throw new Error(`peer with id "${peerId}" not found`);
// TODO: check if role is valid value
// This will propagate the event automatically
giveRolePeer.setRole(role);
// Return no error
cb();
break;
}
case 'raisedHand':
{
const { raisedHand } = request.data;
peer.raisedHand = raisedHand;
// Spread to others
this._notification(peer.socket, 'raisedHand', {
- peerId : peer.id,
- raisedHand : raisedHand,
- raisedHandTimestamp : peer.raisedHandTimestamp
+ peerId: peer.id,
+ raisedHand: raisedHand,
}, true);
// Return no error
cb();
break;
}
case 'moderator:closeRoom':
{
- if (!this._hasPermission(peer, Roles.OWNER))
+ 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();
break;
}
-/*
+
case 'moderator:kickPeer':
{
- if (!this._hasPermission(peer, Roles.MODERATOR))
+ 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:kick');
+ this._notification(kickPeer.socket, 'moderator:kickPeer');
kickPeer.close();
cb();
break;
}
- case 'moderator:lowerHand':
- {
- if (!this._hasPermission(peer, Roles.MODERATOR))
- throw new Error('peer not authorized');
-
- const { peerId } = request.data;
-
- const lowerPeer = this._peers[peerId];
-
- if (!lowerPeer)
- throw new Error(`peer with id "${peerId}" not found`);
-
- this._notification(lowerPeer.socket, 'moderator:lowerHand');
-
- 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;
}
// 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 });
});
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);
}
}
- _hasPermission(peer, role)
- {
- return !!(peer.role & role);
- }
-
/**
* 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 routher 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._allPeers, 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/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php
index fba7806e..d11b4081 100644
--- a/src/app/Http/Controllers/API/V4/OpenViduController.php
+++ b/src/app/Http/Controllers/API/V4/OpenViduController.php
@@ -1,557 +1,528 @@
first();
// This isn't a room, bye bye
if (!$room) {
return $this->errorResponse(404, \trans('meet.room-not-found'));
}
// Only the moderator can do it
if (!$this->isModerator($room)) {
return $this->errorResponse(403);
}
if (!$room->requestAccept($reqid)) {
return $this->errorResponse(500, \trans('meet.session-request-accept-error'));
}
return response()->json(['status' => 'success']);
}
/**
* Deny the room join request.
*
* @param string $id Room identifier (name)
* @param string $reqid Request identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function denyJoinRequest($id, $reqid)
{
$room = Room::where('name', $id)->first();
// This isn't a room, bye bye
if (!$room) {
return $this->errorResponse(404, \trans('meet.room-not-found'));
}
// Only the moderator can do it
if (!$this->isModerator($room)) {
return $this->errorResponse(403);
}
if (!$room->requestDeny($reqid)) {
return $this->errorResponse(500, \trans('meet.session-request-deny-error'));
}
return response()->json(['status' => 'success']);
}
/**
* Create a connection for screen sharing.
*
* @param string $id Room identifier (name)
*
* @return \Illuminate\Http\JsonResponse
*/
public function createConnection($id)
{
$room = Room::where('name', $id)->first();
// This isn't a room, bye bye
if (!$room) {
return $this->errorResponse(404, \trans('meet.room-not-found'));
}
$connection = $this->getConnectionFromRequest();
if (
!$connection
|| $connection->session_id != $room->session_id
|| ($connection->role & Room::ROLE_PUBLISHER) == 0
) {
return $this->errorResponse(403);
}
$response = $room->getSessionToken(Room::ROLE_SCREEN);
return response()->json(['status' => 'success', 'token' => $response['token']]);
}
- /**
- * Dismiss the participant/connection from the session.
- *
- * @param string $id Room identifier (name)
- * @param string $conn Connection identifier
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function dismissConnection($id, $conn)
- {
- $connection = Connection::where('id', $conn)->first();
-
- // There's no such connection, bye bye
- if (!$connection || $connection->room->name != $id) {
- return $this->errorResponse(404, \trans('meet.connection-not-found'));
- }
-
- // Only the moderator can do it
- if (!$this->isModerator($connection->room)) {
- return $this->errorResponse(403);
- }
-
- if (!$connection->dismiss()) {
- return $this->errorResponse(500, \trans('meet.connection-dismiss-error'));
- }
-
- return response()->json(['status' => 'success']);
- }
-
/**
* Listing of rooms that belong to the authenticated user.
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user = Auth::guard()->user();
$rooms = Room::where('user_id', $user->id)->orderBy('name')->get();
if (count($rooms) == 0) {
// Create a room for the user (with a random and unique name)
while (true) {
$name = strtolower(\App\Utils::randStr(3, 3, '-'));
if (!Room::where('name', $name)->count()) {
break;
}
}
$room = Room::create([
'name' => $name,
'user_id' => $user->id
]);
$rooms = collect([$room]);
}
$result = [
'list' => $rooms,
'count' => count($rooms),
];
return response()->json($result);
}
/**
* Join the room session. Each room has one owner, and the room isn't open until the owner
* joins (and effectively creates the session).
*
* @param string $id Room identifier (name)
*
* @return \Illuminate\Http\JsonResponse
*/
public function joinRoom($id)
{
$room = Room::where('name', $id)->first();
// Room does not exist, or the owner is deleted
if (!$room || !$room->owner) {
return $this->errorResponse(404, \trans('meet.room-not-found'));
}
// Check if there's still a valid meet entitlement for the room owner
if (!$room->owner->hasSku('meet')) {
return $this->errorResponse(404, \trans('meet.room-not-found'));
}
$user = Auth::guard()->user();
$isOwner = $user && $user->id == $room->user_id;
$init = !empty(request()->input('init'));
// There's no existing session
if (!$room->hasSession()) {
// Participants can't join the room until the session is created by the owner
if (!$isOwner) {
return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 323]);
}
// The room owner can create the session on request
if (!$init) {
return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 324]);
}
$session = $room->createSession();
if (empty($session)) {
return $this->errorResponse(500, \trans('meet.session-create-error'));
}
}
$settings = $room->getSettings(['locked', 'nomedia', 'password']);
$password = (string) $settings['password'];
$config = [
'locked' => $settings['locked'] === 'true',
'nomedia' => $settings['nomedia'] === 'true',
'password' => $isOwner ? $password : '',
'requires_password' => !$isOwner && strlen($password),
];
$response = ['config' => $config];
// Validate room password
if (!$isOwner && strlen($password)) {
$request_password = request()->input('password');
if ($request_password !== $password) {
return $this->errorResponse(422, \trans('meet.session-password-error'), $response + ['code' => 325]);
}
}
// Handle locked room
if (!$isOwner && $config['locked']) {
$nickname = request()->input('nickname');
$picture = request()->input('picture');
$requestId = request()->input('requestId');
$request = $requestId ? $room->requestGet($requestId) : null;
$error = \trans('meet.session-room-locked-error');
// Request already has been processed (not accepted yet, but it could be denied)
if (empty($request['status']) || $request['status'] != Room::REQUEST_ACCEPTED) {
if (!$request) {
if (empty($nickname) || empty($requestId) || !preg_match('/^[a-z0-9]{8,32}$/i', $requestId)) {
return $this->errorResponse(422, $error, $response + ['code' => 326]);
}
if (empty($picture)) {
$svg = file_get_contents(resource_path('images/user.svg'));
$picture = 'data:image/svg+xml;base64,' . base64_encode($svg);
} elseif (!preg_match('|^data:image/png;base64,[a-zA-Z0-9=+/]+$|', $picture)) {
return $this->errorResponse(422, $error, $response + ['code' => 326]);
}
// TODO: Resize when big/make safe the user picture?
$request = ['nickname' => $nickname, 'requestId' => $requestId, 'picture' => $picture];
if (!$room->requestSave($requestId, $request)) {
// FIXME: should we use error code 500?
return $this->errorResponse(422, $error, $response + ['code' => 326]);
}
// Send the request (signal) to the owner
$result = $room->signal('joinRequest', $request, Room::ROLE_MODERATOR);
}
return $this->errorResponse(422, $error, $response + ['code' => 327]);
}
}
// Initialize connection tokens
if ($init) {
// Choose the connection role
$canPublish = !empty(request()->input('canPublish')) && (empty($config['nomedia']) || $isOwner);
$role = $canPublish ? Room::ROLE_PUBLISHER : Room::ROLE_SUBSCRIBER;
if ($isOwner) {
$role |= Room::ROLE_MODERATOR;
$role |= Room::ROLE_OWNER;
}
// Create session token for the current user/connection
$response = $room->getSessionToken($role);
if (empty($response)) {
return $this->errorResponse(500, \trans('meet.session-join-error'));
}
// Get up-to-date connections metadata
$response['connections'] = $room->getSessionConnections();
$response_code = 200;
$response['role'] = $role;
$response['config'] = $config;
} else {
$response_code = 422;
$response['code'] = 322;
}
return response()->json($response, $response_code);
}
/**
* Set the domain configuration.
*
* @param string $id Room identifier (name)
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function setRoomConfig($id)
{
$room = Room::where('name', $id)->first();
// Room does not exist, or the owner is deleted
if (!$room || !$room->owner) {
return $this->errorResponse(404);
}
$user = Auth::guard()->user();
// Only room owner can configure the room
if ($user->id != $room->user_id) {
return $this->errorResponse(403);
}
$input = request()->input();
$errors = [];
foreach ($input as $key => $value) {
switch ($key) {
case 'password':
if ($value === null || $value === '') {
$input[$key] = null;
} else {
// TODO: Do we have to validate the password in any way?
}
break;
case 'locked':
$input[$key] = $value ? 'true' : null;
break;
case 'nomedia':
$input[$key] = $value ? 'true' : null;
break;
default:
$errors[$key] = \trans('meet.room-unsupported-option-error');
}
}
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
if (!empty($input)) {
$room->setSettings($input);
}
return response()->json([
'status' => 'success',
'message' => \trans('meet.room-setconfig-success'),
]);
}
/**
* Update the participant/connection parameters (e.g. role).
*
* @param string $id Room identifier (name)
* @param string $conn Connection identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function updateConnection($id, $conn)
{
$connection = Connection::where('id', $conn)->first();
// There's no such connection, bye bye
if (!$connection || $connection->room->name != $id) {
return $this->errorResponse(404, \trans('meet.connection-not-found'));
}
foreach (request()->input() as $key => $value) {
switch ($key) {
case 'hand':
// Only possible on user's own connection(s)
if (!$this->isSelfConnection($connection)) {
return $this->errorResponse(403);
}
if ($value) {
// Store current time, so we know the order in the queue
$connection->metadata = ['hand' => time()] + $connection->metadata;
} else {
$connection->metadata = array_diff_key($connection->metadata, ['hand' => 0]);
}
break;
case 'language':
// Only the moderator can do it
if (!$this->isModerator($connection->room)) {
return $this->errorResponse(403);
}
if ($value) {
if (preg_match('/^[a-z]{2}$/', $value)) {
$connection->metadata = ['language' => $value] + $connection->metadata;
}
} else {
$connection->metadata = array_diff_key($connection->metadata, ['language' => 0]);
}
break;
case 'role':
// Only the moderator can do it
if (!$this->isModerator($connection->room)) {
return $this->errorResponse(403);
}
// The 'owner' role is not assignable
if ($value & Room::ROLE_OWNER && !($connection->role & Room::ROLE_OWNER)) {
return $this->errorResponse(403);
} elseif (!($value & Room::ROLE_OWNER) && ($connection->role & Room::ROLE_OWNER)) {
return $this->errorResponse(403);
}
// The room owner has always a 'moderator' role
if (!($value & Room::ROLE_MODERATOR) && $connection->role & Room::ROLE_OWNER) {
$value |= Room::ROLE_MODERATOR;
}
// Promotion to publisher? Put the user hand down
if ($value & Room::ROLE_PUBLISHER && !($connection->role & Room::ROLE_PUBLISHER)) {
$connection->metadata = array_diff_key($connection->metadata, ['hand' => 0]);
}
// Non-publisher cannot be a language interpreter
if (!($value & Room::ROLE_PUBLISHER)) {
$connection->metadata = array_diff_key($connection->metadata, ['language' => 0]);
}
$connection->{$key} = $value;
break;
}
}
// The connection observer will send a signal to everyone when needed
$connection->save();
return response()->json(['status' => 'success']);
}
/**
* Webhook as triggered from OpenVidu server
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\Response The response
*/
public function webhook(Request $request)
{
\Log::debug($request->getContent());
switch ((string) $request->input('event')) {
case 'sessionDestroyed':
// When all participants left the room OpenVidu dispatches sessionDestroyed
// event. We'll remove the session reference from the database.
$sessionId = $request->input('sessionId');
$room = Room::where('session_id', $sessionId)->first();
if ($room) {
$room->session_id = null;
$room->save();
}
// Remove all connections
// Note: We could remove connections one-by-one via the 'participantLeft' event
// but that could create many INSERTs when the session (with many participants) ends
// So, it is better to remove them all in a single INSERT.
Connection::where('session_id', $sessionId)->delete();
break;
}
return response('Success', 200);
}
/**
* Check if current user is a moderator for the specified room.
*
* @param \App\OpenVidu\Room $room The room
*
* @return bool True if the current user is the room moderator
*/
protected function isModerator(Room $room): bool
{
$user = Auth::guard()->user();
// The room owner is a moderator
if ($user && $user->id == $room->user_id) {
return true;
}
// Moderator's authentication via the extra request header
if (
($connection = $this->getConnectionFromRequest())
&& $connection->session_id === $room->session_id
&& $connection->role & Room::ROLE_MODERATOR
) {
return true;
}
return false;
}
/**
* Check if current user "owns" the specified connection.
*
* @param \App\OpenVidu\Connection $connection The connection
*
* @return bool
*/
protected function isSelfConnection(Connection $connection): bool
{
return ($conn = $this->getConnectionFromRequest())
&& $conn->id === $connection->id;
}
/**
* Get the connection object for the token in current request headers.
* It will also validate the token.
*
* @return \App\OpenVidu\Connection|null Connection (if exists and the token is valid)
*/
protected function getConnectionFromRequest()
{
// Authenticate the user via the extra request header
if ($token = request()->header(self::AUTH_HEADER)) {
list($connId, ) = explode(':', base64_decode($token), 2);
if (
($connection = Connection::find($connId))
&& $connection->metadata['authToken'] === $token
) {
return $connection;
}
}
return null;
}
}
diff --git a/src/app/OpenVidu/Connection.php b/src/app/OpenVidu/Connection.php
index cccde474..48bcde76 100644
--- a/src/app/OpenVidu/Connection.php
+++ b/src/app/OpenVidu/Connection.php
@@ -1,98 +1,82 @@
'array',
];
- /**
- * Dismiss (close) the connection.
- *
- * @return bool True on success, False on failure
- */
- public function dismiss()
- {
- if ($this->room->closeOVConnection($this->id)) {
- $this->delete();
-
- return true;
- }
-
- return false;
- }
-
/**
* The room to which this connection belongs.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function room()
{
return $this->belongsTo(Room::class, 'room_id', 'id');
}
/**
* Connection role mutator
*
* @throws \Exception
*/
public function setRoleAttribute($role)
{
$new_role = 0;
$allowed_values = [
Room::ROLE_SUBSCRIBER,
Room::ROLE_PUBLISHER,
Room::ROLE_MODERATOR,
Room::ROLE_SCREEN,
Room::ROLE_OWNER,
];
foreach ($allowed_values as $value) {
if ($role & $value) {
$new_role |= $value;
$role ^= $value;
}
}
if ($role > 0) {
throw new \Exception("Invalid connection role: {$role}");
}
// It is either screen sharing connection or publisher/subscriber connection
if ($new_role & Room::ROLE_SCREEN) {
if ($new_role & Room::ROLE_PUBLISHER) {
$new_role ^= Room::ROLE_PUBLISHER;
}
if ($new_role & Room::ROLE_SUBSCRIBER) {
$new_role ^= Room::ROLE_SUBSCRIBER;
}
}
$this->attributes['role'] = $new_role;
}
}
diff --git a/src/resources/js/meet/client.js b/src/resources/js/meet/client.js
index 98d8b5aa..1d9add9a 100644
--- a/src/resources/js/meet/client.js
+++ b/src/resources/js/meet/client.js
@@ -1,616 +1,650 @@
'use strict'
import { Device, parseScalabilityMode } from 'mediasoup-client'
import Config from './config.js'
import { Media } from './media.js'
import { Roles } from './constants.js'
import { Socket } from './socket.js'
function Client()
{
let eventHandlers = {}
let camProducer
let micProducer
let screenProducer
let consumers = {}
let socket
let sendTransport
let recvTransport
let turnServers = []
let nickname = ''
let peers = {}
let videoSource
let audioSource
const VIDEO_CONSTRAINTS = {
'low': {
width: { ideal: 320 }
},
'medium': {
width: { ideal: 640 }
},
'high': {
width: { ideal: 1280 }
},
'veryhigh': {
width: { ideal: 1920 }
},
'ultra': {
width: { ideal: 3840 }
}
}
// Create a device (use browser auto-detection)
const device = new Device()
// A helper for basic browser media operations
const media = new Media()
this.media = media
navigator.mediaDevices.addEventListener('devicechange', () => {
trigger('deviceChange')
})
/**
* Start a session (join a room)
*/
this.joinSession = (token, props) => {
// Initialize the socket, 'roomReady' request handler will do the rest of the job
socket = initSocket(token)
nickname = props.nickname
videoSource = props.videoSource
audioSource = props.audioSource
}
/**
* Close the session (disconnect)
*/
this.closeSession = async (reason) => {
// If room owner, send the request to close the room
if (peers.self && peers.self.role & Roles.OWNER) {
await socket.sendRequest('moderator:closeRoom')
}
- trigger('closeSession', { reason: reason || 'session-closed' })
+ trigger('closeSession', { reason: reason || 'disconnected' })
if (socket) {
socket.close()
}
media.setupStop()
// Close mediasoup Transports.
if (sendTransport) {
sendTransport.close()
sendTransport = null
}
if (recvTransport) {
recvTransport.close()
recvTransport = null
}
// Remove peers' video elements
Object.keys(peers).forEach(id => {
let peer = peers[id]
if (peer.videoElement) {
$(peer.videoElement).remove()
peer.videoElement = null
peer.tracks = null
}
})
// Reset state
eventHandlers = {}
camProducer = null
micProducer = null
screenProducer = null
consumers = {}
peers = {}
}
this.camMute = async () => {
if (camProducer) {
camProducer.pause()
await socket.sendRequest('pauseProducer', { producerId: camProducer.id })
trigger('updatePeer', updatePeerState(peers.self))
}
return this.camStatus()
}
this.camUnmute = async () => {
if (camProducer) {
camProducer.resume()
await socket.sendRequest('resumeProducer', { producerId: camProducer.id })
trigger('updatePeer', updatePeerState(peers.self))
}
return this.camStatus()
}
this.camStatus = () => {
return camProducer && !camProducer.paused && !camProducer.closed
}
this.micMute = async () => {
if (micProducer) {
micProducer.pause()
await socket.sendRequest('pauseProducer', { producerId: micProducer.id })
trigger('updatePeer', updatePeerState(peers.self))
}
return this.micStatus()
}
this.micUnmute = async () => {
if (micProducer) {
micProducer.resume()
await socket.sendRequest('resumeProducer', { producerId: micProducer.id })
trigger('updatePeer', updatePeerState(peers.self))
}
return this.micStatus()
}
this.micStatus = () => {
return micProducer && !micProducer.paused && !micProducer.closed
}
+ this.kickPeer = (peerId) => {
+ socket.sendRequest('moderator:kickPeer', { peerId })
+ }
+
this.chatMessage = (message) => {
socket.sendRequest('chatMessage', { message })
}
+ this.raiseHand = async (status) => {
+ if (peers.self.raisedHand != status) {
+ peers.self.raisedHand = status
+ await socket.sendRequest('raisedHand', { raisedHand: status })
+ trigger('updatePeer', peers.self, ['raisedHand'])
+ }
+
+ return status
+ }
+
this.setNickname = (nickname) => {
- peers.self.nickname = nickname
- socket.sendRequest('changeNickname', { nickname })
- trigger('updatePeer', peers.self, ['nickname'])
+ if (peers.self.nickname != nickname) {
+ peers.self.nickname = nickname
+ socket.sendRequest('changeNickname', { nickname })
+ trigger('updatePeer', peers.self, ['nickname'])
+ }
}
/**
* Register event handlers
*/
this.on = (eventName, callback) => {
eventHandlers[eventName] = callback
}
/**
* Execute an event handler
*/
const trigger = (...args) => {
const eventName = args.shift()
if (eventName in eventHandlers) {
eventHandlers[eventName].apply(null, args)
}
}
const initSocket = (token) => {
// Connect to websocket
socket = new Socket(token)
socket.on('disconnect', reason => {
// this.closeSession()
})
socket.on('reconnectFailed', () => {
// this.closeSession()
})
socket.on('request', async (request, cb) => {
switch (request.method) {
case 'newConsumer':
const {
peerId,
producerId,
id,
kind,
rtpParameters,
type,
appData,
producerPaused
} = request.data
const consumer = await recvTransport.consume({
id,
producerId,
kind,
rtpParameters
})
consumer.peerId = peerId
consumer.on('transportclose', () => {
// TODO: What actually else needs to be done here?
delete consumers[consumer.id]
})
consumers[consumer.id] = consumer
// We are ready. Answer the request so the server will
// resume this Consumer (which was paused for now).
cb(null)
let peer = peers[peerId]
if (!peer) {
return
}
let tracks = (peer.tracks || []).filter(track => track.kind != kind)
tracks.push(consumer.track)
setPeerTracks(peer, tracks)
trigger('updatePeer', peer)
break
default:
console.error('Unknow request method: ' + request.method)
}
})
socket.on('notification', (notification) => {
switch (notification.method) {
case 'roomReady':
turnServers = notification.data.turnServers
joinRoom()
return
case 'newPeer':
peers[notification.data.id] = notification.data
trigger('addPeer', notification.data)
return
case 'peerClosed':
const { peerId } = notification.data
delete peers[peerId]
trigger('removePeer', peerId)
return
case 'consumerClosed': {
const { consumerId } = notification.data
const consumer = consumers[consumerId]
if (!consumer) {
return
}
consumer.close()
delete consumers[consumerId]
let peer = peers[consumer.peerId]
if (peer) {
// TODO: Update peer state, remove track
trigger('updatePeer', peer)
}
return
}
case 'consumerPaused':
case 'consumerResumed': {
const { consumerId } = notification.data
const consumer = consumers[consumerId]
if (!consumer) {
return
}
consumer[notification.method == 'consumerPaused' ? 'pause' : 'resume']()
let peer = peers[consumer.peerId]
if (peer) {
trigger('updatePeer', updatePeerState(peer))
}
return
}
case 'changeNickname': {
const { peerId, nickname } = notification.data
const peer = peers[peerId]
if (!peer) {
return
}
peer.nickname = nickname
trigger('updatePeer', peer, ['nickname'])
-
return
}
case 'chatMessage': {
trigger('chatMessage', notification.data)
return
}
case 'moderator:closeRoom': {
this.closeSession('session-closed')
return
}
+ case 'moderator:kickPeer': {
+ this.closeSession('session-closed')
+ return
+ }
+
+ case 'raisedHand': {
+ const { peerId, raisedHand } = notification.data
+ const peer = peers[peerId]
+
+ if (!peer) {
+ return
+ }
+
+ peer.raisedHand = raisedHand
+
+ trigger('updatePeer', peer, ['raisedHand'])
+ return
+ }
+
default:
console.error('Unknow notification method: ' + notification.method)
return
}
})
return socket
}
const joinRoom = async () => {
const routerRtpCapabilities = await socket.getRtpCapabilities()
routerRtpCapabilities.headerExtensions = routerRtpCapabilities.headerExtensions
.filter(ext => ext.uri !== 'urn:3gpp:video-orientation')
await device.load({ routerRtpCapabilities })
const iceTransportPolicy = (device.handlerName.toLowerCase().includes('firefox') && turnServers) ? 'relay' : undefined;
// Setup 'producer' transport
if (videoSource || audioSource) {
const transportInfo = await socket.sendRequest('createWebRtcTransport', {
forceTcp: false,
producing: true,
consuming: false
})
const { id, iceParameters, iceCandidates, dtlsParameters } = transportInfo
sendTransport = device.createSendTransport({
id,
iceParameters,
iceCandidates,
dtlsParameters,
iceServers: turnServers,
iceTransportPolicy: iceTransportPolicy,
proprietaryConstraints: { optional: [{ googDscp: true }] }
})
sendTransport.on('connect', ({ dtlsParameters }, callback, errback) => {
socket.sendRequest('connectWebRtcTransport',
{ transportId: sendTransport.id, dtlsParameters })
.then(callback)
.catch(errback)
})
sendTransport.on('produce', async ({ kind, rtpParameters, appData }, callback, errback) => {
try {
const { id } = await socket.sendRequest('produce', {
transportId: sendTransport.id,
kind,
rtpParameters,
appData
})
callback({ id })
} catch (error) {
errback(error)
}
})
}
// Setup 'consumer' transport
const transportInfo = await socket.sendRequest('createWebRtcTransport', {
forceTcp: false,
producing: false,
consuming: true
})
const { id, iceParameters, iceCandidates, dtlsParameters } = transportInfo
recvTransport = device.createRecvTransport({
id,
iceParameters,
iceCandidates,
dtlsParameters,
iceServers: turnServers,
iceTransportPolicy: iceTransportPolicy
})
recvTransport.on('connect', ({ dtlsParameters }, callback, errback) => {
socket.sendRequest('connectWebRtcTransport', { transportId: recvTransport.id, dtlsParameters })
.then(callback)
.catch(errback)
})
// Send the "join" request, get room data, participants, etc.
const { peers: existing, role, id: peerId } = await socket.sendRequest('join', {
nickname: nickname,
rtpCapabilities: device.rtpCapabilities
})
trigger('joinSuccess')
let peer = {
id: peerId,
role,
isSelf: true,
nickname,
audioActive: !!audioSource,
videoActive: !!videoSource
}
// Start publishing webcam
if (videoSource) {
await setCamera(videoSource)
// Create the video element
peer.videoElement = media.createVideoElement([ camProducer.track ], { mirror: true })
}
// Start publishing microphone
if (audioSource) {
setMic(audioSource)
// Note: We're not adding this track to the video element
}
trigger('addPeer', peer)
// Add self to the list
peers.self = peer
console.log(existing)
// Trigger addPeer event for all peers already in the room, maintain peers list
existing.forEach(peer => {
let tracks = []
// We receive newConsumer requests before we add the peer to peers list,
// therefore we look here for any consumers that belong to this peer and update
// the peer. If we do not do this we have to wait about 20 seconds for repeated
// newConsumer requests
Object.keys(consumers).forEach(cid => {
if (consumers[cid].peerId === peer.id) {
tracks.push(consumers[cid].track)
}
})
if (tracks.length) {
setPeerTracks(peer, tracks)
}
trigger('addPeer', peer)
peers[peer.id] = peer
})
}
const setCamera = async (deviceId) => {
if (!device.canProduce('video')) {
throw new Error('cannot produce video')
}
const { aspectRatio, frameRate, resolution } = Config.videoOptions
const track = await media.getTrack({
video: {
deviceId: { ideal: deviceId },
...VIDEO_CONSTRAINTS[resolution],
frameRate
}
})
// TODO: Simulcast support?
camProducer = await sendTransport.produce({
track,
appData: {
source : 'webcam'
}
})
/*
camProducer.on('transportclose', () => {
camProducer = null
})
camProducer.on('trackended', () => {
// disableWebcam()
})
*/
}
const setMic = async (deviceId) => {
if (!device.canProduce('audio')) {
throw new Error('cannot produce audio')
}
const {
autoGainControl,
echoCancellation,
noiseSuppression,
sampleRate,
channelCount,
volume,
sampleSize,
opusStereo,
opusDtx,
opusFec,
opusPtime,
opusMaxPlaybackRate
} = Config.audioOptions
const track = await media.getTrack({
audio: {
sampleRate,
channelCount,
volume,
autoGainControl,
echoCancellation,
noiseSuppression,
sampleSize,
deviceId: { ideal: deviceId }
}
})
micProducer = await sendTransport.produce({
track,
codecOptions: {
opusStereo,
opusDtx,
opusFec,
opusPtime,
opusMaxPlaybackRate
},
appData: {
source : 'mic'
}
})
/*
micProducer.on('transportclose', () => {
micProducer = null
})
micProducer.on('trackended', () => {
// disableMic()
})
*/
}
const setPeerTracks = (peer, tracks) => {
if (!peer.videoElement) {
peer.videoElement = media.createVideoElement(tracks, {})
} else {
const stream = new MediaStream()
tracks.forEach(track => stream.addTrack(track))
peer.videoElement.srcObject = stream
}
updatePeerState(peer)
peer.tracks = tracks
}
const updatePeerState = (peer) => {
if (peer.isSelf) {
peer.videoActive = this.camStatus()
peer.audioActive = this.micStatus()
} else {
peer.videoActive = false
peer.audioActive = false
Object.keys(consumers).forEach(cid => {
const consumer = consumers[cid]
if (consumer.peerId == peer.id) {
peer[consumer.kind + 'Active'] = !consumer.paused && !consumer.closed && !consumer.producerPaused
}
})
}
return peer
}
}
export { Client }
diff --git a/src/resources/js/meet/room.js b/src/resources/js/meet/room.js
index 12ba91be..2febf73b 100644
--- a/src/resources/js/meet/room.js
+++ b/src/resources/js/meet/room.js
@@ -1,1294 +1,1276 @@
'use strict'
import anchorme from 'anchorme'
import { Client } from './client.js'
import { Roles } from './constants.js'
import { Dropdown } from 'bootstrap'
import { library } from '@fortawesome/fontawesome-svg-core'
function Room(container)
{
let session // Session object where the user will connect
let sessionData // Room session metadata
/*
let publisher // Publisher object which the user will publish
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 connections = {} // Connected users in the session
let peers = {}
let chatCount = 0
let publishersContainer
let subscribersContainer
let scrollStop
let $t
const client = new Client()
// 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.raiseHand = raiseHand
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
/**
* Join the room session
*
* @param data Session metadata and event handlers:
* token - A token for the main connection,
* shareToken - A 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();
$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',
+ let events = ['Success', 'Error', 'Destroy', 'JoinRequest', 'ConnectionChange',
'SessionDataUpdate', 'MediaSetup']
events.map(event => 'on' + event).forEach(event => {
if (!data[event]) {
data[event] = () => {}
}
})
sessionData = data
// Participant added (including self)
client.on('addPeer', (event) => {
console.log('addPeer', event)
event.element = participantCreate(event)
if (event.videoElement) {
$(event.element).prepend(event.videoElement)
}
peers[event.id] = event
})
// Participant removed
client.on('removePeer', (peerId) => {
console.log('removePeer', peerId)
let peer = peers[peerId]
if (peer) {
// Remove elements related to the participant
- connectionHandDown(peerId)
+ peerHandDown(peer)
$(peer.element).remove()
delete peers[peerId]
}
resize()
})
// Participant properties changed e.g. audio/video muted/unmuted
client.on('updatePeer', (event, changed) => {
console.log('updatePeer', event)
let peer = peers[event.id]
if (!peer) {
return
}
event.element = peer.element
if (event.videoElement && event.videoElement.parentNode != event.element) {
$(event.element).prepend(event.videoElement)
} else if (!event.videoElement) {
$(event.element).find('video').remove()
}
if (changed && changed.includes('nickname')) {
nicknameUpdate(event.nickname, event.id)
}
+ if (changed && changed.includes('raisedHand')) {
+ if (event.raisedHand) {
+ peerHandUp(event)
+ } else {
+ peerHandDown(event)
+ }
+ }
+
participantUpdate(event.element, event)
peers[event.id] = event
})
client.on('joinSuccess', () => {
data.onSuccess()
})
// Handle session disconnection events
client.on('closeSession', event => {
// Notify the UI
data.onDestroy(event)
// Remove all participant elements
Object.keys(peers).forEach(peerId => {
$(peers[peerId].element).remove()
delete peers[peerId]
})
// refresh the matrix
resize()
})
const { audioSource, videoSource } = client.media.setupData()
// Start the session
client.joinSession(data.token, { videoSource, audioSource, nickname: data.nickname })
// Prepare the chat
- setupChat()
+ initChat()
}
/**
* Leave the room (disconnect)
*/
function leaveRoom() {
client.closeSession()
peers = {}
}
+ /**
+ * Raise or lower the hand
+ *
+ * @param status Hand raised or not
+ */
+ async function raiseHand(status) {
+ return await client.raiseHand(status)
+ }
+
/**
* 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) {
client.media.setupStart(props)
}
/**
* Stop the setup "process", cleanup after it.
*/
function setupStop() {
client.media.setupStop()
}
/**
* Change the publisher audio device
*
* @param deviceId Device identifier string
*/
async function setupSetAudioDevice(deviceId) {
return await client.media.setupSetAudio(deviceId)
}
/**
* Change the publisher video device
*
* @param deviceId Device identifier string
*/
async function setupSetVideoDevice(deviceId) {
return await client.media.setupSetVideo(deviceId)
}
/**
* Setup the chat UI
*/
- function setupChat() {
+ function initChat() {
// Handle arriving chat messages
client.on('chatMessage', pushChatMessage)
// 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) {
client.chatMessage(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
})
}
/*
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: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
}
}
*/
/**
* 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 = false // TODO
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.peerId) {
// A message from the same user as the last message, no new box needed
message.appendTo(box)
} else {
box = $('
').data('id', data.peerId)
.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
}
}
/**
* 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
*/
async function switchAudio() {
const isActive = client.micStatus()
if (isActive) {
return await client.micMute()
} else {
return await client.micUnmute()
}
}
/**
* Mute/Unmute video for current session publisher
*/
async function switchVideo() {
const isActive = client.camStatus()
if (isActive) {
return await client.camMute()
} else {
return await client.camUnmute()
}
}
/**
* 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 false // TODO !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia)
}
/**
* 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))
+ function peerHandUp(peer) {
+ let element = $(nicknameWidget(peer))
- participantUpdate(element, connection)
+ participantUpdate(element, peer)
- element.attr('id', 'qa' + connection.connectionId)
+ element.attr('id', 'qa' + peer.id)
.appendTo($(sessionData.queueElement).show())
setTimeout(() => element.addClass('widdle'), 50)
}
/**
* Handler for Hand-Down "signal"
*/
- function connectionHandDown(connectionId) {
+ function peerHandDown(peer) {
let list = $(sessionData.queueElement)
- list.find('#qa' + connectionId).remove();
+ list.find('#qa' + peer.id).remove();
if (!list.find('.meet-nickname').length) {
list.hide();
}
}
/**
* Update participant nickname in the UI
*
* @param nickname Nickname
* @param peerId Connection identifier of the user
*/
function nicknameUpdate(nickname, peerId) {
if (peerId) {
$(sessionData.chatElement).find('.chat').find('.message').each(function() {
let elem = $(this)
if (elem.data('id') == peerId) {
elem.find('.nickname').text(nickname || '')
}
})
$(sessionData.queueElement).find('#qa' + peerId + ' .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
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
element wrapper with controls
*
* @param params Connection metadata/params
* @param content Optional content to prepend to the element
*/
function publisherCreate(params, content) {
let isScreen = params.role & Roles.SCREEN
// Create the element
let wrapper = $(
''
+ svgIcon('user', 'fas', 'watermark')
+ '
'
// TODO + '
' + svgIcon('cog') + ' '
+ '
'
+ '
' + svgIcon('volume-mute') + ' '
+ '
' + svgIcon('expand') + ' '
+ '
' + svgIcon('compress') + ' '
+ '
'
+ '
'
+ '' + svgIcon('microphone-slash') + ' '
+ '' + svgIcon('video-slash') + ' '
+ '
'
+ '
'
)
// Append the nickname widget
wrapper.find('.controls').before(nicknameWidget(params))
if (content) {
wrapper.prepend(content)
}
if (isScreen) {
wrapper.addClass('screen')
}
if (params.isSelf) {
wrapper.find('.link-setup').removeClass('hidden').on('click', () => sessionData.onMediaSetup())
} else {
let volumeInput = wrapper.find('.volume input')
let audioButton = wrapper.find('.link-audio')
let inVolume = false
let hideVolumeTimeout
let hideVolume = () => {
if (inVolume) {
hideVolumeTimeout = setTimeout(hideVolume, 1000)
} else {
volumeInput.parent().addClass('hidden')
}
}
// Enable and set up the audio mute button
audioButton.removeClass('hidden')
.on('click', e => {
let video = wrapper.find('video')[0]
video.muted = !video.muted
video.volume = video.muted ? 0 : 1
audioButton[video.muted ? 'addClass' : 'removeClass']('text-danger')
volumeInput.val(video.volume)
})
// Show the volume slider when mouse is over the audio mute/unmute button
.on('mouseenter', () => {
let video = wrapper.find('video')[0]
clearTimeout(hideVolumeTimeout)
volumeInput.parent().removeClass('hidden')
volumeInput.val(video.volume)
})
.on('mouseleave', () => {
hideVolumeTimeout = setTimeout(hideVolume, 1000)
})
// Set up the audio volume control
volumeInput
.on('mouseenter', () => { inVolume = true })
.on('mouseleave', () => { inVolume = false })
.on('change input', () => {
let video = wrapper.find('video')[0]
let volume = volumeInput.val()
video.volume = volume
video.muted = volume == 0
audioButton[video.muted ? 'addClass' : 'removeClass']('text-danger')
})
}
participantUpdate(wrapper, params, true)
// Fullscreen control
if (document.fullscreenEnabled) {
wrapper.find('.link-fullscreen.closed').removeClass('hidden')
.on('click', () => {
wrapper.get(0).requestFullscreen()
})
wrapper.find('.link-fullscreen.open')
.on('click', () => {
document.exitFullscreen()
})
wrapper.on('fullscreenchange', () => {
// const enabled = document.fullscreenElement
wrapper.find('.link-fullscreen').toggleClass('hidden')
})
}
// Remove the subscriber element, if exists
$('#subscriber-' + params.id).remove()
let prio = params.isSelf || (isScreen && !$(publishersContainer).children('.screen').length)
return wrapper[prio ? 'prependTo' : 'appendTo'](publishersContainer)
.attr('id', 'publisher-' + params.id)
.get(0)
}
/**
* Update the publisher/subscriber element controls
*
* @param wrapper The wrapper element
* @param params Connection metadata/params
*/
function participantUpdate(wrapper, params, noupdate) {
const element = $(wrapper)
const isModerator = sessionData.role & Roles.MODERATOR
const isSelf = params.isSelf
const rolePublisher = params.role & Roles.PUBLISHER
const roleModerator = params.role & Roles.MODERATOR
const roleScreen = params.role & Roles.SCREEN
const roleOwner = params.role & Roles.OWNER
const roleInterpreter = rolePublisher && !!params.language
if (!noupdate && !roleScreen) {
const isPublisher = element.is('.meet-video')
// Publisher-to-interpreter or vice-versa, move element to the subscribers list or vice-versa,
// but keep the existing video element
if (
!isSelf
&& element.find('video').length
&& ((roleInterpreter && isPublisher) || (!roleInterpreter && !isPublisher && rolePublisher))
) {
wrapper = participantCreate(params, element.find('video'))
element.remove()
return wrapper
}
// Handle publisher-to-subscriber and subscriber-to-publisher change
if (
!roleInterpreter
&& (rolePublisher && !isPublisher) || (!rolePublisher && isPublisher)
) {
element.remove()
return participantCreate(params)
}
}
let muted = false
let video = element.find('video')[0]
// When a channel is selected - mute everyone except the interpreter of the language.
// When a channel is not selected - mute language interpreters only
if (sessionData.channel) {
muted = !(roleInterpreter && params.language == sessionData.channel)
} else {
muted = roleInterpreter
}
if (muted && !isSelf) {
element.find('.status-audio').removeClass('hidden')
element.find('.link-audio').addClass('hidden')
} else {
element.find('.status-audio')[params.audioActive ? 'addClass' : 'removeClass']('hidden')
if (!isSelf) {
element.find('.link-audio').removeClass('hidden')
}
muted = !params.audioActive || isSelf
}
element.find('.status-video')[params.videoActive ? 'addClass' : 'removeClass']('hidden')
if (video) {
video.muted = muted
}
if ('nickname' in params) {
element.find('.meet-nickname > .content').text(params.nickname)
}
if (isSelf) {
element.addClass('self')
}
if (isModerator) {
element.addClass('moderated')
}
const withPerm = isModerator && !roleScreen && !(roleOwner && !isSelf);
const withMenu = isSelf || (isModerator && !roleOwner)
// TODO: This probably could be better done with css
let elements = {
'.dropdown-menu': withMenu,
'.permissions': withPerm,
'.interpreting': withPerm && rolePublisher,
'svg.moderator': roleModerator,
'svg.user': !roleModerator && !roleInterpreter,
'svg.interpreter': !roleModerator && roleInterpreter
}
Object.keys(elements).forEach(key => {
element.find(key)[elements[key] ? 'removeClass' : 'addClass']('hidden')
})
element.find('.action-role-publisher input').prop('checked', rolePublisher)
element.find('.action-role-moderator input').prop('checked', roleModerator)
.prop('disabled', roleOwner)
element.find('.interpreting select').val(roleInterpreter ? params.language : '')
return wrapper
}
/**
* Update/refresh state of all participants' elements
*/
function participantUpdateAll() {
Object.keys(connections).forEach(key => {
const conn = connections[key]
participantUpdate(conn.element, conn)
})
}
/**
* Create a tag-like element for a subscriber participant
*
* @param params Connection metadata/params
* @param content Optional content to prepend to the element
*/
function subscriberCreate(params, content) {
// Create the element
let wrapper = $('').append(nicknameWidget(params))
if (content) {
wrapper.prepend(content)
}
participantUpdate(wrapper, params, true)
return wrapper[params.isSelf ? 'prependTo' : 'appendTo'](subscribersContainer)
.attr('id', 'subscriber-' + params.id)
.get(0)
}
/**
* Create a tag-like nickname widget
*
* @param object params Connection metadata/params
*/
function nicknameWidget(params) {
let languages = []
// Append languages selection options
Object.keys(sessionData.languages).forEach(code => {
languages.push(`
${$t(sessionData.languages[code])} `)
})
// Create the element
let element = $(
'
'
)
let nickname = element.find('.meet-nickname')
.addClass('btn btn-outline-' + (params.isSelf ? 'primary' : 'secondary'))
.attr({title: $t('meet.menu-options'), 'data-bs-toggle': 'dropdown'})
- const dropdown = new Dropdown(nickname[0], {boundary: container.parentNode})
+ const dropdown = new Dropdown(nickname[0], { boundary: container.parentNode })
if (params.isSelf) {
// Add events for nickname change
let editable = element.find('.content')[0]
let editableEnable = () => {
editable.contentEditable = true
editable.focus()
}
let editableUpdate = () => {
// Skip redundant update on blur, if it was already updated
if (editable.contentEditable !== 'false') {
editable.contentEditable = false
client.setNickname(editable.innerText)
}
}
element.find('.action-nickname').on('click', editableEnable)
element.find('.action-dismiss').remove()
$(editable).on('blur', editableUpdate)
.on('keydown', e => {
// Enter or Esc
if (e.keyCode == 13 || e.keyCode == 27) {
editableUpdate()
return false
}
// Do not propagate the event, so it does not interfere with our
// keyboard shortcuts
e.stopPropagation()
})
} else {
element.find('.action-nickname').remove()
- element.find('.action-dismiss').on('click', e => {
- sessionData.onDismiss(params.id)
+ element.find('.action-dismiss').on('click', () => {
+ client.kickPeer(params.id)
})
}
let connectionRole = () => {
if (params.isSelf) {
return sessionData.role
}
if (params.id in connections) {
return connections[params.peerId].role
}
return 0
}
// Don't close the menu on permission change
element.find('.dropdown-menu > label').on('click', e => { e.stopPropagation() })
element.find('.action-role-publisher input').on('change', e => {
const enabled = e.target.checked
let role = connectionRole()
if (enabled) {
role |= Roles.PUBLISHER
} else {
role |= Roles.SUBSCRIBER
if (role & Roles.PUBLISHER) {
role ^= Roles.PUBLISHER
}
}
sessionData.onConnectionChange(params.id, { role })
})
element.find('.action-role-moderator input').on('change', e => {
const enabled = e.target.checked
let role = connectionRole()
if (enabled) {
role |= Roles.MODERATOR
} else if (role & Roles.MODERATOR) {
role ^= Roles.MODERATOR
}
sessionData.onConnectionChange(params.id, { role })
})
element.find('.interpreting select')
.on('change', e => {
const language = $(e.target).val()
sessionData.onConnectionChange(params.id, { language })
dropdown.hide()
})
.on('click', e => {
// Prevents from closing the dropdown menu on click
e.stopPropagation()
})
return element.get(0)
}
/**
* Window onresize event handler (updates room layout)
*/
function resize() {
if (publishersContainer) {
updateLayout()
}
$(container).parent()[window.screen.width <= 768 ? 'addClass' : 'removeClass']('mobile')
}
/**
* Update the room "matrix" layout
*/
function updateLayout() {
let publishers = $(publishersContainer).find('.meet-video')
let numOfVideos = publishers.length
if (sessionData && sessionData.counterElement) {
- sessionData.counterElement.innerHTML = Object.keys(connections).length + 1
+ sessionData.counterElement.innerHTML = Object.keys(peers).length
}
if (!numOfVideos) {
subscribersContainer.style.minHeight = 'auto'
return
}
// Note: offsetHeight/offsetWidth return rounded values, but for proper matrix
// calculations we need more precision, therefore we use getBoundingClientRect()
let allHeight = container.offsetHeight
let scrollHeight = subscribersContainer.scrollHeight
let bcr = publishersContainer.getBoundingClientRect()
let containerWidth = bcr.width
let containerHeight = bcr.height
let limit = Math.ceil(allHeight * 0.25) // max subscribers list height
// Fix subscribers list height
if (subscribersContainer.offsetHeight <= scrollHeight) {
limit = Math.min(scrollHeight, limit)
subscribersContainer.style.minHeight = limit + 'px'
containerHeight = allHeight - limit
} else {
subscribersContainer.style.minHeight = 'auto'
}
let css, rows, cols, height, padding = 0
// Make the first screen sharing tile big
let screenVideo = publishers.filter('.screen').find('video').get(0)
if (screenVideo) {
let element = screenVideo.parentNode
let connId = element.id.replace(/^publisher-/, '')
/*
let connection = connections[connId]
// We know the shared screen video dimensions, we can calculate
// width/height of the tile in the matrix
if (connection && connection.videoDimensions) {
let screenWidth = connection.videoDimensions.width
let screenHeight = containerHeight
// TODO: When the shared window is minimized the width/height is set to 1 (or 2)
// - at least on my system. We might need to handle this case nicer. Right now
// it create a 1-2px line on the left of the matrix - not a big issue.
// TODO: Make the 0.666 factor bigger for wide screen and small number of participants?
let maxWidth = Math.ceil(containerWidth * 0.666)
if (screenWidth > maxWidth) {
screenWidth = maxWidth
}
// Set the tile position and size
$(element).css({
width: screenWidth + 'px',
height: screenHeight + 'px',
position: 'absolute',
top: 0,
left: 0
})
padding = screenWidth + 'px'
// Now the estate for the rest of participants is what's left on the right side
containerWidth -= screenWidth
publishers = publishers.not(element)
numOfVideos -= 1
}
*/
}
// Compensate the shared screen estate with a padding
$(publishersContainer).css('padding-left', padding)
const factor = containerWidth / containerHeight
if (factor >= 16/9) {
if (numOfVideos <= 3) {
rows = 1
} else if (numOfVideos <= 8) {
rows = 2
} else if (numOfVideos <= 15) {
rows = 3
} else if (numOfVideos <= 20) {
rows = 4
} else {
rows = 5
}
cols = Math.ceil(numOfVideos / rows)
} else {
if (numOfVideos == 1) {
cols = 1
} else if (numOfVideos <= 4) {
cols = 2
} else if (numOfVideos <= 9) {
cols = 3
} else if (numOfVideos <= 16) {
cols = 4
} else if (numOfVideos <= 25) {
cols = 5
} else {
cols = 6
}
rows = Math.ceil(numOfVideos / cols)
if (rows < cols && containerWidth < containerHeight) {
cols = rows
rows = Math.ceil(numOfVideos / cols)
}
}
// console.log('factor=' + factor, 'num=' + numOfVideos, 'cols = '+cols, 'rows=' + rows);
// Update all tiles (except the main shared screen) in the matrix
publishers.css({
width: (containerWidth / cols) + 'px',
// Height must be in pixels to make object-fit:cover working
height: (containerHeight / rows) + 'px'
})
}
/**
* Initialize screen sharing session/publisher
*/
function screenConnect(callback) {
if (!sessionData.shareToken) {
return false
}
let gotSession = !!screenSession
if (!screenOV) {
screenOV = ovInit()
}
// Init screen sharing session
if (!gotSession) {
screenSession = screenOV.initSession();
}
let successFunc = function() {
screenSession.publish(screenPublisher)
screenSession.on('sessionDisconnected', event => {
callback(false)
screenSession = null
screenPublisher = null
})
if (callback) {
callback(true)
}
}
let errorFunc = function() {
screenPublisher = null
if (callback) {
callback(false, true)
}
}
// Init the publisher
let params = {
videoSource: 'screen',
publishAudio: false
}
screenPublisher = screenOV.initPublisher(null, params)
screenPublisher.once('accessAllowed', (event) => {
if (gotSession) {
successFunc()
} else {
screenSession.connect(sessionData.shareToken, sessionData.params)
.then(() => {
successFunc()
})
.catch(error => {
console.error('There was an error connecting to the session:', error.code, error.message);
errorFunc()
})
}
})
screenPublisher.once('accessDenied', () => {
console.info('ScreenShare: Access Denied')
errorFunc()
})
}
/**
* Create an svg element (string) for a FontAwesome icon
*
* @todo Find if there's a "official" way to do this
*/
function svgIcon(name, type, className) {
// Note: the library will contain definitions for all icons registered elswhere
const icon = library.definitions[type || 'fas'][name]
let attrs = {
'class': 'svg-inline--fa',
'aria-hidden': true,
focusable: false,
role: 'img',
xmlns: 'http://www.w3.org/2000/svg',
viewBox: `0 0 ${icon[0]} ${icon[1]}`
}
if (className) {
attrs['class'] += ' ' + className
}
return $(`
`)
.attr(attrs)
.get(0).outerHTML
}
/**
* A way to update some session data, after you joined the room
*
* @param data Same input as for joinRoom()
*/
function updateSession(data) {
sessionData.shareToken = data.shareToken
}
/**
* Get all existing language interpretation channels
*/
function getChannels(connections) {
let channels = []
Object.keys(connections || {}).forEach(key => {
let conn = connections[key]
if (
conn.language
&& !channels.includes(conn.language)
) {
channels.push(conn.language)
}
})
return channels
}
}
export { Room }
diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue
index 7e5f16cd..21fcba62 100644
--- a/src/resources/vue/Meet/Room.vue
+++ b/src/resources/vue/Meet/Room.vue
@@ -1,749 +1,731 @@
{{ $t('meet.setup-title') }}
{{ $t('meet.leave-body') }}
diff --git a/src/routes/api.php b/src/routes/api.php
index 5cc43e20..6c04bbd1 100644
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -1,247 +1,245 @@
'api',
'prefix' => $prefix . 'api/auth'
],
function ($router) {
Route::post('login', 'API\AuthController@login');
Route::group(
['middleware' => 'auth:api'],
function ($router) {
Route::get('info', 'API\AuthController@info');
Route::post('info', 'API\AuthController@info');
Route::post('logout', 'API\AuthController@logout');
Route::post('refresh', 'API\AuthController@refresh');
}
);
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'middleware' => 'api',
'prefix' => $prefix . 'api/auth'
],
function ($router) {
Route::post('password-reset/init', 'API\PasswordResetController@init');
Route::post('password-reset/verify', 'API\PasswordResetController@verify');
Route::post('password-reset', 'API\PasswordResetController@reset');
Route::post('signup/init', 'API\SignupController@init');
Route::get('signup/invitations/{id}', 'API\SignupController@invitation');
Route::get('signup/plans', 'API\SignupController@plans');
Route::post('signup/verify', 'API\SignupController@verify');
Route::post('signup', 'API\SignupController@signup');
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'middleware' => 'auth:api',
'prefix' => $prefix . 'api/v4'
],
function () {
Route::post('companion/register', 'API\V4\CompanionAppsController@register');
Route::post('auth-attempts/{id}/confirm', 'API\V4\AuthAttemptsController@confirm');
Route::post('auth-attempts/{id}/deny', 'API\V4\AuthAttemptsController@deny');
Route::get('auth-attempts/{id}/details', 'API\V4\AuthAttemptsController@details');
Route::get('auth-attempts', 'API\V4\AuthAttemptsController@index');
Route::apiResource('domains', API\V4\DomainsController::class);
Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm');
Route::get('domains/{id}/status', 'API\V4\DomainsController@status');
Route::post('domains/{id}/config', 'API\V4\DomainsController@setConfig');
Route::apiResource('groups', API\V4\GroupsController::class);
Route::get('groups/{id}/status', 'API\V4\GroupsController@status');
Route::apiResource('packages', API\V4\PackagesController::class);
Route::apiResource('skus', API\V4\SkusController::class);
Route::apiResource('users', API\V4\UsersController::class);
Route::post('users/{id}/config', 'API\V4\UsersController@setConfig');
Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus');
Route::get('users/{id}/status', 'API\V4\UsersController@status');
Route::apiResource('wallets', API\V4\WalletsController::class);
Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions');
Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts');
Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload');
Route::post('payments', 'API\V4\PaymentsController@store');
//Route::delete('payments', 'API\V4\PaymentsController@cancel');
Route::get('payments/mandate', 'API\V4\PaymentsController@mandate');
Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate');
Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate');
Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete');
Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods');
Route::get('payments/pending', 'API\V4\PaymentsController@payments');
Route::get('payments/has-pending', 'API\V4\PaymentsController@hasPayments');
Route::get('openvidu/rooms', 'API\V4\OpenViduController@index');
Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom');
Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig');
// FIXME: I'm not sure about this one, should we use DELETE request maybe?
Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection');
Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection');
Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest');
Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest');
}
);
// Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group
Route::group(
[
'domain' => \config('app.website_domain'),
'prefix' => $prefix . 'api/v4'
],
function () {
Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom');
Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection');
- // FIXME: I'm not sure about this one, should we use DELETE request maybe?
- Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection');
Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection');
Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest');
Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest');
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'middleware' => 'api',
'prefix' => $prefix . 'api/v4'
],
function ($router) {
Route::post('support/request', 'API\V4\SupportController@request');
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'prefix' => $prefix . 'api/webhooks'
],
function () {
Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook');
Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook');
Route::get('nginx', 'API\NGINXController@authenticate');
}
);
if (\config('app.with_services')) {
Route::group(
[
'domain' => 'services.' . \config('app.website_domain'),
'prefix' => $prefix . 'api/webhooks'
],
function () {
Route::get('nginx', 'API\V4\NGINXController@authenticate');
Route::post('policy/greylist', 'API\V4\PolicyController@greylist');
Route::post('policy/ratelimit', 'API\V4\PolicyController@ratelimit');
Route::post('policy/spf', 'API\V4\PolicyController@senderPolicyFramework');
}
);
}
if (\config('app.with_admin')) {
Route::group(
[
'domain' => 'admin.' . \config('app.website_domain'),
'middleware' => ['auth:api', 'admin'],
'prefix' => $prefix . 'api/v4',
],
function () {
Route::apiResource('domains', API\V4\Admin\DomainsController::class);
Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend');
Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend');
Route::apiResource('groups', API\V4\Admin\GroupsController::class);
Route::post('groups/{id}/suspend', 'API\V4\Admin\GroupsController@suspend');
Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend');
Route::apiResource('skus', API\V4\Admin\SkusController::class);
Route::apiResource('users', API\V4\Admin\UsersController::class);
Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts');
Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA');
Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus');
Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend');
Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend');
Route::apiResource('wallets', API\V4\Admin\WalletsController::class);
Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff');
Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions');
Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart');
}
);
}
if (\config('app.with_reseller')) {
Route::group(
[
'domain' => 'reseller.' . \config('app.website_domain'),
'middleware' => ['auth:api', 'reseller'],
'prefix' => $prefix . 'api/v4',
],
function () {
Route::apiResource('domains', API\V4\Reseller\DomainsController::class);
Route::post('domains/{id}/suspend', 'API\V4\Reseller\DomainsController@suspend');
Route::post('domains/{id}/unsuspend', 'API\V4\Reseller\DomainsController@unsuspend');
Route::apiResource('groups', API\V4\Reseller\GroupsController::class);
Route::post('groups/{id}/suspend', 'API\V4\Reseller\GroupsController@suspend');
Route::post('groups/{id}/unsuspend', 'API\V4\Reseller\GroupsController@unsuspend');
Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class);
Route::post('invitations/{id}/resend', 'API\V4\Reseller\InvitationsController@resend');
Route::post('payments', 'API\V4\Reseller\PaymentsController@store');
Route::get('payments/mandate', 'API\V4\Reseller\PaymentsController@mandate');
Route::post('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateCreate');
Route::put('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateUpdate');
Route::delete('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateDelete');
Route::get('payments/methods', 'API\V4\Reseller\PaymentsController@paymentMethods');
Route::get('payments/pending', 'API\V4\Reseller\PaymentsController@payments');
Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments');
Route::apiResource('skus', API\V4\Reseller\SkusController::class);
Route::apiResource('users', API\V4\Reseller\UsersController::class);
Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts');
Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA');
Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus');
Route::post('users/{id}/suspend', 'API\V4\Reseller\UsersController@suspend');
Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend');
Route::apiResource('wallets', API\V4\Reseller\WalletsController::class);
Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff');
Route::get('wallets/{id}/receipts', 'API\V4\Reseller\WalletsController@receipts');
Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\Reseller\WalletsController@receiptDownload');
Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions');
Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart');
}
);
}
diff --git a/src/tests/Feature/Controller/OpenViduTest.php b/src/tests/Feature/Controller/OpenViduTest.php
index 90be911e..fa9f63a8 100644
--- a/src/tests/Feature/Controller/OpenViduTest.php
+++ b/src/tests/Feature/Controller/OpenViduTest.php
@@ -1,732 +1,653 @@
clearMeetEntitlements();
$room = Room::where('name', 'john')->first();
$room->setSettings(['password' => null, 'locked' => null, 'nomedia' => null]);
}
public function tearDown(): void
{
$this->clearMeetEntitlements();
$room = Room::where('name', 'john')->first();
$room->setSettings(['password' => null, 'locked' => null, 'nomedia' => null]);
parent::tearDown();
}
/**
* Test listing user rooms
*
* @group openvidu
*/
public function testIndex(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
Room::where('user_id', $jack->id)->delete();
// Unauth access not allowed
$response = $this->get("api/v4/openvidu/rooms");
$response->assertStatus(401);
// John has one room
$response = $this->actingAs($john)->get("api/v4/openvidu/rooms");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame('john', $json['list'][0]['name']);
// Jack has no room, but it will be auto-created
$response = $this->actingAs($jack)->get("api/v4/openvidu/rooms");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertMatchesRegularExpression('/^[0-9a-z-]{11}$/', $json['list'][0]['name']);
}
/**
* Test joining the room
*
* @group openvidu
*/
public function testJoinRoom(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$room = Room::where('name', 'john')->first();
$room->session_id = null;
$room->save();
$this->assignMeetEntitlement($john);
// Unauth access, no session yet
$response = $this->post("api/v4/openvidu/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(323, $json['code']);
// Non-existing room name
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing");
$response->assertStatus(404);
// TODO: Test accessing an existing room of deleted owner
// Non-owner, no session yet
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(323, $json['code']);
// Room owner, no session yet
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(324, $json['code']);
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
$json = $response->json();
$session_id = $room->fresh()->session_id;
$this->assertSame(Room::ROLE_SUBSCRIBER | Room::ROLE_MODERATOR | Room::ROLE_OWNER, $json['role']);
$this->assertSame($session_id, $json['session']);
$this->assertTrue(is_string($session_id) && !empty($session_id));
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
$john_token = $json['token'];
// Non-owner, now the session exists, no 'init' argument
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(322, $json['code']);
$this->assertTrue(empty($json['token']));
// Non-owner, now the session exists, with 'init', but no 'canPublish' argument
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(Room::ROLE_SUBSCRIBER, $json['role']);
$this->assertSame($session_id, $json['session']);
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
$this->assertTrue($json['token'] != $john_token);
// Non-owner, now the session exists, with 'init', and with 'role=PUBLISHER'
$post = ['canPublish' => true, 'init' => 1];
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
$this->assertSame($session_id, $json['session']);
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
$this->assertTrue($json['token'] != $john_token);
$this->assertEmpty($json['config']['password']);
$this->assertEmpty($json['config']['requires_password']);
// Non-owner, password protected room, password not provided
$room->setSettings(['password' => 'pass']);
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame(325, $json['code']);
$this->assertSame('error', $json['status']);
$this->assertSame('Failed to join the session. Invalid password.', $json['message']);
$this->assertEmpty($json['config']['password']);
$this->assertTrue($json['config']['requires_password']);
// Non-owner, password protected room, invalid provided
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['password' => 'aa']);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(325, $json['code']);
// Non-owner, password protected room, valid password provided
// TODO: Test without init=1
$post = ['password' => 'pass', 'init' => 'init'];
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame($session_id, $json['session']);
// Make sure the room owner can access the password protected room w/o password
// TODO: Test without init=1
$post = ['init' => 'init'];
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(200);
// Test 'nomedia' room option
$room->setSettings(['nomedia' => 'true', 'password' => null]);
$post = ['init' => 'init', 'canPublish' => true];
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(Room::ROLE_PUBLISHER & $json['role'], Room::ROLE_PUBLISHER);
$post = ['init' => 'init', 'canPublish' => true];
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(Room::ROLE_PUBLISHER & $json['role'], 0);
}
/**
* Test locked room and join requests
*
* @group openvidu
*/
/* public function testJoinRequests(): void */
/* { */
/* $john = $this->getTestUser('john@kolab.org'); */
/* $jack = $this->getTestUser('jack@kolab.org'); */
/* $room = Room::where('name', 'john')->first(); */
/* $room->session_id = null; */
/* $room->save(); */
/* $room->setSettings(['password' => null, 'locked' => 'true']); */
/* $this->assignMeetEntitlement($john); */
/* // Create the session (also makes sure the owner can access a locked room) */
/* $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]); */
/* $response->assertStatus(200); */
/* // Non-owner, locked room, invalid/missing input */
/* $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}"); */
/* $response->assertStatus(422); */
/* $json = $response->json(); */
/* $this->assertCount(4, $json); */
/* $this->assertSame(326, $json['code']); */
/* $this->assertSame('error', $json['status']); */
/* $this->assertSame('Failed to join the session. Room locked.', $json['message']); */
/* $this->assertTrue($json['config']['locked']); */
/* // Non-owner, locked room, invalid requestId */
/* $post = ['nickname' => 'name', 'requestId' => '-----', 'init' => 1]; */
/* $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post); */
/* $response->assertStatus(422); */
/* $json = $response->json(); */
/* $this->assertSame(326, $json['code']); */
/* // Non-owner, locked room, invalid requestId */
/* $post = ['nickname' => 'name', 'init' => 1]; */
/* $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post); */
/* $response->assertStatus(422); */
/* $json = $response->json(); */
/* $this->assertSame(326, $json['code']); */
/* // Non-owner, locked room, valid input */
/* $reqId = '12345678'; */
/* $post = ['nickname' => 'name', 'requestId' => $reqId, 'picture' => 'data:image/png;base64,01234']; */
/* $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post); */
/* $response->assertStatus(422); */
/* $json = $response->json(); */
/* $this->assertCount(4, $json); */
/* $this->assertSame(327, $json['code']); */
/* $this->assertSame('error', $json['status']); */
/* $this->assertSame('Failed to join the session. Room locked.', $json['message']); */
/* $this->assertTrue($json['config']['locked']); */
/* // TODO: How do we assert that a signal has been sent to the owner? */
/* // Test denying a request */
/* // Unknown room */
/* $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/unknown/request/unknown/deny"); */
/* $response->assertStatus(404); */
/* // Unknown request Id */
/* $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/unknown/deny"); */
/* $response->assertStatus(500); */
/* $json = $response->json(); */
/* $this->assertCount(2, $json); */
/* $this->assertSame('error', $json['status']); */
/* $this->assertSame('Failed to deny the join request.', $json['message']); */
/* // Non-owner access forbidden */
/* $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/deny"); */
/* $response->assertStatus(403); */
/* // Valid request */
/* $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/deny"); */
/* $response->assertStatus(200); */
/* $json = $response->json(); */
/* $this->assertSame('success', $json['status']); */
/* // Non-owner, locked room, join request denied */
/* $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post); */
/* $response->assertStatus(422); */
/* $json = $response->json(); */
/* $this->assertSame(327, $json['code']); */
/* // Test accepting a request */
/* // Unknown room */
/* $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/unknown/request/unknown/accept"); */
/* $response->assertStatus(404); */
/* // Unknown request Id */
/* $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/unknown/accept"); */
/* $response->assertStatus(500); */
/* $json = $response->json(); */
/* $this->assertCount(2, $json); */
/* $this->assertSame('error', $json['status']); */
/* $this->assertSame('Failed to accept the join request.', $json['message']); */
/* // Non-owner access forbidden */
/* $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/accept"); */
/* $response->assertStatus(403); */
/* // Valid request */
/* $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/accept"); */
/* $response->assertStatus(200); */
/* $json = $response->json(); */
/* $this->assertSame('success', $json['status']); */
/* // Non-owner, locked room, join request accepted */
/* $post['init'] = 1; */
/* $post['canPublish'] = true; */
/* $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post); */
/* $response->assertStatus(200); */
/* $json = $response->json(); */
/* $this->assertSame(Room::ROLE_PUBLISHER, $json['role']); */
/* $this->assertTrue(strpos($json['token'], 'wss://') === 0); */
/* // TODO: Test a scenario where both password and lock are enabled */
/* // TODO: Test accepting/denying as a non-owner moderator */
/* } */
/**
* Test joining the room
*
* @group openvidu
* @depends testJoinRoom
*/
public function testJoinRoomGuest(): void
{
$this->assignMeetEntitlement('john@kolab.org');
// There's no asy way to logout the user in the same test after
// using actingAs(). That's why this is moved to a separate test
$room = Room::where('name', 'john')->first();
// Guest, request with screenShare token
$post = ['canPublish' => true, 'screenShare' => 1, 'init' => 1];
$response = $this->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
$this->assertSame($room->session_id, $json['session']);
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
}
/**
* Test creating an extra connection for screen sharing
*
* @group openvidu
*/
/* public function testCreateConnection(): void */
/* { */
/* $john = $this->getTestUser('john@kolab.org'); */
/* $jack = $this->getTestUser('jack@kolab.org'); */
/* $room = Room::where('name', 'john')->first(); */
/* $room->session_id = null; */
/* $room->save(); */
/* $this->assignMeetEntitlement($john); */
/* // First we create the session */
/* $post = ['init' => 1, 'canPublish' => 1]; */
/* $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", $post); */
/* $response->assertStatus(200); */
/* $json = $response->json(); */
/* $owner_auth_token = $json['authToken']; */
/* // And the other user connection */
/* $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]); */
/* $response->assertStatus(200); */
/* $json = $response->json(); */
/* $conn_id = $json['connectionId']; */
/* $auth_token = $json['authToken']; */
/* // Non-existing room name */
/* $response = $this->post("api/v4/openvidu/rooms/non-existing/connections", []); */
/* $response->assertStatus(404); */
/* // No connection token provided */
/* $response = $this->post("api/v4/openvidu/rooms/{$room->name}/connections", []); */
/* $response->assertStatus(403); */
/* // Invalid token */
/* $response = $this->actingAs($jack) */
/* ->withHeaders([OpenViduController::AUTH_HEADER => '123']) */
/* ->post("api/v4/openvidu/rooms/{$room->name}/connections", []); */
/* $response->assertStatus(403); */
/* // Subscriber can't get the screen-sharing connection */
/* // Note: We're acting as Jack because there's no easy way to unset the 'actingAs' user */
/* // throughout the test */
/* $response = $this->actingAs($jack) */
/* ->withHeaders([OpenViduController::AUTH_HEADER => $auth_token]) */
/* ->post("api/v4/openvidu/rooms/{$room->name}/connections", []); */
/* $response->assertStatus(403); */
/* // Publisher can get the connection */
/* $response = $this->actingAs($jack) */
/* ->withHeaders([OpenViduController::AUTH_HEADER => $owner_auth_token]) */
/* ->post("api/v4/openvidu/rooms/{$room->name}/connections", []); */
/* $response->assertStatus(200); */
/* $json = $response->json(); */
/* $this->assertSame('success', $json['status']); */
/* $this->assertTrue(strpos($json['token'], 'wss://') === 0); */
/* // OpenVidu 2.18 does not send 'role' param in the token uri */
/* // $this->assertTrue(strpos($json['token'], 'role=PUBLISHER') !== false); */
/* } */
- /**
- * Test dismissing a participant (closing a connection)
- *
- * @group openvidu
- */
- public function testDismissConnection(): void
- {
- /*
- $john = $this->getTestUser('john@kolab.org');
- $jack = $this->getTestUser('jack@kolab.org');
- $room = Room::where('name', 'john')->first();
- $room->session_id = null;
- $room->save();
-
- $this->assignMeetEntitlement($john);
-
- // First we create the session
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- // And the other user connection
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $conn_id = $json['connectionId'];
- $room->refresh();
- $conn_data = $room->getOVConnection($conn_id);
-
- $this->assertSame($conn_id, $conn_data['connectionId']);
-
- // Non-existing room name
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing/connections/{$conn_id}/dismiss");
- $response->assertStatus(404);
-
- // TODO: Test accessing an existing room of deleted owner
-
- // Non-existing connection
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/connections/123/dismiss");
- $response->assertStatus(404);
-
- $json = $response->json();
-
- $this->assertCount(2, $json);
- $this->assertSame('error', $json['status']);
- $this->assertSame('The connection does not exist.', $json['message']);
-
- // Non-owner access
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}/dismiss");
- $response->assertStatus(403);
-
- // Expected success
- $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}/dismiss");
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame('success', $json['status']);
- $this->assertNull($room->getOVConnection($conn_id));
-
- // Test acting as a moderator
- $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
- $response->assertStatus(200);
- $json = $response->json();
- $conn_id = $json['connectionId'];
-
- // Note: We're acting as Jack because there's no easy way to unset a 'actingAs' user
- // throughout the test
- $response = $this->actingAs($jack)
- ->withHeaders([OpenViduController::AUTH_HEADER => $this->getModeratorToken($room)])
- ->post("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}/dismiss");
-
- $response->assertStatus(200);
- */
- }
-
/**
* Test configuring the room (session)
*
* @group openvidu
*/
/* public function testSetRoomConfig(): void */
/* { */
/* $john = $this->getTestUser('john@kolab.org'); */
/* $jack = $this->getTestUser('jack@kolab.org'); */
/* $room = Room::where('name', 'john')->first(); */
/* // Unauth access not allowed */
/* $response = $this->post("api/v4/openvidu/rooms/{$room->name}/config", []); */
/* $response->assertStatus(401); */
/* // Non-existing room name */
/* $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing/config", []); */
/* $response->assertStatus(404); */
/* // TODO: Test a room with a deleted owner */
/* // Non-owner */
/* $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/config", []); */
/* $response->assertStatus(403); */
/* // Room owner */
/* $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/config", []); */
/* $response->assertStatus(200); */
/* $json = $response->json(); */
/* $this->assertCount(2, $json); */
/* $this->assertSame('success', $json['status']); */
/* $this->assertSame("Room configuration updated successfully.", $json['message']); */
/* // Set password and room lock */
/* $post = ['password' => 'aaa', 'locked' => 1]; */
/* $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/config", $post); */
/* $response->assertStatus(200); */
/* $json = $response->json(); */
/* $this->assertCount(2, $json); */
/* $this->assertSame('success', $json['status']); */
/* $this->assertSame("Room configuration updated successfully.", $json['message']); */
/* $room->refresh(); */
/* $this->assertSame('aaa', $room->getSetting('password')); */
/* $this->assertSame('true', $room->getSetting('locked')); */
/* // Unset password and room lock */
/* $post = ['password' => '', 'locked' => 0]; */
/* $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/config", $post); */
/* $response->assertStatus(200); */
/* $json = $response->json(); */
/* $this->assertCount(2, $json); */
/* $this->assertSame('success', $json['status']); */
/* $this->assertSame("Room configuration updated successfully.", $json['message']); */
/* $room->refresh(); */
/* $this->assertSame(null, $room->getSetting('password')); */
/* $this->assertSame(null, $room->getSetting('locked')); */
/* // Test invalid option error */
/* $post = ['password' => 'eee', 'unknown' => 0]; */
/* $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/config", $post); */
/* $response->assertStatus(422); */
/* $json = $response->json(); */
/* $this->assertCount(2, $json); */
/* $this->assertSame('error', $json['status']); */
/* $this->assertSame("Invalid room configuration option.", $json['errors']['unknown']); */
/* $room->refresh(); */
/* $this->assertSame(null, $room->getSetting('password')); */
/* } */
/**
* Test updating a participant (connection)
*
* @group openvidu
*/
/*
public function testUpdateConnection(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$room = Room::where('name', 'john')->first();
$room->session_id = null;
$room->save();
$this->assignMeetEntitlement($john);
// First we create the session
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
$json = $response->json();
$owner_conn_id = $json['connectionId'];
// And the other user connection
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
$json = $response->json();
$conn_id = $json['connectionId'];
$auth_token = $json['authToken'];
$room->refresh();
$conn_data = $room->getOVConnection($conn_id);
$this->assertSame($conn_id, $conn_data['connectionId']);
// Non-existing room name
$response = $this->actingAs($john)->put("api/v4/openvidu/rooms/non-existing/connections/{$conn_id}", []);
$response->assertStatus(404);
// Non-existing connection
$response = $this->actingAs($john)->put("api/v4/openvidu/rooms/{$room->name}/connections/123", []);
$response->assertStatus(404);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('error', $json['status']);
$this->assertSame('The connection does not exist.', $json['message']);
// Non-owner access (empty post)
$response = $this->actingAs($jack)->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", []);
$response->assertStatus(200);
// Non-owner access (role update)
$post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_MODERATOR];
$response = $this->actingAs($jack)->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
$response->assertStatus(403);
// Expected success
$post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_MODERATOR];
$response = $this->actingAs($john)->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
$response->assertStatus(200);
*/
/* $json = $response->json(); */
/* $this->assertSame('success', $json['status']); */
/* $this->assertSame($post['role'], Connection::find($conn_id)->role); */
/* // Access as moderator */
/* // Note: We're acting as Jack because there's no easy way to unset a 'actingAs' user */
/* // throughout the test */
/* $token = $this->getModeratorToken($room); */
/* $post = ['role' => Room::ROLE_PUBLISHER]; */
/* $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token]) */
/* ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post); */
/* $response->assertStatus(200); */
/* $this->assertSame('success', $json['status']); */
/* $this->assertSame($post['role'], Connection::find($conn_id)->role); */
/* // Assert that it's not possible to add/remove the 'owner' role */
/* $post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_OWNER]; */
/* $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token]) */
/* ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post); */
/* $response->assertStatus(403); */
/* $post = ['role' => Room::ROLE_PUBLISHER]; */
/* $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token]) */
/* ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$owner_conn_id}", $post); */
/* $response->assertStatus(403); */
/* // Assert that removing a 'moderator' role from the owner is not possible */
/* $post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_OWNER]; */
/* $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token]) */
/* ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$owner_conn_id}", $post); */
/* $response->assertStatus(200); */
/* $this->assertSame($post['role'] | Room::ROLE_MODERATOR, Connection::find($owner_conn_id)->role); */
/* // Assert that non-moderator token does not allow access */
/* $post = ['role' => Room::ROLE_SUBSCRIBER]; */
/* $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $auth_token]) */
/* ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post); */
/* $response->assertStatus(403); */
// TODO: Test updating 'language' and 'hand' properties
/* } */
/**
* Create a moderator connection to the room session.
*
* @param \App\OpenVidu\Room $room The room
*
* @return string The connection authentication token
*/
/* private function getModeratorToken(Room $room): string */
/* { */
/* $result = $room->getSessionToken(Room::ROLE_MODERATOR); */
/* return $result['authToken']; */
/* } */
}