Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
210 KB
Referenced Files
None
Subscribers
None
diff --git a/meet/server/lib/Room.js b/meet/server/lib/Room.js
index a2fe290a..62379ac7 100644
--- a/meet/server/lib/Room.js
+++ b/meet/server/lib/Room.js
@@ -1,1257 +1,1256 @@
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);
+ 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', 'extravideo' ]
+ ![ 'mic', 'webcam', 'screen' ]
.includes(appData.source)
)
throw new Error('invalid producer source');
if (
appData.source === 'mic' &&
!this._hasPermission(peer, Roles.PUBLISHER)
)
throw new Error('peer not authorized');
if (
appData.source === 'webcam' &&
!this._hasPermission(peer, Roles.PUBLISHER)
)
throw new Error('peer not authorized');
if (
appData.source === 'screen' &&
!this._hasPermission(peer, 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))
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
}, true);
// Return no error
cb();
break;
}
- case 'moderator:closeMeeting':
+ case 'moderator:closeRoom':
{
- if (!this._hasPermission(peer, Roles.MODERATOR))
+ if (!this._hasPermission(peer, Roles.OWNER))
throw new Error('peer not authorized');
- this._notification(peer.socket, 'moderator:kick', null, true);
+ this._notification(peer.socket, 'moderator:closeRoom', null, true);
cb();
// Close the room
this.close();
break;
}
/*
case 'moderator:kickPeer':
{
if (!this._hasPermission(peer, 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');
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 a24456ff..fba7806e 100644
--- a/src/app/Http/Controllers/API/V4/OpenViduController.php
+++ b/src/app/Http/Controllers/API/V4/OpenViduController.php
@@ -1,590 +1,557 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\OpenVidu\Connection;
use App\OpenVidu\Room;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class OpenViduController extends Controller
{
public const AUTH_HEADER = 'X-Meet-Auth-Token';
/**
* Accept the room join request.
*
* @param string $id Room identifier (name)
* @param string $reqid Request identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function acceptJoinRequest($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->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']);
}
- /**
- * Close the room session.
- *
- * @param string $id Room identifier (name)
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function closeRoom($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'));
- }
-
- $user = Auth::guard()->user();
-
- // Only the room owner can do it
- if (!$user || $user->id != $room->user_id) {
- return $this->errorResponse(403);
- }
-
- if (!$room->deleteSession()) {
- return $this->errorResponse(500, \trans('meet.session-close-error'));
- }
-
- return response()->json([
- 'status' => 'success',
- 'message' => __('meet.session-close-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/Room.php b/src/app/OpenVidu/Room.php
index 07f73c95..3ba5bedf 100644
--- a/src/app/OpenVidu/Room.php
+++ b/src/app/OpenVidu/Room.php
@@ -1,422 +1,396 @@
<?php
namespace App\OpenVidu;
use App\Traits\SettingsTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
/**
* The eloquent definition of a Room.
*
* @property int $id Room identifier
* @property string $name Room name
* @property int $user_id Room owner
* @property ?string $session_id OpenVidu session identifier
*/
class Room extends Model
{
use SettingsTrait;
public const ROLE_SUBSCRIBER = 1 << 0;
public const ROLE_PUBLISHER = 1 << 1;
public const ROLE_MODERATOR = 1 << 2;
public const ROLE_SCREEN = 1 << 3;
public const ROLE_OWNER = 1 << 4;
public const REQUEST_ACCEPTED = 'accepted';
public const REQUEST_DENIED = 'denied';
private const OV_ROLE_MODERATOR = 'MODERATOR';
private const OV_ROLE_PUBLISHER = 'PUBLISHER';
private const OV_ROLE_SUBSCRIBER = 'SUBSCRIBER';
protected $fillable = [
'user_id',
'name'
];
protected $table = 'openvidu_rooms';
/** @var \GuzzleHttp\Client|null HTTP client instance */
private static $client = null;
/**
* Creates HTTP client for connections to OpenVidu server
*
* @return \GuzzleHttp\Client HTTP client instance
*/
private function client()
{
if (!self::$client) {
self::$client = new \GuzzleHttp\Client(
[
'http_errors' => false, // No exceptions from Guzzle
'base_uri' => \config('openvidu.api_url'),
'verify' => \config('openvidu.api_verify_tls'),
'auth' => [
\config('openvidu.api_username'),
\config('openvidu.api_password')
],
'on_stats' => function (\GuzzleHttp\TransferStats $stats) {
$threshold = \config('logging.slow_log');
if ($threshold && ($sec = $stats->getTransferTime()) > $threshold) {
$url = $stats->getEffectiveUri();
$method = $stats->getRequest()->getMethod();
\Log::warning(sprintf("[STATS] %s %s: %.4f sec.", $method, $url, $sec));
}
},
]
);
}
return self::$client;
}
/**
* Destroy a OpenVidu connection
*
* @param string $conn Connection identifier
*
* @return bool True on success, False otherwise
* @throws \Exception if session does not exist
*/
public function closeOVConnection($conn): bool
{
if (!$this->session_id) {
throw new \Exception("The room session does not exist");
}
$url = 'sessions/' . $this->session_id . '/connection/' . urlencode($conn);
$response = $this->client()->request('DELETE', $url);
return $response->getStatusCode() == 204;
}
/**
* Fetch a OpenVidu connection information.
*
* @param string $conn Connection identifier
*
* @return ?array Connection data on success, Null otherwise
* @throws \Exception if session does not exist
*/
public function getOVConnection($conn): ?array
{
// Note: getOVConnection() not getConnection() because Eloquent\Model::getConnection() exists
// TODO: Maybe use some other name? getParticipant?
if (!$this->session_id) {
throw new \Exception("The room session does not exist");
}
$url = 'sessions/' . $this->session_id . '/connection/' . urlencode($conn);
$response = $this->client()->request('GET', $url);
if ($response->getStatusCode() == 200) {
return json_decode($response->getBody(), true);
}
return null;
}
/**
* Create a OpenVidu session
*
* @return array|null Session data on success, NULL otherwise
*/
public function createSession(): ?array
{
$response = $this->client()->request(
'POST',
"sessions",
[
'json' => [
'mediaMode' => 'ROUTED',
'recordingMode' => 'MANUAL'
]
]
);
if ($response->getStatusCode() !== 200) {
$this->session_id = null;
$this->save();
return null;
// TODO: Log an error/warning
}
$session = json_decode($response->getBody(), true);
$this->session_id = $session['id'];
$this->save();
return $session;
}
- /**
- * Delete a OpenVidu session
- *
- * @return bool
- */
- public function deleteSession(): bool
- {
- if (!$this->session_id) {
- return true;
- }
-
- $response = $this->client()->request(
- 'DELETE',
- "sessions/" . $this->session_id,
- );
-
- if ($response->getStatusCode() == 204) {
- $this->session_id = null;
- $this->save();
-
- return true;
- }
-
- return false;
- }
-
/**
* Returns metadata for every connection in a session.
*
* @return array Connections metadata, indexed by connection identifier
* @throws \Exception if session does not exist
*/
public function getSessionConnections(): array
{
if (!$this->session_id) {
throw new \Exception("The room session does not exist");
}
return Connection::where('session_id', $this->session_id)
// Ignore screen sharing connection for now
->whereRaw("(role & " . self::ROLE_SCREEN . ") = 0")
->get()
->keyBy('id')
->map(function ($item) {
// Warning: Make sure to not return all metadata here as it might contain sensitive data.
return [
'role' => $item->role,
'hand' => $item->metadata['hand'] ?? 0,
'language' => $item->metadata['language'] ?? null,
];
})
// Sort by order in the queue, so UI can re-build the existing queue in order
->sort(function ($a, $b) {
return $a['hand'] <=> $b['hand'];
})
->all();
}
/**
* Create a OpenVidu session (connection) token
*
* @param int $role User role (see self::ROLE_* constants)
*
* @return array|null Token data on success, NULL otherwise
* @throws \Exception if session does not exist
*/
public function getSessionToken($role = self::ROLE_SUBSCRIBER): ?array
{
if (!$this->session_id) {
throw new \Exception("The room session does not exist");
}
$url = 'sessions/' . $this->session_id . '/connection';
$post = [
'json' => [
'role' => $role,
]
];
$response = $this->client()->request('POST', $url, $post);
if ($response->getStatusCode() == 200) {
$json = json_decode($response->getBody(), true);
$authToken = base64_encode($json['id'] . ':' . \random_bytes(16));
//This is actually the url to the websocket (includes the connectionId below)
$connectionToken = $json['token'];
$connectionId = $json['id'];
// Create the connection reference in our database
$conn = new Connection();
$conn->id = $connectionId;
$conn->session_id = $this->session_id;
$conn->room_id = $this->id;
$conn->role = $role;
$conn->metadata = ['token' => $connectionToken, 'authToken' => $authToken];
$conn->save();
return [
'session' => $this->session_id,
'token' => $connectionToken,
'authToken' => $authToken,
'connectionId' => $connectionId,
'role' => $role,
];
}
// TODO: Log an error/warning on non-200 response
return null;
}
/**
* Check if the room has an active session
*
* @return bool True when the session exists, False otherwise
*/
public function hasSession(): bool
{
if (!$this->session_id) {
return false;
}
$response = $this->client()->request('GET', "sessions/{$this->session_id}");
return $response->getStatusCode() == 200;
}
/**
* The room owner.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function owner()
{
return $this->belongsTo('\App\User', 'user_id', 'id');
}
/**
* Accept the join request.
*
* @param string $id Request identifier
*
* @return bool True on success, False on failure
*/
public function requestAccept(string $id): bool
{
$request = Cache::get($this->session_id . '-' . $id);
if ($request) {
$request['status'] = self::REQUEST_ACCEPTED;
return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1));
}
return false;
}
/**
* Deny the join request.
*
* @param string $id Request identifier
*
* @return bool True on success, False on failure
*/
public function requestDeny(string $id): bool
{
$request = Cache::get($this->session_id . '-' . $id);
if ($request) {
$request['status'] = self::REQUEST_DENIED;
return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1));
}
return false;
}
/**
* Get the join request data.
*
* @param string $id Request identifier
*
* @return array|null Request data (e.g. nickname, status, picture?)
*/
public function requestGet(string $id): ?array
{
return Cache::get($this->session_id . '-' . $id);
}
/**
* Save the join request.
*
* @param string $id Request identifier
* @param array $request Request data
*
* @return bool True on success, False on failure
*/
public function requestSave(string $id, array $request): bool
{
// We don't really need the picture in the cache
// As we use this cache for the request status only
unset($request['picture']);
return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1));
}
/**
* Any (additional) properties of this room.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function settings()
{
return $this->hasMany('App\OpenVidu\RoomSetting', 'room_id');
}
/**
* Send a OpenVidu signal to the session participants (connections)
*
* @param string $name Signal name (type)
* @param array $data Signal data array
* @param null|int|string[] $target List of target connections, Null for all connections.
* It can be also a participant role.
*
* @return bool True on success, False on failure
* @throws \Exception if session does not exist
*/
public function signal(string $name, array $data = [], $target = null): bool
{
if (!$this->session_id) {
throw new \Exception("The room session does not exist");
}
$post = [
'session' => $this->session_id,
'type' => $name,
'data' => $data ? json_encode($data) : '',
];
// Get connection IDs by participant role
if (is_int($target)) {
$connections = Connection::where('session_id', $this->session_id)
->whereRaw("(role & $target)")
->pluck('id')
->all();
if (empty($connections)) {
return false;
}
$target = $connections;
}
if (!empty($target)) {
$post['to'] = $target;
}
$response = $this->client()->request('POST', 'signal', ['json' => $post]);
return $response->getStatusCode() == 200;
}
}
diff --git a/src/resources/js/meet/client.js b/src/resources/js/meet/client.js
index 7cbd1f6d..98d8b5aa 100644
--- a/src/resources/js/meet/client.js
+++ b/src/resources/js/meet/client.js
@@ -1,588 +1,616 @@
'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
+ * Start a session (join a room)
*/
- this.startSession = (token, props) => {
+ 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 = () => {
+ 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' })
+
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.chatMessage = (message) => {
socket.sendRequest('chatMessage', { message })
}
this.setNickname = (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)
- peers[peerId] = peer
-
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
+ }
+
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
}
- peer = updatePeerState(peer)
+ updatePeerState(peer)
peer.tracks = tracks
-
- peers[peer.id] = peer
}
const updatePeerState = (peer) => {
if (peer.isSelf) {
peer.videoActive = this.camStatus()
peer.audioActive = this.micStatus()
- peers.self = peer
} 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
}
})
-
- peers[peer.id] = peer
}
return peer
}
}
export { Client }
diff --git a/src/resources/js/meet/constants.js b/src/resources/js/meet/constants.js
new file mode 100644
index 00000000..c4c8199f
--- /dev/null
+++ b/src/resources/js/meet/constants.js
@@ -0,0 +1,9 @@
+class Roles {
+ static get SUBSCRIBER() { return 1 << 0; }
+ static get PUBLISHER() { return 1 << 1; }
+ static get MODERATOR() { return 1 << 2; }
+ static get SCREEN() { return 1 << 3; }
+ static get OWNER() { return 1 << 4; }
+}
+
+export { Roles }
diff --git a/src/resources/js/meet/media.js b/src/resources/js/meet/media.js
index 04977022..08f215cf 100644
--- a/src/resources/js/meet/media.js
+++ b/src/resources/js/meet/media.js
@@ -1,265 +1,278 @@
'use strict'
function Media()
{
let audioActive = false // True if the audio track is active
let videoActive = false // True if the video track is active
let audioSource = '' // Current audio device identifier
let videoSource = '' // Current video device identifier
let cameras = [] // List of user video devices
let microphones = [] // List of user audio devices
let setupVideoElement // <video> element for setup process
let setupVolumeElement // Volume indicator element for setup process
this.getAudioDevices = async () => {
let audioDevices = {}
try
{
const devices = await navigator.mediaDevices.enumerateDevices()
for (const device of devices) {
if (device.kind !== 'audioinput') {
continue
}
audioDevices[device.deviceId] = device
}
}
catch (error) {
console.error(error)
}
return audioDevices
}
this.getWebcams = async () => {
let webcamDevices = {}
try {
const devices = await navigator.mediaDevices.enumerateDevices()
for (const device of devices) {
if (device.kind !== 'videoinput') {
continue
}
webcamDevices[device.deviceId] = device
}
}
catch (error) {
console.error(error)
}
return webcamDevices
}
this.getMediaStream = async (successCallback, errorCallback) => {
navigator.mediaDevices.getUserMedia({ audio: true, video: true })
.then(mediaStream => {
successCallback(mediaStream)
})
.catch(error => {
errorCallback(error)
})
}
this.getTrack = async (constraints) => {
const stream = await navigator.mediaDevices.getUserMedia(constraints)
if (constraints['audio']) {
return stream.getAudioTracks()[0]
}
return stream.getVideoTracks()[0]
}
this.createVideoElement = (tracks, props) => {
const videoElement = document.createElement('video')
const stream = new MediaStream()
tracks.forEach(track => stream.addTrack(track))
videoElement.srcObject = stream
this.setVideoProps(videoElement, props)
return videoElement
}
this.setVideoProps = (videoElement, props) => {
videoElement.autoplay = true
videoElement.controls = false
videoElement.muted = props.muted || false
videoElement.disablePictureInPicture = true // this does not work in Firefox
videoElement.tabIndex = -1
videoElement.setAttribute('playsinline', 'true')
if (props.mirror) {
videoElement.style.transform = 'rotateY(180deg)'
videoElement.style.webkitTransform = 'rotateY(180deg)'
}
}
/**
* 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)
*/
this.setupStart = (props) => {
setupVideoElement = props.videoElement
setupVolumeElement = props.volumeElement
const callback = async (mediaStream) => {
let videoStream = mediaStream.getVideoTracks()[0]
let audioStream = mediaStream.getAudioTracks()[0]
audioActive = !!audioStream
videoActive = !!videoStream
this.setVideoProps(setupVideoElement, { mirror: true, muted: true })
setupVideoElement.srcObject = mediaStream
volumeMeterStart()
microphones = await this.getAudioDevices()
cameras = await this.getWebcams()
Object.keys(cameras).forEach(deviceId => {
// device's props: deviceId, kind, label
const device = cameras[deviceId]
if (videoStream && videoStream.label == device.label) {
videoSource = device.deviceId
}
})
Object.keys(microphones).forEach(deviceId => {
const device = microphones[deviceId]
if (audioStream && audioStream.label == device.label) {
audioSource = device.deviceId
}
})
props.onSuccess({
microphones,
cameras,
audioSource,
videoSource,
audioActive,
videoActive
})
}
this.getMediaStream(callback, props.onError)
}
/**
* Stop the setup "process", cleanup after it.
*/
this.setupStop = () => {
volumeMeterStop()
+
+ if (setupVideoElement) {
+ const mediaStream = new MediaStream()
+ setupVideoElement.srcObject = mediaStream
+ }
}
this.setupData = () => {
return {
microphones,
cameras,
audioSource,
videoSource,
audioActive,
videoActive
}
}
/**
* Change the publisher audio device
*
* @param deviceId Device identifier string
*/
this.setupSetAudio = async (deviceId) => {
+ const mediaStream = setupVideoElement.srcObject
+
if (!deviceId) {
volumeMeterStop()
+ removeTracksFromStream(mediaStream, 'Audio')
audioActive = false
audioSource = ''
} else if (deviceId == audioSource) {
volumeMeterStart()
audioActive = true
} else {
- const mediaStream = setupVideoElement.srcObject
const constraints = {
audio: {
deviceId: { ideal: deviceId }
}
}
volumeMeterStop()
// Stop and remove the old track, otherwise you get "Concurrent mic process limit." error
- mediaStream.getAudioTracks().forEach(track => {
- track.stop()
- mediaStream.removeTrack(track)
- })
+ removeTracksFromStream(mediaStream, 'Audio')
// TODO: Error handling
const track = await this.getTrack(constraints)
mediaStream.addTrack(track)
volumeMeterStart()
audioActive = true
audioSource = deviceId
}
return audioActive
}
/**
* Change the publisher video device
*
* @param deviceId Device identifier string
*/
this.setupSetVideo = async (deviceId) => {
+ const mediaStream = setupVideoElement.srcObject
+
if (!deviceId) {
+ removeTracksFromStream(mediaStream, 'Video')
+ // Without the next line the video element will freeze on the last video frame
+ // instead of turning black.
+ setupVideoElement.srcObject = mediaStream
videoActive = false
videoSource = ''
} else if (deviceId == audioSource) {
videoActive = true
} else {
- const mediaStream = setupVideoElement.srcObject
const constraints = {
video: {
deviceId: { ideal: deviceId }
}
}
// Stop and remove the old track, otherwise you get "Concurrent mic process limit." error
- mediaStream.getVideoTracks().forEach(track => {
- track.stop()
- mediaStream.removeTrack(track)
- })
+ removeTracksFromStream(mediaStream, 'Video')
// TODO: Error handling
const track = await this.getTrack(constraints)
mediaStream.addTrack(track)
videoActive = true
videoSource = deviceId
}
return videoActive
}
+ const removeTracksFromStream = (stream, type) => {
+ stream[`get${type}Tracks`]().forEach(track => {
+ track.stop()
+ stream.removeTrack(track)
+ })
+ }
+
const volumeMeterStart = () => {
// TODO
}
const volumeMeterStop = () => {
// TODO
}
}
export { Media }
diff --git a/src/resources/js/meet/room.js b/src/resources/js/meet/room.js
index 90eaaf80..12ba91be 100644
--- a/src/resources/js/meet/room.js
+++ b/src/resources/js/meet/room.js
@@ -1,1313 +1,1294 @@
'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'
-class Roles {
- static get SUBSCRIBER() { return 1 << 0; }
- static get PUBLISHER() { return 1 << 1; }
- static get MODERATOR() { return 1 << 2; }
- static get SCREEN() { return 1 << 3; }
- static get OWNER() { return 1 << 4; }
-}
-
function Room(container)
{
let session // Session object where the user will connect
- let publisher // Publisher object which the user will publish
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.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 = $('<div id="meet-publishers">').appendTo(container).get(0)
subscribersContainer = $('<div id="meet-subscribers">').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',
'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)
$(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)
}
participantUpdate(event.element, event)
peers[event.id] = event
})
client.on('joinSuccess', () => {
data.onSuccess()
})
-/*
+
// Handle session disconnection events
- client.on('sessionDisconnected', event => {
+ client.on('closeSession', event => {
+ // Notify the UI
data.onDestroy(event)
- client = null
+
+ // 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.startSession(data.token, { videoSource, audioSource, nickname: data.nickname })
+ client.joinSession(data.token, { videoSource, audioSource, nickname: data.nickname })
// Prepare the chat
setupChat()
}
/**
* Leave the room (disconnect)
*/
function leaveRoom() {
-/*
- if (publisher) {
- // Release any media
- let mediaStream = publisher.stream.getMediaStream()
- if (mediaStream) {
- mediaStream.getTracks().forEach(track => track.stop())
- }
-
- publisher = null
- }
-
- if (session) {
- session.disconnect();
- session = null
- }
-
- if (screenSession) {
- screenSession.disconnect();
- screenSession = null
- }
-*/
+ client.closeSession()
+ peers = {}
}
/**
* 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() {
// 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('<span class="badge badge-dark blinker">')
.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 = $('<span>').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/, '<br>')
// Display the message
let isSelf = false // TODO
let chat = $(sessionData.chatElement).find('.chat')
let box = chat.find('.message').last()
message = $('<div>').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 = $('<div class="message">').data('id', data.peerId)
.append($('<div class="nickname">').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))
participantUpdate(element, connection)
element.attr('id', 'qa' + connection.connectionId)
.appendTo($(sessionData.queueElement).show())
setTimeout(() => element.addClass('widdle'), 50)
}
/**
* Handler for Hand-Down "signal"
*/
function connectionHandDown(connectionId) {
let list = $(sessionData.queueElement)
list.find('#qa' + connectionId).remove();
if (!list.find('.meet-nickname').length) {
list.hide();
}
}
/**
* Update participant nickname in the UI
*
* @param nickname Nickname
* @param 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 <video> 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 = $(
'<div class="meet-video">'
+ svgIcon('user', 'fas', 'watermark')
+ '<div class="controls">'
// TODO + '<button type="button" class="btn btn-link link-setup hidden" title="' + $t('meet.media-setup') + '">' + svgIcon('cog') + '</button>'
+ '<div class="volume hidden"><input type="range" min="0" max="1" step="0.1" /></div>'
+ '<button type="button" class="btn btn-link link-audio hidden" title="' + $t('meet.menu-audio-mute') + '">' + svgIcon('volume-mute') + '</button>'
+ '<button type="button" class="btn btn-link link-fullscreen closed hidden" title="' + $t('meet.menu-fullscreen') + '">' + svgIcon('expand') + '</button>'
+ '<button type="button" class="btn btn-link link-fullscreen open hidden" title="' + $t('meet.menu-fullscreen') + '">' + svgIcon('compress') + '</button>'
+ '</div>'
+ '<div class="status">'
+ '<span class="bg-warning status-audio hidden">' + svgIcon('microphone-slash') + '</span>'
+ '<span class="bg-warning status-video hidden">' + svgIcon('video-slash') + '</span>'
+ '</div>'
+ '</div>'
)
// 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 = $('<div class="meet-subscriber">').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(`<option value="${code}">${$t(sessionData.languages[code])}</option>`)
})
// Create the element
let element = $(
'<div class="dropdown">'
+ '<a href="#" class="meet-nickname btn" aria-haspopup="true" aria-expanded="false" role="button">'
+ '<span class="content"></span>'
+ '<span class="icon">'
+ svgIcon('user', null, 'user')
+ svgIcon('crown', null, 'moderator hidden')
+ svgIcon('headphones', null, 'interpreter hidden')
+ '</span>'
+ '</a>'
+ '<div class="dropdown-menu">'
+ '<a class="dropdown-item action-nickname" href="#">Nickname</a>'
+ '<a class="dropdown-item action-dismiss" href="#">Dismiss</a>'
+ '<div class="dropdown-divider permissions"></div>'
+ '<div class="permissions">'
+ '<h6 class="dropdown-header">' + $t('meet.perm') + '</h6>'
+ '<label class="dropdown-item action-role-publisher form-check form-switch">'
+ '<input type="checkbox" class="form-check-input">'
+ ' <span class="form-check-label">' + $t('meet.perm-av') + '</span>'
+ '</label>'
+ '<label class="dropdown-item action-role-moderator form-check form-switch">'
+ '<input type="checkbox" class="form-check-input">'
+ ' <span class="form-check-label">' + $t('meet.perm-mod') + '</span>'
+ '</label>'
+ '</div>'
+ '<div class="dropdown-divider interpreting"></div>'
+ '<div class="interpreting">'
+ '<h6 class="dropdown-header">' + $t('meet.lang-int') + '</h6>'
+ '<div class="ps-3 pe-3"><select class="form-select">'
+ '<option value="">- ' + $t('form.none') + ' -</option>'
+ languages.join('')
+ '</select></div>'
+ '</div>'
+ '</div>'
+ '</div>'
)
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})
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)
})
}
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
}
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 $(`<svg><path fill="currentColor" d="${icon[4]}"></path></svg>`)
.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, Roles }
+export { Room }
diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue
index 28ed8d8e..7e5f16cd 100644
--- a/src/resources/vue/Meet/Room.vue
+++ b/src/resources/vue/Meet/Room.vue
@@ -1,757 +1,749 @@
<template>
<div id="meet-component">
<div id="meet-session-toolbar" class="hidden">
<span id="meet-counter" :title="$t('meet.partcnt')"><svg-icon icon="users"></svg-icon> <span></span></span>
<span id="meet-session-logo" v-html="$root.logo()"></span>
<div id="meet-session-menu">
<button :class="'btn link-audio' + (audioActive ? '' : ' on')" @click="switchSound" :disabled="!isPublisher()" :title="$t('meet.menu-audio-' + (audioActive ? 'mute' : 'unmute'))">
<svg-icon :icon="audioActive ? 'microphone' : 'microphone-slash'"></svg-icon>
</button>
<button :class="'btn link-video' + (videoActive ? '' : ' on')" @click="switchVideo" :disabled="!isPublisher()" :title="$t('meet.menu-video-' + (videoActive ? 'mute' : 'unmute'))">
<svg-icon :icon="videoActive ? 'video' : 'video-slash'"></svg-icon>
</button>
<button :class="'btn link-screen' + (screenShareActive ? ' on' : '')" @click="switchScreen" :disabled="!canShareScreen || !isPublisher()" :title="$t('meet.menu-screen')">
<svg-icon icon="desktop"></svg-icon>
</button>
<button :class="'btn link-hand' + (handRaised ? ' on' : '')" v-if="!isPublisher()" @click="switchHand" :title="$t('meet.menu-hand-' + (handRaised ? 'lower' : 'raise'))">
<svg-icon icon="hand-paper"></svg-icon>
</button>
<span id="channel-select" :style="'display:' + (channels.length ? '' : 'none')" class="dropdown">
<button :class="'btn link-channel' + (session.channel ? ' on' : '')" data-bs-toggle="dropdown"
:title="$t('meet.menu-channel')" aria-haspopup="true" aria-expanded="false"
>
<svg-icon icon="headphones"></svg-icon>
<span class="badge bg-danger" v-if="session.channel">{{ session.channel.toUpperCase() }}</span>
</button>
<div class="dropdown-menu">
<a :class="'dropdown-item' + (!session.channel ? ' active' : '')" href="#" data-code="" @click="switchChannel">- {{ $t('form.none') }} -</a>
<a v-for="code in channels" :key="code" href="#" @click="switchChannel" :data-code="code"
:class="'dropdown-item' + (session.channel == code ? ' active' : '')"
>{{ $t('lang.' + code) }}</a>
</div>
</span>
<button :class="'btn link-chat' + (chatActive ? ' on' : '')" @click="switchChat" :title="$t('meet.menu-chat')">
<svg-icon icon="comment"></svg-icon>
</button>
<button class="btn link-fullscreen closed hidden" @click="switchFullscreen" :title="$t('meet.menu-fullscreen')">
<svg-icon icon="expand"></svg-icon>
</button>
<button class="btn link-fullscreen open hidden" @click="switchFullscreen" :title="$t('meet.menu-fullscreen-exit')">
<svg-icon icon="compress"></svg-icon>
</button>
<button class="btn link-options" v-if="isRoomOwner()" @click="roomOptions" :title="$t('meet.options')">
<svg-icon icon="cog"></svg-icon>
</button>
<button class="btn link-logout" @click="logout" :title="$t('meet.menu-leave')">
<svg-icon icon="power-off"></svg-icon>
</button>
</div>
</div>
<div id="meet-setup" class="card container mt-2 mt-md-5 mb-5">
<div class="card-body">
<div class="card-title">{{ $t('meet.setup-title') }}</div>
<div class="card-text">
<form class="media-setup-form row" @submit.prevent="joinSession">
<div class="media-setup-preview col-sm-6 mb-3 mb-sm-0">
<video class="rounded"></video>
<div class="volume"><div class="bar"></div></div>
</div>
<div class="col-sm-6 align-self-center">
<div class="input-group mb-2">
<label for="setup-microphone" class="input-group-text mb-0" :title="$t('meet.mic')">
<svg-icon icon="microphone"></svg-icon>
</label>
<select class="form-select" id="setup-microphone" v-model="microphone" @change="setupMicrophoneChange">
<option value="">{{ $t('form.none') }}</option>
<option v-for="mic in setup.microphones" :value="mic.deviceId" :key="mic.deviceId">{{ mic.label }}</option>
</select>
</div>
<div class="input-group mb-2">
<label for="setup-camera" class="input-group-text mb-0" :title="$t('meet.cam')">
<svg-icon icon="video"></svg-icon>
</label>
<select class="form-select" id="setup-camera" v-model="camera" @change="setupCameraChange">
<option value="">{{ $t('form.none') }}</option>
<option v-for="cam in setup.cameras" :value="cam.deviceId" :key="cam.deviceId">{{ cam.label }}</option>
</select>
</div>
<div class="input-group mb-2">
<label for="setup-nickname" class="input-group-text mb-0" :title="$t('meet.nick')">
<svg-icon icon="user"></svg-icon>
</label>
<input class="form-control" type="text" id="setup-nickname" v-model="nickname" :placeholder="$t('meet.nick-placeholder')">
</div>
<div class="input-group mt-2" v-if="session.config && session.config.requires_password">
<label for="setup-password" class="input-group-text mb-0" :title="$t('form.password')">
<svg-icon icon="key"></svg-icon>
</label>
<input type="password" class="form-control" id="setup-password" v-model="password" :placeholder="$t('form.password')">
</div>
<div class="mt-3">
<button type="submit" id="join-button"
:class="'btn w-100 btn-' + (isRoomReady() ? 'success' : 'primary')"
>
<span v-if="isRoomReady()">{{ $t('meet.joinnow') }}</span>
<span v-else-if="roomState == 323">{{ $t('meet.imaowner') }}</span>
<span v-else>{{ $t('meet.join') }}</span>
</button>
</div>
</div>
<div class="mt-4 col-sm-12">
<status-message :status="roomState" :status-labels="roomStateLabels"></status-message>
</div>
</form>
</div>
</div>
</div>
<div id="meet-session-layout" class="d-flex hidden">
<div id="meet-queue">
<div class="head" :title="$t('meet.qa')"><svg-icon icon="microphone-alt"></svg-icon></div>
</div>
<div id="meet-session"></div>
<div id="meet-chat">
<div class="chat"></div>
<div class="chat-input m-2">
<textarea class="form-control" rows="1"></textarea>
</div>
</div>
</div>
<logon-form id="meet-auth" class="hidden" :dashboard="false" @success="authSuccess"></logon-form>
<div id="leave-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('meet.leave-title') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
</div>
<div class="modal-body">
<p>{{ $t('meet.leave-body') }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger modal-action" data-bs-dismiss="modal">{{ $t('btn.close') }}</button>
</div>
</div>
</div>
</div>
<div id="media-setup-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('meet.media-title') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
</div>
<div class="modal-body">
<form class="media-setup-form">
<div class="media-setup-preview"></div>
<div class="input-group mt-2">
<label for="setup-mic" class="input-group-text mb-0" :title="$t('meet.mic')">
<svg-icon icon="microphone"></svg-icon>
</label>
<select class="form-select" id="setup-mic" v-model="microphone" @change="setupMicrophoneChange">
<option value="">{{ $t('form.none') }}</option>
<option v-for="mic in setup.microphones" :value="mic.deviceId" :key="mic.deviceId">{{ mic.label }}</option>
</select>
</div>
<div class="input-group mt-2">
<label for="setup-cam" class="input-group-text mb-0" :title="$t('meet.cam')">
<svg-icon icon="video"></svg-icon>
</label>
<select class="form-select" id="setup-cam" v-model="camera" @change="setupCameraChange">
<option value="">{{ $t('form.none') }}</option>
<option v-for="cam in setup.cameras" :value="cam.deviceId" :key="cam.deviceId">{{ cam.label }}</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-action" data-bs-dismiss="modal">{{ $t('btn.close') }}</button>
</div>
</div>
</div>
</div>
<room-options v-if="session.config" :config="session.config" :room="room" @config-update="configUpdate"></room-options>
</div>
</template>
<script>
import { Modal } from 'bootstrap'
- import { Room as Meet, Roles } from '../../js/meet/room.js'
+ import { Room as Meet } from '../../js/meet/room.js'
+ import { Roles } from '../../js/meet/constants.js'
import StatusMessage from '../Widgets/StatusMessage'
import LogonForm from '../Login'
import RoomOptions from './RoomOptions'
// Register additional icons
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faComment,
faCog,
faCompress,
faCrown,
faDesktop,
faExpand,
faHandPaper,
faHeadphones,
faMicrophone,
faMicrophoneSlash,
faMicrophoneAlt,
faPowerOff,
faUser,
faUsers,
faVideo,
faVideoSlash,
faVolumeMute
} from '@fortawesome/free-solid-svg-icons'
// Register only these icons we need
library.add(
faComment,
faCog,
faCompress,
faCrown,
faDesktop,
faExpand,
faHandPaper,
faHeadphones,
faMicrophone,
faMicrophoneSlash,
faMicrophoneAlt,
faPowerOff,
faUser,
faUsers,
faVideo,
faVideoSlash,
faVolumeMute
)
let roomRequest
const authHeader = 'X-Meet-Auth-Token'
export default {
components: {
LogonForm,
RoomOptions,
StatusMessage
},
data() {
return {
setup: {
cameras: [],
microphones: [],
},
canShareScreen: false,
camera: '',
channels: [],
languages: {
en: 'lang.en',
de: 'lang.de',
fr: 'lang.fr',
it: 'lang.it'
},
meet: null,
microphone: '',
nickname: '',
password: '',
room: null,
roomState: 'init',
roomStateLabels: {
init: 'meet.status-init',
323: 'meet.status-323',
324: 'meet.status-324',
325: 'meet.status-325',
326: 'meet.status-326',
327: 'meet.status-327',
404: 'meet.status-404',
429: 'meet.status-429',
500: 'meet.status-500'
},
session: {},
audioActive: false,
videoActive: false,
chatActive: false,
handRaised: false,
screenShareActive: false
}
},
mounted() {
this.room = this.$route.params.room
// Initialize OpenVidu and do some basic checks
this.meet = new Meet($('#meet-session')[0]);
this.canShareScreen = this.meet.isScreenSharingSupported()
// Check the room and init the session
this.initSession()
// Setup the room UI
this.setupSession()
// Configure dialog events
$('#leave-dialog')[0].addEventListener('hide.bs.modal', () => {
// FIXME: Where exactly the user should land? Currently he'll land
// on dashboard (if he's logged in) or login form (if he's not).
this.$router.push({ name: 'dashboard' })
})
const dialog = $('#media-setup-dialog')[0]
dialog.addEventListener('show.bs.modal', () => { this.meet.setupStart() })
dialog.addEventListener('hide.bs.modal', () => { this.meet.setupStop() })
},
beforeDestroy() {
clearTimeout(roomRequest)
$('#app').removeClass('meet')
if (this.meet) {
this.meet.leaveRoom()
}
delete axios.defaults.headers.common[authHeader]
$(document.body).off('keydown.meet')
},
methods: {
authSuccess() {
// The user authentication succeeded, we still don't know it's really the room owner
this.initSession()
$('#meet-setup').removeClass('hidden')
$('#meet-auth').addClass('hidden')
},
configUpdate(config) {
this.session.config = Object.assign({}, this.session.config, config)
},
dismissParticipant(id) {
axios.post('/api/v4/openvidu/rooms/' + this.room + '/connections/' + id + '/dismiss')
},
initSession(init) {
const button = $('#join-button').prop('disabled', true)
this.post = {
password: this.password,
nickname: this.nickname,
screenShare: this.canShareScreen ? 1 : 0,
init: init ? 1 : 0,
picture: init ? this.makePicture() : '',
requestId: this.requestId(),
canPublish: !!this.camera || !!this.microphone
}
$('#setup-password,#setup-nickname').removeClass('is-invalid')
axios.post('/api/v4/openvidu/rooms/' + this.room, this.post, { ignoreErrors: true })
.then(response => {
button.prop('disabled', false)
// We already have token, the response is redundant
if (this.roomState == 'ready' && this.session.token) {
return
}
this.roomState = 'ready'
this.session = response.data
if (init) {
this.joinSession()
}
if (this.session.authToken) {
axios.defaults.headers.common[authHeader] = this.session.authToken
}
})
.catch(error => {
if (!error.response) {
console.error(error)
return
}
const data = error.response.data || {}
if (data.code) {
this.roomState = data.code
} else {
this.roomState = error.response.status
}
button.prop('disabled', this.roomState == 'init' || this.roomState == 327 || this.roomState >= 400)
if (data.config) {
this.session.config = data.config
}
switch (this.roomState) {
case 323:
// Waiting for the owner to open the room...
// Update room state every 10 seconds
roomRequest = setTimeout(() => { this.initSession() }, 10000)
break;
case 324:
// Room is ready for the owner, but the 'init' was not requested yet
clearTimeout(roomRequest)
break;
case 325:
// Missing/invalid password
if (init) {
$('#setup-password').addClass('is-invalid').focus()
}
break;
case 326:
// Locked room prerequisites error
if (init && !$('#setup-nickname').val()) {
$('#setup-nickname').addClass('is-invalid').focus()
}
break;
case 327:
// Waiting for the owner's approval to join
// Update room state every 10 seconds
roomRequest = setTimeout(() => { this.initSession(true) }, 10000)
break;
case 429:
// Rate limited, wait and try again
const waitTime = error.response.headers['retry-after'] || 10
roomRequest = setTimeout(() => { this.initSession(init) }, waitTime * 1000)
break;
default:
if (this.roomState >= 400 && this.roomState != 404) {
this.roomState = 500
}
}
})
if (document.fullscreenEnabled) {
$('#meet-session-menu').find('.link-fullscreen.closed').removeClass('hidden')
}
},
isModerator() {
return this.isRoomOwner() || (!!this.session.role && (this.session.role & Roles.MODERATOR) > 0)
},
isPublisher() {
return !!this.session.role && (this.session.role & Roles.PUBLISHER) > 0
},
isRoomOwner() {
return !!this.session.role && (this.session.role & Roles.OWNER) > 0
},
isRoomReady() {
return ['ready', 322, 324, 325, 326, 327].includes(this.roomState)
},
// An event received by the room owner when a participant is asking for a permission to join the room
joinRequest(data) {
// The toast for this user request already exists, ignore
// It's not really needed as we do this on server-side already
if ($('#i' + data.requestId).length) {
return
}
// FIXME: Should the message close button act as the Deny button? Do we need the Deny button?
let body = $(
`<div>`
+ `<div class="picture"><img src="${data.picture}"></div>`
+ `<div class="content">`
+ `<p class="mb-2"></p>`
+ `<div class="text-end">`
+ `<button type="button" class="btn btn-sm btn-success accept">${this.$t('btn.accept')}</button>`
+ `<button type="button" class="btn btn-sm btn-danger deny ms-2">${this.$t('btn.deny')}</button>`
)
this.$toast.message({
className: 'join-request',
icon: 'user',
timeout: 0,
title: this.$t('meet.join-request'),
// titleClassName: '',
body: body.html(),
onShow: element => {
const id = data.requestId
$(element).find('p').text(this.$t('meet.join-requested', { user: data.nickname || '' }))
// add id attribute, so we can identify it
$(element).attr('id', 'i' + id)
// add action to the buttons
.find('button.accept,button.deny').on('click', e => {
const action = $(e.target).is('.accept') ? 'accept' : 'deny'
axios.post('/api/v4/openvidu/rooms/' + this.room + '/request/' + id + '/' + action)
.then(response => {
$('#i' + id).remove()
})
})
}
})
},
// Entering the room
joinSession() {
// The form can be submitted not only via the submit button,
// make sure the submit is allowed
if ($('#meet-setup [type=submit]').prop('disabled')) {
return;
}
if (this.roomState == 323) {
$('#meet-setup').addClass('hidden')
$('#meet-auth').removeClass('hidden')
return
}
if (this.roomState != 'ready' && !this.session.token) {
this.initSession(true)
return
}
clearTimeout(roomRequest)
this.session.nickname = this.nickname
this.session.languages = this.languages
this.session.menuElement = $('#meet-session-menu')[0]
this.session.chatElement = $('#meet-chat')[0]
this.session.queueElement = $('#meet-queue')[0]
this.session.counterElement = $('#meet-counter span')[0]
this.session.translate = (label, args) => this.$t(label, args)
this.session.onSuccess = () => {
$('#app').addClass('meet')
$('#meet-setup').addClass('hidden')
$('#meet-session-toolbar,#meet-session-layout').removeClass('hidden')
}
this.session.onError = () => {
this.roomState = 500
}
this.session.onDestroy = event => {
- // TODO: Display different message for each reason: forceDisconnectByUser,
- // forceDisconnectByServer, sessionClosedByServer?
- if (event.reason != 'disconnect' && event.reason != 'networkDisconnect' && !this.isRoomOwner()) {
+ // TODO: Display different message for every other reason
+ if (event.reason == 'session-closed' && !this.isRoomOwner()) {
new Modal('#leave-dialog').show()
}
}
this.session.onDismiss = connId => { this.dismissParticipant(connId) }
this.session.onSessionDataUpdate = data => { this.updateSession(data) }
this.session.onConnectionChange = (connId, data) => { this.updateParticipant(connId, data) }
this.session.onJoinRequest = data => { this.joinRequest(data) }
this.session.onMediaSetup = () => { this.setupMedia() }
this.meet.joinRoom(this.session)
this.keyboardShortcuts()
},
keyboardShortcuts() {
$(document.body).on('keydown.meet', e => {
if ($(e.target).is('select,input,textarea')) {
return
}
// Self-Mute with 'm' key
if (e.key == 'm' || e.key == 'M') {
if ($('#meet-session-menu').find('.link-audio:not(:disabled)').length) {
this.switchSound()
}
}
})
},
logout() {
- const logout = () => {
- this.meet.leaveRoom()
- this.meet = null
- this.$router.push({ name: 'dashboard' })
- }
-
- if (this.isRoomOwner()) {
- axios.post('/api/v4/openvidu/rooms/' + this.room + '/close').then(logout)
- } else {
- logout()
- }
+ this.meet.leaveRoom()
+ this.meet = null
+ this.$router.push({ name: 'dashboard' })
},
makePicture() {
const video = $("#meet-setup video")[0];
// Skip if video is not "playing"
if (!video.videoWidth || !this.camera) {
return ''
}
// we're going to crop a square from the video and resize it
const maxSize = 64
// Calculate sizing
let sh = Math.floor(video.videoHeight / 1.5)
let sw = sh
let sx = (video.videoWidth - sw) / 2
let sy = (video.videoHeight - sh) / 2
let dh = Math.min(sh, maxSize)
let dw = sh < maxSize ? sw : Math.floor(sw * dh/sh)
const canvas = $("<canvas>")[0];
canvas.width = dw;
canvas.height = dh;
// draw the image on the canvas (square cropped and resized)
canvas.getContext('2d').drawImage(video, sx, sy, sw, sh, 0, 0, dw, dh);
// convert it to a usable data URL (png format)
return canvas.toDataURL();
},
requestId() {
const key = 'kolab-meet-uid'
if (!this.reqId) {
this.reqId = localStorage.getItem(key)
}
if (!this.reqId) {
// We store the identifier in the browser to make sure that it is the same after
// page refresh for the avg user. This will not prevent hackers from sending
// the new identifier on every request.
// If we're afraid of a room owner being spammed with join requests we might invent
// a way to silently ignore all join requests after the owner pressed some button
// stating "all attendees already joined, lock the room for good!".
// This will create max. 24-char numeric string
this.reqId = (String(Date.now()) + String(Math.random()).substring(2)).substring(0, 24)
localStorage.setItem(key, this.reqId)
}
return this.reqId
},
roomOptions() {
new Modal('#room-options-dialog').show()
},
setupMedia() {
const dialog = $('#media-setup-dialog')[0]
if (!$('video', dialog).length) {
$('#meet-setup').find('video,div.volume').appendTo($('.media-setup-preview', dialog))
}
new Modal(dialog).show()
},
setupSession() {
this.meet.setupStart({
videoElement: $('#meet-setup video')[0],
volumeElement: $('#meet-setup .volume')[0],
onSuccess: setup => {
this.setup = setup
this.microphone = setup.audioSource
this.camera = setup.videoSource
this.audioActive = setup.audioActive
this.videoActive = setup.videoActive
},
onError: error => {
this.audioActive = false
this.videoActive = false
}
})
},
async setupCameraChange() {
this.videoActive = await this.meet.setupSetVideoDevice(this.camera)
},
async setupMicrophoneChange() {
this.audioActive = await this.meet.setupSetAudioDevice(this.microphone)
},
switchChannel(e) {
let channel = $(e.target).data('code')
this.$set(this.session, 'channel', channel)
this.meet.switchChannel(channel)
},
switchChat() {
let chat = $('#meet-chat')
let enabled = chat.is('.open')
chat.toggleClass('open')
if (!enabled) {
chat.find('textarea').focus()
}
this.chatActive = !enabled
// Trigger resize, so participant matrix can update its layout
window.dispatchEvent(new Event('resize'));
},
switchFullscreen() {
const element = this.$el
$(element).off('fullscreenchange').on('fullscreenchange', (e) => {
let enabled = document.fullscreenElement == element
let buttons = $('#meet-session-menu').find('.link-fullscreen')
buttons.first()[enabled ? 'addClass' : 'removeClass']('hidden')
buttons.last()[!enabled ? 'addClass' : 'removeClass']('hidden')
})
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
element.requestFullscreen()
}
},
switchHand() {
this.updateSelf({ hand: !this.handRaised })
},
async switchSound() {
this.audioActive = await this.meet.switchAudio()
},
async switchVideo() {
this.videoActive = await this.meet.switchVideo()
},
switchScreen() {
const switchScreenAction = () => {
this.meet.switchScreen((enabled, error) => {
this.screenShareActive = enabled
if (!enabled && !error) {
// Closing a screen sharing connection invalidates the token
delete this.session.shareToken
}
})
}
if (this.session.shareToken || this.screenShareActive) {
switchScreenAction()
} else {
axios.post('/api/v4/openvidu/rooms/' + this.room + '/connections')
.then(response => {
this.session.shareToken = response.data.token
this.meet.updateSession(this.session)
switchScreenAction()
})
}
},
updateParticipant(connId, params) {
if (this.isModerator()) {
axios.put('/api/v4/openvidu/rooms/' + this.room + '/connections/' + connId, params)
}
},
updateSelf(params, onSuccess) {
axios.put('/api/v4/openvidu/rooms/' + this.room + '/connections/' + this.session.connectionId, params)
.then(response => {
if (onSuccess) {
onSuccess(response)
}
})
},
updateSession(data) {
this.session = data
this.channels = data.channels || []
const isPublisher = this.isPublisher()
this.videoActive = isPublisher ? data.videoActive : false
this.audioActive = isPublisher ? data.audioActive : false
this.handRaised = data.hand
}
}
}
</script>
diff --git a/src/tests/Feature/Controller/OpenViduTest.php b/src/tests/Feature/Controller/OpenViduTest.php
index 515db1fd..90be911e 100644
--- a/src/tests/Feature/Controller/OpenViduTest.php
+++ b/src/tests/Feature/Controller/OpenViduTest.php
@@ -1,785 +1,732 @@
<?php
namespace Tests\Feature\Controller;
use App\Http\Controllers\API\V4\OpenViduController;
use App\OpenVidu\Connection;
use App\OpenVidu\Room;
use Tests\TestCase;
class OpenViduTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->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 closing the room (session)
- *
- * @group openvidu
- * @depends testJoinRoom
- */
- /* public function testCloseRoom(): 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}/close", []); */
- /* $response->assertStatus(401); */
-
- /* // Non-existing room name */
- /* $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing/close", []); */
- /* $response->assertStatus(404); */
-
- /* // Non-owner */
- /* $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/close", []); */
- /* $response->assertStatus(403); */
-
- /* // Room owner */
- /* $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/close", []); */
- /* $response->assertStatus(200); */
-
- /* $json = $response->json(); */
-
- /* $this->assertNull($room->fresh()->session_id); */
- /* $this->assertSame('success', $json['status']); */
- /* $this->assertSame("The session has been closed successfully.", $json['message']); */
- /* $this->assertCount(2, $json); */
-
- /* // TODO: Test if the session is removed from the OpenVidu server too */
-
- /* // Test error handling when it's not possible to delete the session on */
- /* // the OpenVidu server (use fake session_id) */
- /* $room->session_id = 'aaa'; */
- /* $room->save(); */
-
- /* $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/close", []); */
- /* $response->assertStatus(500); */
-
- /* $json = $response->json(); */
-
- /* $this->assertSame('aaa', $room->fresh()->session_id); */
- /* $this->assertSame('error', $json['status']); */
- /* $this->assertSame("Failed to close the session.", $json['message']); */
- /* $this->assertCount(2, $json); */
- /* } */
-
/**
* 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']; */
/* } */
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Apr 4, 9:20 AM (3 w, 3 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18823435
Default Alt Text
(210 KB)

Event Timeline