diff --git a/meet/server/server.js b/meet/server/server.js index 09295d2c..515685fc 100755 --- a/meet/server/server.js +++ b/meet/server/server.js @@ -1,392 +1,389 @@ #!/usr/bin/env node process.title = 'kolabmeet-server'; const config = require('./config/config'); const fs = require('fs'); const http = require('http'); const spdy = require('spdy'); const express = require('express'); const bodyParser = require('body-parser'); const cookieParser = require('cookie-parser'); const compression = require('compression'); const mediasoup = require('mediasoup'); const AwaitQueue = require('awaitqueue'); const Logger = require('./lib/Logger'); const Room = require('./lib/Room'); const Peer = require('./lib/Peer'); const helmet = require('helmet'); // auth const redis = require('redis'); const expressSession = require('express-session'); const RedisStore = require('connect-redis')(expressSession); const sharedSession = require('express-socket.io-session'); const interactiveServer = require('./lib/interactiveServer'); const promExporter = require('./lib/promExporter'); const { v4: uuidv4 } = require('uuid'); /* eslint-disable no-console */ console.log('- process.env.DEBUG:', process.env.DEBUG); console.log('- config.mediasoup.worker.logLevel:', config.mediasoup.worker.logLevel); console.log('- config.mediasoup.worker.logTags:', config.mediasoup.worker.logTags); /* eslint-enable no-console */ const logger = new Logger(); const queue = new AwaitQueue(); let statusLogger = null; if ('StatusLogger' in config) statusLogger = new config.StatusLogger(); // mediasoup Workers. // @type {Array} const mediasoupWorkers = []; // Map of Room instances indexed by roomId. const rooms = new Map(); // Map of Peer instances indexed by peerId. const peers = new Map(); // TLS server configuration. const tls = { cert : fs.readFileSync(config.tls.cert), key : fs.readFileSync(config.tls.key), secureOptions : 'tlsv12', ciphers : [ 'ECDHE-ECDSA-AES128-GCM-SHA256', 'ECDHE-RSA-AES128-GCM-SHA256', 'ECDHE-ECDSA-AES256-GCM-SHA384', 'ECDHE-RSA-AES256-GCM-SHA384', 'ECDHE-ECDSA-CHACHA20-POLY1305', 'ECDHE-RSA-CHACHA20-POLY1305', 'DHE-RSA-AES128-GCM-SHA256', 'DHE-RSA-AES256-GCM-SHA384' ].join(':'), honorCipherOrder : true }; const app = express(); app.use(helmet.hsts()); const sharedCookieParser=cookieParser(); app.use(sharedCookieParser); app.use(bodyParser.json({ limit: '5mb' })); app.use(bodyParser.urlencoded({ limit: '5mb', extended: true })); const session = expressSession({ secret : config.cookieSecret, name : config.cookieName, resave : true, saveUninitialized : true, store : config.redisOptions.host != 'none' ? new RedisStore({ client: redis.createClient(config.redisOptions) }) : null, cookie : { secure : true, httpOnly : true, maxAge : 60 * 60 * 1000 // Expire after 1 hour since last request from user } }); if (config.trustProxy) { app.set('trust proxy', config.trustProxy); } app.use(session); let mainListener; let io; async function run() { try { // Open the interactive server. await interactiveServer(rooms, peers); // start Prometheus exporter if (config.prometheus) { await promExporter(rooms, peers, config.prometheus); } // Run a mediasoup Worker. await runMediasoupWorkers(); // Run HTTPS server. await runHttpsServer(); // Run WebSocketServer. await runWebSocketServer(); - // eslint-disable-next-line no-unused-vars - const errorHandler = (err, req, res, next) => { + const errorHandler = (err, req, res /*, next */) => { const trackingId = uuidv4(); res.status(500).send( `

Internal Server Error

If you report this error, please also report this tracking ID which makes it possible to locate your session in the logs which are available to the system administrator: ${trackingId}

