Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117757612
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
210 KB
Referenced Files
None
Subscribers
None
View Options
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
Details
Attached
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)
Attached To
Mode
rK kolab
Attached
Detach File
Event Timeline