` ); logger.error( 'Express error handler dump with tracking ID: %s, error dump: %o', trackingId, err); }; - // eslint-disable-next-line no-unused-vars app.use(errorHandler); } catch (error) { logger.error('run() [error:"%o"]', error); } app.emit('ready'); } function statusLog() { if (statusLogger) { statusLogger.log({ rooms : rooms, peers : peers }); } } async function runHttpsServer() { app.use(compression()); - app.get(`${config.pathPrefix}/api/ping`, function (req, res, /*next*/) { + app.get(`${config.pathPrefix}/api/ping`, function (req, res /*, next*/) { res.send('PONG') }) - app.get(`${config.pathPrefix}/api/sessions`, function (req, res, /*next*/) { + app.get(`${config.pathPrefix}/api/sessions`, function (req, res /*, next*/) { //TODO json.stringify res.json({ id : "testId" }) }) //Check if the room exists - app.get(`${config.pathPrefix}/api/sessions/:session_id`, function (req, res, /*next*/) { + app.get(`${config.pathPrefix}/api/sessions/:session_id`, function (req, res /*, next*/) { console.warn("Checking for room") let room = rooms.get(req.params.session_id); if (!room) { console.warn("doesn't exist") res.status(404).send() } else { console.warn("exist") res.status(200).send() } }) // Create room and return id - app.post(`${config.pathPrefix}/api/sessions`, async function (req, res, /*next*/) { + app.post(`${config.pathPrefix}/api/sessions`, async function (req, res /*, next*/) { console.warn("Creating new room", req.body.mediaMode, req.body.recordingMode) //FIXME we're truncating because of kolab4 database layout (should be fixed instead) const roomId = uuidv4().substring(0, 16) await getOrCreateRoom({ roomId }); res.json({ id : roomId }) }) - app.post(`${config.pathPrefix}/api/signal`, async function (req, res, /*next*/) { + app.post(`${config.pathPrefix}/api/signal`, async function (req, res /*, next*/) { let data = req.body; - const roomId = data.session; - // const signalType = data.type; - // const payload = data.data; - const peers = data.to; - - - if (peers) { - for (const peerId of peers) { - let peer = peers.get(peerId); - peer.socket.emit( - 'signal', data - ); - } + const roomId = data.roomId; + const emit = (socket) => { + socket.emit('notification', { + method: `signal:${data.type}`, + data: data.data + }) + }; + + if ('role' in data) { + peers.forEach(peer => { + if (peer.socket && peer.roomId == roomId && peer.hasRole(data.role)) { + emit(peer.socket); + } + }) } else { - io.to(roomId).emit( - 'signal', data - ); + emit(io.to(roomId)); } res.json({}) }); // Create connection in room (just wait for websocket instead? // $post = [ // 'json' => [ // 'role' => self::OV_ROLE_PUBLISHER, // 'data' => json_encode(['role' => $role]) // ] // ]; - app.post(`${config.pathPrefix}/api/sessions/:session_id/connection`, function (req, res, /*next*/) { + app.post(`${config.pathPrefix}/api/sessions/:session_id/connection`, function (req, res /*, next*/) { logger.info("Creating connection in session", req.params.session_id) let roomId = req.params.session_id let data = req.body; //FIXME we're truncating because of kolab4 database layout (should be fixed instnead) const peerId = uuidv4().substring(0, 16) //TODO create room already? let peer = new Peer({ id: peerId, roomId }); peers.set(peerId, peer); peer.on('close', () => { peers.delete(peerId); statusLog(); }); peer.nickname = "Display Name"; if ('role' in data) peer.setRole(data.role); const proto = config.publicDomain.includes('localhost') || config.publicDomain.includes('127.0.0.1') ? 'ws' : 'wss'; res.json({ id: peerId, // When the below get's passed to the socket.io client we end up with something like (depending on the socket.io path) // wss://${publicDomain}/meetmedia/signaling/?peerId=peer1&roomId=room1&EIO=3&transport=websocket, token: `${proto}://${config.publicDomain}/?peerId=${peerId}&roomId=${roomId}` }) }) if (config.httpOnly === true) { // http mainListener = http.createServer(app); } else { // https mainListener = spdy.createServer(tls, app); // http const redirectListener = http.createServer(app); if (config.listeningHost) redirectListener.listen(config.listeningRedirectPort, config.listeningHost); else redirectListener.listen(config.listeningRedirectPort); } console.info(`Listening on ${config.listeningPort} ${config.listeningHost}`) // https or http if (config.listeningHost) mainListener.listen(config.listeningPort, config.listeningHost); else mainListener.listen(config.listeningPort); } /** * Create a WebSocketServer to allow WebSocket connections from browsers. */ async function runWebSocketServer() { io = require('socket.io')(mainListener, { path: `${config.pathPrefix}/signaling`, cookie: false }); io.use( sharedSession(session, sharedCookieParser, { autoSave: true }) ); // Handle connections from clients. io.on('connection', (socket) => { logger.info("websocket connection") const { roomId, peerId } = socket.handshake.query; if (!roomId || !peerId) { logger.warn('connection request without roomId and/or peerId'); socket.disconnect(true); return; } logger.info('connection request [roomId:"%s", peerId:"%s"]', roomId, peerId); queue.push(async () => { const room = await getOrCreateRoom({ roomId }); let peer = peers.get(peerId); if (!peer) { logger.warn("Peer does not exist %s", peerId); socket.disconnect(true); return; } peer.socket = socket; room.handlePeer({ peer }); statusLog(); }) .catch((error) => { logger.error('room creation or room joining failed [error:"%o"]', error); if (socket) socket.disconnect(true); }); }); } /** * Launch as many mediasoup Workers as given in the configuration file. */ async function runMediasoupWorkers() { const { numWorkers } = config.mediasoup; logger.info('running %d mediasoup Workers...', numWorkers); for (let i = 0; i < numWorkers; ++i) { const worker = await mediasoup.createWorker( { logLevel : config.mediasoup.worker.logLevel, logTags : config.mediasoup.worker.logTags, rtcMinPort : config.mediasoup.worker.rtcMinPort, rtcMaxPort : config.mediasoup.worker.rtcMaxPort }); worker.on('died', () => { logger.error( 'mediasoup Worker died, exiting in 2 seconds... [pid:%d]', worker.pid); setTimeout(() => process.exit(1), 2000); }); mediasoupWorkers.push(worker); } } /** * Get a Room instance (or create one if it does not exist). */ async function getOrCreateRoom({ roomId }) { let room = rooms.get(roomId); // If the Room does not exist create a new one. if (!room) { logger.info('creating a new Room [roomId:"%s"]', roomId); room = await Room.create({ mediasoupWorkers, roomId, peers }); rooms.set(roomId, room); statusLog(); room.on('close', () => { rooms.delete(roomId); statusLog(); }); } return room; } run(); module.exports = app; // export for testing diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php index d11b4081..5a6e7df7 100644 --- a/src/app/Http/Controllers/API/V4/OpenViduController.php +++ b/src/app/Http/Controllers/API/V4/OpenViduController.php @@ -1,528 +1,518 @@ first(); // This isn't a room, bye bye if (!$room) { return $this->errorResponse(404, \trans('meet.room-not-found')); } // Only the moderator can do it if (!$this->isModerator($room)) { return $this->errorResponse(403); } if (!$room->requestAccept($reqid)) { return $this->errorResponse(500, \trans('meet.session-request-accept-error')); } return response()->json(['status' => 'success']); } /** * Deny the room join request. * * @param string $id Room identifier (name) * @param string $reqid Request identifier * * @return \Illuminate\Http\JsonResponse */ public function denyJoinRequest($id, $reqid) { $room = Room::where('name', $id)->first(); // This isn't a room, bye bye if (!$room) { return $this->errorResponse(404, \trans('meet.room-not-found')); } // Only the moderator can do it if (!$this->isModerator($room)) { return $this->errorResponse(403); } if (!$room->requestDeny($reqid)) { return $this->errorResponse(500, \trans('meet.session-request-deny-error')); } return response()->json(['status' => 'success']); } /** * Create a connection for screen sharing. * * @param string $id Room identifier (name) * * @return \Illuminate\Http\JsonResponse */ public function createConnection($id) { $room = Room::where('name', $id)->first(); // This isn't a room, bye bye if (!$room) { return $this->errorResponse(404, \trans('meet.room-not-found')); } $connection = $this->getConnectionFromRequest(); if ( !$connection || $connection->session_id != $room->session_id || ($connection->role & Room::ROLE_PUBLISHER) == 0 ) { return $this->errorResponse(403); } $response = $room->getSessionToken(Room::ROLE_SCREEN); return response()->json(['status' => 'success', 'token' => $response['token']]); } /** * 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 + // Send the request (signal) to all moderators $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; + + // TODO: We need to update connection state via webhook + // I.e. isModerator() checks here require up-to-date + // participant role information. Another option might be accepting/denying + // join requests via websocket } 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 3ba5bedf..fdbbc162 100644 --- a/src/app/OpenVidu/Room.php +++ b/src/app/OpenVidu/Room.php @@ -1,396 +1,378 @@ 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; } /** * 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. + * @param string $name Signal name (type) + * @param array $data Signal data array + * @param int $target Limit targets by their 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) : '', + 'roomId' => $this->session_id, + 'type' => $name, + 'role' => $target, + 'data' => $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 e8125af1..6cb8d497 100644 --- a/src/resources/js/meet/client.js +++ b/src/resources/js/meet/client.js @@ -1,771 +1,776 @@ '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 sendTransportInfo let sendTransport let recvTransport let iceServers = [] let nickname = '' let peers = {} let joinProps = {} let videoSource let audioSource const VIDEO_CONSTRAINTS = { 'low': { width: { ideal: 320 } }, 'medium': { width: { ideal: 640 } }, 'high': { width: { ideal: 1280 } }, 'veryhigh': { width: { ideal: 1920 } }, 'ultra': { width: { ideal: 3840 } } } // Create a device (use browser auto-detection) const device = new Device() // A helper for basic browser media operations const media = new Media() this.media = media navigator.mediaDevices.addEventListener('devicechange', () => { trigger('deviceChange') }) /** * Start a session (join a room) */ this.joinSession = (token, props) => { // Store the join properties for later joinProps = props // Initialize the socket, 'roomReady' request handler will do the rest of the job socket = initSocket(token) } /** * Close the session (disconnect) */ this.closeSession = async (reason) => { // If room owner, send the request to close the room if (reason === true && peers.self && peers.self.role & Roles.OWNER) { await socket.sendRequest('moderator:closeRoom') } trigger('closeSession', { reason: reason || 'disconnected' }) if (socket) { socket.close() } media.setupStop() // Close mediasoup Transports. if (sendTransport) { sendTransport.close() sendTransport = null } if (recvTransport) { recvTransport.close() recvTransport = null } // Remove peers' video elements Object.keys(peers).forEach(id => { let peer = peers[id] if (peer.videoElement) { $(peer.videoElement).remove() } }) // Reset state eventHandlers = {} camProducer = null micProducer = null screenProducer = null consumers = {} peers = {} } this.isJoined = () => { return 'self' in peers } this.camMute = async () => { if (camProducer) { camProducer.pause() await socket.sendRequest('pauseProducer', { producerId: camProducer.id }) trigger('updatePeer', updatePeerState(peers.self)) } return this.camStatus() } this.camUnmute = async () => { if (camProducer) { camProducer.resume() await socket.sendRequest('resumeProducer', { producerId: camProducer.id }) trigger('updatePeer', updatePeerState(peers.self)) } return this.camStatus() } this.camStatus = () => { return camProducer && !camProducer.paused && !camProducer.closed } this.micMute = async () => { if (micProducer) { micProducer.pause() await socket.sendRequest('pauseProducer', { producerId: micProducer.id }) trigger('updatePeer', updatePeerState(peers.self)) } return this.micStatus() } this.micUnmute = async () => { if (micProducer) { micProducer.resume() await socket.sendRequest('resumeProducer', { producerId: micProducer.id }) trigger('updatePeer', updatePeerState(peers.self)) } return this.micStatus() } this.micStatus = () => { return micProducer && !micProducer.paused && !micProducer.closed } this.kickPeer = (peerId) => { socket.sendRequest('moderator:kickPeer', { peerId }) } this.chatMessage = (message) => { socket.sendRequest('chatMessage', { message }) } this.raiseHand = async (status) => { if (peers.self.raisedHand != status) { peers.self.raisedHand = status await socket.sendRequest('raisedHand', { raisedHand: status }) trigger('updatePeer', peers.self, ['raisedHand']) } return status } this.setNickname = (nickname) => { if (peers.self.nickname != nickname) { peers.self.nickname = nickname socket.sendRequest('changeNickname', { nickname }) trigger('updatePeer', peers.self, ['nickname']) } } this.addRole = (peerId, role) => { socket.sendRequest('moderator:addRole', { peerId, role }) } this.removeRole = (peerId, role) => { socket.sendRequest('moderator:removeRole', { peerId, role }) } /** * 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 } addPeerTrack(peer, consumer.track) trigger('updatePeer', peer) break default: console.error('Unknow request method: ' + request.method) } }) socket.on('notification', (notification) => { switch (notification.method) { case 'roomReady': iceServers = notification.data.iceServers 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 'changeRole': { const { peerId, role } = notification.data const peer = peers.self.id === peerId ? peers.self : peers[peerId] if (!peer) { return } let changes = ['role'] const rolePublisher = role & Roles.PUBLISHER const roleModerator = role & Roles.MODERATOR const isPublisher = peer.role & Roles.PUBLISHER const isModerator = peer.role & Roles.MODERATOR if (isPublisher && !rolePublisher) { // demoted to a subscriber changes.push('publisherRole') if (peer.isSelf) { // stop publishing any streams this.setMic('', true) this.setCamera('', true) } else { // remove the video element peer.videoElement = null // TODO: Do we need to remove/stop consumers? } } else if (!isPublisher && rolePublisher) { // promoted to a publisher changes.push('publisherRole') // create a video element with no tracks setPeerTracks(peer, []) } if ((!isModerator && roleModerator) || (isModerator && !roleModerator)) { changes.push('moderatorRole') } peer.role = role trigger('updatePeer', peer, changes) return } case 'chatMessage': { trigger('chatMessage', notification.data) return } case 'moderator:closeRoom': { this.closeSession('session-closed') return } case 'moderator:kickPeer': { this.closeSession('session-closed') return } case 'raisedHand': { const { peerId, raisedHand } = notification.data const peer = peers[peerId] if (!peer) { return } peer.raisedHand = raisedHand trigger('updatePeer', peer, ['raisedHand']) return } + case 'signal:joinRequest': { + trigger('joinRequest', notification.data) + return + } + default: console.error('Unknow notification method: ' + notification.method) } }) 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 }) // Setup the consuming transport (for handling streams of other participants) await setRecvTransport() // Send the "join" request, get room data, participants, etc. const { peers: existing, role, id: peerId } = await socket.sendRequest('join', { nickname: joinProps.nickname, rtpCapabilities: device.rtpCapabilities }) trigger('joinSuccess') let peer = { id: peerId, role, nickname: joinProps.nickname, isSelf: true } // Add self to the list peers.self = peer // Start publishing webcam and mic (and setup the producing transport) await this.setCamera(joinProps.videoSource, true) await this.setMic(joinProps.audioSource, true) updatePeerState(peer) trigger('addPeer', peer) // 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) } peers[peer.id] = peer trigger('addPeer', peer) }) } this.setCamera = async (deviceId, noUpdate) => { // Actually selected device, do nothing if (deviceId == videoSource) { return } // Remove current device, stop producer if (camProducer && !camProducer.closed) { camProducer.close() await socket.sendRequest('closeProducer', { producerId: camProducer.id }) setPeerTracks(peers.self, []) } peers.self.videoSource = videoSource = deviceId if (!deviceId) { if (!noUpdate) { trigger('updatePeer', updatePeerState(peers.self), ['videoSource']) } return } 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 } }) await setSendTransport() // TODO: Simulcast support? camProducer = await sendTransport.produce({ track, appData: { source : 'webcam' } }) /* camProducer.on('transportclose', () => { camProducer = null }) camProducer.on('trackended', () => { // disableWebcam() }) */ // Create/Update the video element addPeerTrack(peers.self, track) if (!noUpdate) { trigger('updatePeer', peers.self, ['videoSource']) } } this.setMic = async (deviceId, noUpdate) => { // Actually selected device, do nothing if (deviceId == audioSource) { return } // Remove current device, stop producer if (micProducer && !micProducer.closed) { micProducer.close() await socket.sendRequest('closeProducer', { producerId: micProducer.id }) } peers.self.audioSource = audioSource = deviceId if (!deviceId) { if (!noUpdate) { trigger('updatePeer', updatePeerState(peers.self), ['audioSource']) } return } 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 } } }) await setSendTransport() micProducer = await sendTransport.produce({ track, codecOptions: { opusStereo, opusDtx, opusFec, opusPtime, opusMaxPlaybackRate }, appData: { source : 'mic' } }) /* micProducer.on('transportclose', () => { micProducer = null }) micProducer.on('trackended', () => { // disableMic() }) */ // Note: We're not adding this track to the video element if (!noUpdate) { trigger('updatePeer', updatePeerState(peers.self), ['audioSource']) } } const setPeerTracks = (peer, tracks) => { if (!peer.videoElement) { peer.videoElement = media.createVideoElement(tracks, { mirror: peer.isSelf }) } else { const stream = new MediaStream() tracks.forEach(track => stream.addTrack(track)) peer.videoElement.srcObject = stream } updatePeerState(peer) } const addPeerTrack = (peer, track) => { if (!peer.videoElement) { setPeerTracks(peer, [ track ]) return } const stream = peer.videoElement.srcObject if (track.kind == 'video') { media.removeTracksFromStream(stream, 'Video') } else { media.removeTracksFromStream(stream, 'Audio') } stream.addTrack(track) updatePeerState(peer) } const updatePeerState = (peer) => { if (peer.isSelf) { peer.videoActive = this.camStatus() peer.audioActive = this.micStatus() } else { peer.videoActive = false peer.audioActive = false Object.keys(consumers).forEach(cid => { const consumer = consumers[cid] if (consumer.peerId == peer.id) { peer[consumer.kind + 'Active'] = !consumer.paused && !consumer.closed && !consumer.producerPaused } }) } return peer } const setSendTransport = async () => { if (sendTransport && !sendTransport.closed) { return } if (!sendTransportInfo) { sendTransportInfo = await socket.sendRequest('createWebRtcTransport', { forceTcp: false, producing: true, consuming: false }) } const { id, iceParameters, iceCandidates, dtlsParameters } = sendTransportInfo const iceTransportPolicy = (device.handlerName.toLowerCase().includes('firefox') && iceServers) ? 'relay' : undefined sendTransport = device.createSendTransport({ id, iceParameters, iceCandidates, dtlsParameters, iceServers, 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) } }) } const setRecvTransport = async () => { const transportInfo = await socket.sendRequest('createWebRtcTransport', { forceTcp: false, producing: false, consuming: true }) const { id, iceParameters, iceCandidates, dtlsParameters } = transportInfo const iceTransportPolicy = (device.handlerName.toLowerCase().includes('firefox') && iceServers) ? 'relay' : undefined recvTransport = device.createRecvTransport({ id, iceParameters, iceCandidates, dtlsParameters, iceServers, iceTransportPolicy }) recvTransport.on('connect', ({ dtlsParameters }, callback, errback) => { socket.sendRequest('connectWebRtcTransport', { transportId: recvTransport.id, dtlsParameters }) .then(callback) .catch(errback) }) } } export { Client } diff --git a/src/resources/js/meet/room.js b/src/resources/js/meet/room.js index 0ded59c3..9f03367d 100644 --- a/src/resources/js/meet/room.js +++ b/src/resources/js/meet/room.js @@ -1,1083 +1,1083 @@ 'use strict' import anchorme from 'anchorme' import { Client } from './client.js' import { Roles } from './constants.js' import { Dropdown } from 'bootstrap' import { library } from '@fortawesome/fontawesome-svg-core' function Room(container) { let sessionData // Room session metadata let peers = {} // Participants in the session (including self) let publishersContainer // Container element for publishers let subscribersContainer // Container element for subscribers let chatCount = 0 let scrollStop let $t const client = new Client() // Disconnect participant when browser's window close window.addEventListener('beforeunload', () => { leaveRoom() }) window.addEventListener('resize', resize) // Public methods this.isScreenSharingSupported = isScreenSharingSupported this.joinRoom = joinRoom this.leaveRoom = leaveRoom this.raiseHand = raiseHand this.setupStart = setupStart this.setupStop = setupStop this.setupSetAudioDevice = setupSetAudioDevice this.setupSetVideoDevice = setupSetVideoDevice this.switchAudio = switchAudio this.switchChannel = switchChannel this.switchScreen = switchScreen this.switchVideo = switchVideo this.updateSession = updateSession /** * Join the room session * * @param data Session metadata and event handlers: * token - A token for the main connection, * shareToken - A token for screen-sharing connection, * nickname - Participant name, * 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, * onJoinRequest - Callback for join request, * onSessionDataUpdate - Callback for current user connection update, * onMediaSetup - Called when user clicks the Media setup button * translate - Translation function */ function joinRoom(data) { // Create a container for subscribers and publishers publishersContainer = $('
').appendTo(container).get(0) subscribersContainer = $('
').appendTo(container).get(0) resize(); $t = data.translate // Make sure all supported callbacks exist, so we don't have to check // their existence everywhere anymore let events = ['Success', 'Error', 'Destroy', 'JoinRequest', '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) 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 peerHandDown(peer) $(peer.element).remove() delete peers[peerId] } resize() }) // Participant properties changed e.g. audio/video muted/unmuted client.on('updatePeer', (event, changed) => { console.log('updatePeer', event) let peer = peers[event.id] if (!peer) { return } event.element = peer.element if (event.videoElement && event.videoElement.parentNode != event.element) { $(event.element).prepend(event.videoElement) } else if (!event.videoElement) { $(event.element).find('video').remove() } if (changed && changed.length) { if (changed && changed.includes('nickname')) { nicknameUpdate(event.nickname, event.id) } if (changed.includes('raisedHand')) { if (event.raisedHand) { peerHandUp(event) } else { peerHandDown(event) } } } event.element = participantUpdate(event.element, event) // It's me, got publisher role if (peer.isSelf && (event.role & Roles.PUBLISHER) && changed && changed.includes('publisherRole')) { // Open the media setup dialog sessionData.onMediaSetup() } /* // Update channels list sessionData.channels = getChannels(peers) // 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 (changed && changed.includes('moderatorRole')) { participantUpdateAll() } // Inform the vue component, so it can update some UI controls // sessionData.onSessionDataUpdate(sessionData) peers[event.id] = event }) client.on('joinSuccess', () => { data.onSuccess() client.media.setupStop() }) client.on('joinRequest', event => { - // TODO data.onJoinRequest(event) + data.onJoinRequest(event) }) // Handle session disconnection events client.on('closeSession', event => { // Notify the UI data.onDestroy(event) // Remove all participant elements Object.keys(peers).forEach(peerId => { $(peers[peerId].element).remove() }) peers = {} // refresh the matrix resize() }) const { audioSource, videoSource } = client.media.setupData() // Start the session client.joinSession(data.token, { videoSource, audioSource, nickname: data.nickname }) // Prepare the chat initChat() } /** * Leave the room (disconnect) */ function leaveRoom(forced) { client.closeSession(forced) peers = {} } /** * Raise or lower the hand * * @param status Hand raised or not */ async function raiseHand(status) { return await client.raiseHand(status) } /** * Sets the audio and video devices for the session. * This will ask user for permission to access media devices. * * @param props Setup properties (videoElement, volumeElement, onSuccess, onError) */ function setupStart(props) { client.media.setupStart(props) // When setting up devices while the session is ongoing we have to // disable currently selected devices (temporarily) otherwise e.g. // changing a mic or camera to another device will not be possible. if (client.isJoined()) { client.setMic('') client.setCamera('') } } /** * Stop the setup "process", cleanup after it. */ async function setupStop() { client.media.setupStop() // Apply device changes to the client const { audioSource, videoSource } = client.media.setupData() await client.setMic(audioSource) await client.setCamera(videoSource) } /** * 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 initChat() { // Handle arriving chat messages client.on('chatMessage', pushChatMessage) // The UI elements are created in the vue template // Here we add a logic for how they work const chat = $(sessionData.chatElement).find('.chat').get(0) const textarea = $(sessionData.chatElement).find('textarea') const button = $(sessionData.menuElement).find('.link-chat') textarea.on('keydown', e => { if (e.keyCode == 13 && !e.shiftKey) { if (textarea.val().length) { client.chatMessage(textarea.val()) textarea.val('') } return false } }) // Add an element for the count of unread messages on the chat button button.append('') .on('click', () => { button.find('.badge').text('') chatCount = 0 // When opening the chat scroll it to the bottom, or we shouldn't? scrollStop = false chat.scrollTop = chat.scrollHeight }) $(chat).on('scroll', event => { // Detect manual scrollbar moves, disable auto-scrolling until // the scrollbar is positioned on the element bottom again scrollStop = chat.scrollTop + chat.offsetHeight < chat.scrollHeight }) } /** * Add a message to the chat * * @param data Object with a message, nickname, id (of the connection, empty for self) */ function pushChatMessage(data) { let message = $('').text(data.message).text() // make the message secure // Format the message, convert emails and urls to links message = anchorme({ input: message, options: { attributes: { target: "_blank" }, // any link above 20 characters will be truncated // to 20 characters and ellipses at the end truncate: 20, // characters will be taken out of the middle middleTruncation: true } // TODO: anchorme is extensible, we could support // github/phabricator's markup e.g. backticks for code samples }) message = message.replace(/\r?\n/, '
') // Display the message let isSelf = false // TODO let chat = $(sessionData.chatElement).find('.chat') let box = chat.find('.message').last() message = $('
').html(message) message.find('a').attr('rel', 'noreferrer') if (box.length && box.data('id') == data.peerId) { // A message from the same user as the last message, no new box needed message.appendTo(box) } else { box = $('
').data('id', data.peerId) .append($('
').text(data.nickname || '')) .append(message) .appendTo(chat) if (isSelf) { box.addClass('self') } } // Count unread messages if (!$(sessionData.chatElement).is('.open')) { if (!isSelf) { chatCount++ } } else { chatCount = 0 } $(sessionData.menuElement).find('.link-chat .badge').text(chatCount ? chatCount : '') // Scroll the chat element to the end if (!scrollStop) { chat.get(0).scrollTop = chat.get(0).scrollHeight } } /** * Switch interpreted language channel * * @param channel Two-letter language code */ function switchChannel(channel) { sessionData.channel = channel // Mute/unmute all peers 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) { // TODO } /** * Detect if screen sharing is supported by the browser */ function isScreenSharingSupported() { return false // TODO !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia) } /** * Handler for Hand-Up "signal" */ function peerHandUp(peer) { let element = $(nicknameWidget(peer)) participantUpdate(element, peer) element.attr('id', 'qa' + peer.id) .appendTo($(sessionData.queueElement).show()) setTimeout(() => element.addClass('widdle'), 50) } /** * Handler for Hand-Down "signal" */ function peerHandDown(peer) { let list = $(sessionData.queueElement) list.find('#qa' + peer.id).remove(); if (!list.find('.meet-nickname').length) { list.hide(); } } /** * Update participant nickname in the UI * * @param nickname Nickname * @param peerId Connection identifier of the user */ function nicknameUpdate(nickname, peerId) { if (peerId) { $(sessionData.chatElement).find('.chat').find('.message').each(function() { let elem = $(this) if (elem.data('id') == peerId) { elem.find('.nickname').text(nickname || '') } }) $(sessionData.queueElement).find('#qa' + peerId + ' .content').text(nickname || '') } } /** * Create a participant element in the matrix. Depending on the peer role * parameter it will be a video element wrapper inside the matrix or a simple * tag-like element on the subscribers list. * * @param params Peer 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) if (params.videoElement) { $(element).prepend(params.videoElement) } } else { // subscribers and language interpreters element = subscriberCreate(params, content) } setTimeout(resize, 50); return element } /** * Create a