diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php index 7d14befb..b12fc043 100644 --- a/src/app/Http/Controllers/API/V4/OpenViduController.php +++ b/src/app/Http/Controllers/API/V4/OpenViduController.php @@ -1,409 +1,463 @@ 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) { + // 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']); } /** - * Denying the room join request. + * 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')); } - $user = Auth::guard()->user(); - - // Only the room owner can do it - if (!$user || $user->id != $room->user_id) { + // 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'), ]); } /** - * Accepting the room join request. + * 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')); } - $user = Auth::guard()->user(); - - // Only the room owner can do it (for now) - if (!$user || $user->id != $connection->room->user_id) { + // 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 current 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 beta entitlement for the room owner $sku = \App\Sku::where('title', 'meet')->first(); if ($sku && !$room->owner->entitlements()->where('sku_id', $sku->id)->first()) { 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')); } } $password = (string) $room->getSetting('password'); $config = [ 'locked' => $room->getSetting('locked') === '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')); $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')); } // Create session token for screen sharing connection if (($role & Room::ROLE_PUBLISHER) && !empty(request()->input('screenShare'))) { $add_token = $room->getSessionToken(Room::ROLE_SCREEN); $response['shareToken'] = $add_token['token']; } + // 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; 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')); + } + + // Only the moderator can do it + if (!$this->isModerator($connection->room)) { + return $this->errorResponse(403); + } + + foreach (request()->input() as $key => $value) { + switch ($key) { + case 'role': + $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; + } + + // TODO: Moderators authentication + + return false; + } } diff --git a/src/app/Observers/OpenVidu/ConnectionObserver.php b/src/app/Observers/OpenVidu/ConnectionObserver.php new file mode 100644 index 00000000..57a86c4d --- /dev/null +++ b/src/app/Observers/OpenVidu/ConnectionObserver.php @@ -0,0 +1,32 @@ +role != $connection->getOriginal('role')) { + $params = [ + 'connectionId' => $connection->id, + 'role' => $connection->role + ]; + + // Send the signal to all participants + $connection->room->signal('connectionUpdate', $params); + + // TODO: When demoting publisher to subscriber maybe we should + // destroy all streams using REST API. For now we trust the + // participant browser to do this. + } + } +} diff --git a/src/app/OpenVidu/Connection.php b/src/app/OpenVidu/Connection.php index ba8950bc..cccde474 100644 --- a/src/app/OpenVidu/Connection.php +++ b/src/app/OpenVidu/Connection.php @@ -1,57 +1,98 @@ 'array', ]; /** * Dismiss (close) the connection. * * @return bool True on success, False on failure */ public function dismiss() { if ($this->room->closeOVConnection($this->id)) { $this->delete(); return true; } return false; } /** * The room to which this connection belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function room() { return $this->belongsTo(Room::class, 'room_id', 'id'); } + + /** + * Connection role mutator + * + * @throws \Exception + */ + public function setRoleAttribute($role) + { + $new_role = 0; + + $allowed_values = [ + Room::ROLE_SUBSCRIBER, + Room::ROLE_PUBLISHER, + Room::ROLE_MODERATOR, + Room::ROLE_SCREEN, + Room::ROLE_OWNER, + ]; + + foreach ($allowed_values as $value) { + if ($role & $value) { + $new_role |= $value; + $role ^= $value; + } + } + + if ($role > 0) { + throw new \Exception("Invalid connection role: {$role}"); + } + + // It is either screen sharing connection or publisher/subscriber connection + if ($new_role & Room::ROLE_SCREEN) { + if ($new_role & Room::ROLE_PUBLISHER) { + $new_role ^= Room::ROLE_PUBLISHER; + } + if ($new_role & Room::ROLE_SUBSCRIBER) { + $new_role ^= Room::ROLE_SUBSCRIBER; + } + } + + $this->attributes['role'] = $new_role; + } } diff --git a/src/app/OpenVidu/Room.php b/src/app/OpenVidu/Room.php index cc9bc124..f5d1e373 100644 --- a/src/app/OpenVidu/Room.php +++ b/src/app/OpenVidu/Room.php @@ -1,384 +1,409 @@ 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') ] ] ); } 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; } $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) { + // For now we need only 'role' property, it might change in the future. + // Make sure to not return all metadata here as it might contain sensitive data. + return ['role' => $item->role]; + }) + ->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"); } // FIXME: Looks like passing the role in 'data' param is the only way // to make it visible for everyone in a room. So, for example we can // handle/style subscribers/publishers/moderators differently on the // client-side. Is this a security issue? $data = ['role' => $role]; $url = 'sessions/' . $this->session_id . '/connection'; $post = [ 'json' => [ 'role' => self::OV_ROLE_PUBLISHER, 'data' => json_encode($data) ] ]; $response = $this->client()->request('POST', $url, $post); if ($response->getStatusCode() == 200) { $json = json_decode($response->getBody(), true); // Extract the 'token' part of the token, it will be used to authenticate the connection. // It will be needed in next iterations e.g. to authenticate moderators that aren't // Kolab4 users (or are just not logged in to Kolab4). // FIXME: we could as well generate our own token for auth purposes parse_str(parse_url($json['token'], PHP_URL_QUERY), $url); // Create the connection reference in our database $conn = new Connection(); $conn->id = $json['id']; $conn->session_id = $this->session_id; $conn->room_id = $this->id; $conn->role = $role; $conn->metadata = ['token' => $url['token']]; $conn->save(); return [ 'session' => $this->session_id, 'token' => $json['token'], 'connectionId' => $json['id'], 'role' => $role, ]; } 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/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index 2209bbf2..40437600 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,60 +1,61 @@ sql, implode(', ', $query->bindings))); }); } // Register some template helpers Blade::directive('theme_asset', function ($path) { $path = trim($path, '/\'"'); return ""; }); } } diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js index db3c0781..4b19e7df 100644 --- a/src/resources/js/meet/app.js +++ b/src/resources/js/meet/app.js @@ -1,1078 +1,1301 @@ import anchorme from 'anchorme' import { library } from '@fortawesome/fontawesome-svg-core' import { OpenVidu } from 'openvidu-browser' class Roles { static get SUBSCRIBER() { return 1 << 0; } static get PUBLISHER() { return 1 << 1; } static get MODERATOR() { return 1 << 2; } static get SCREEN() { return 1 << 3; } static get OWNER() { return 1 << 4; } } function Meet(container) { let OV // OpenVidu object to initialize a session let session // Session object where the user will connect let publisher // Publisher object which the user will publish let audioActive = false // True if the audio track of the publisher is active let videoActive = false // True if the video track of the publisher is active - let numOfVideos = 0 // Keeps track of the number of videos that are being shown let audioSource = '' // Currently selected microphone let videoSource = '' // Currently selected camera let sessionData // Room session metadata let screenOV // OpenVidu object to initialize a screen sharing session let screenSession // Session object where the user will connect for screen sharing let screenPublisher // Publisher object which the user will publish the screen sharing let publisherDefaults = { publishAudio: true, // Whether to start publishing with your audio unmuted or not publishVideo: true, // Whether to start publishing with your video enabled or not resolution: '640x480', // The resolution of your video frameRate: 30, // The frame rate of your video mirror: true // Whether to mirror your local video or not } let cameras = [] // List of user video devices let microphones = [] // List of user audio devices let connections = {} // Connected users in the session let containerWidth let containerHeight let chatCount = 0 let volumeElement - let setupProps let subscribersContainer OV = new OpenVidu() screenOV = new OpenVidu() // If there's anything to do, do it here. //OV.setAdvancedConfiguration(config) // Disable all logging except errors // OV.enableProdMode() // 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.setup = setup + this.setupStart = setupStart + this.setupStop = setupStop this.setupSetAudioDevice = setupSetAudioDevice this.setupSetVideoDevice = setupSetVideoDevice this.switchAudio = switchAudio this.switchScreen = switchScreen this.switchVideo = switchVideo this.updateSession = updateSession /** * Join the room session * - * @param data Session metadata and event handlers (session, token, shareToken, nickname, role, - * chatElement, menuElement, onDestroy, onJoinRequest) + * @param data Session metadata and event handlers (token, shareToken, nickname, role, connections, + * chatElement, menuElement, onDestroy, onJoinRequest, onDismiss, onConnectionChange, + * onSessionDataUpdate, onMediaSetup) */ function joinRoom(data) { resize(); volumeMeterStop() data.params = { nickname: data.nickname, // user nickname // avatar: undefined // avatar image } // Create a container for subscribers if (!subscribersContainer) { subscribersContainer = $('
').appendTo(container).get(0) } sessionData = data // Init a session session = OV.initSession() // Handle connection creation events session.on('connectionCreated', event => { // Ignore the current user connection if (event.connection.role) { return } // This is the first event executed when a user joins in. // We'll create the video wrapper here, which can be re-used // in 'streamCreated' event handler. - // Note: For a user with a subscriber role 'streamCreated' event - // is not being dispatched at all let metadata = connectionData(event.connection) - let connectionId = event.connection.connectionId - metadata.connId = connectionId - - let element = participantCreate(metadata) + const connId = metadata.connectionId + + // The connection metadata here is the initial metadata set on + // connection initialization. There's no way to update it via OpenVidu API. + // So, we merge the initial connection metadata with up-to-dated one that + // we got from our database. + if (sessionData.connections && connId in sessionData.connections) { + Object.assign(metadata, sessionData.connections[connId]) + delete sessionData.connections[connId] + } - connections[connectionId] = { element } + metadata.element = participantCreate(metadata) - resize() + connections[connId] = metadata // Send the current user status to the connecting user // otherwise e.g. nickname might be not up to date signalUserUpdate(event.connection) }) session.on('connectionDestroyed', event => { let conn = connections[event.connection.connectionId] if (conn) { - if ($(conn.element).is('.meet-video')) { - numOfVideos-- - } $(conn.element).remove() delete connections[event.connection.connectionId] } resize() }) // On every new Stream received... session.on('streamCreated', event => { - let connection = event.stream.connection - let connectionId = connection.connectionId - let metadata = connectionData(connection) - let wrapper = connections[connectionId].element + let connectionId = event.stream.connection.connectionId + let metadata = connections[connectionId] let props = { // Prepend the video element so it is always before the watermark element insertMode: 'PREPEND' } // Subscribe to the Stream to receive it - let subscriber = session.subscribe(event.stream, wrapper, props); + let subscriber = session.subscribe(event.stream, metadata.element, props); subscriber.on('videoElementCreated', event => { $(event.element).prop({ tabindex: -1 }) resize() }) -/* - subscriber.on('videoElementDestroyed', event => { - }) -*/ + + metadata.audioActive = event.stream.audioActive + metadata.videoActive = event.stream.videoActive + // Update the wrapper controls/status - participantUpdate(wrapper, event.stream) + participantUpdate(metadata.element, metadata) }) -/* - session.on('streamDestroyed', event => { + + // Stream properties changes e.g. audio/video muted/unmuted + session.on('streamPropertyChanged', event => { + let connectionId = event.stream.connection.connectionId + let metadata = connections[connectionId] + + if (session.connection.connectionId == connectionId) { + metadata = sessionData + } + + if (metadata) { + metadata[event.changedProperty] = event.newValue + participantUpdate(metadata.element, metadata) + } }) -*/ + // Handle session disconnection events session.on('sessionDisconnected', event => { if (data.onDestroy) { data.onDestroy(event) } resize() }) // Handle signals from all participants session.on('signal', signalEventHandler) // Connect with the token session.connect(data.token, data.params) .then(() => { - let wrapper - let params = { self: true, role: data.role, audioActive, videoActive } + let params = { + connectionId: session.connection.connectionId, + role: data.role, + audioActive, + videoActive + } + params = Object.assign({}, data.params, params) publisher.on('videoElementCreated', event => { $(event.element).prop({ muted: true, // Mute local video to avoid feedback disablePictureInPicture: true, // this does not work in Firefox tabindex: -1 }) resize() }) - wrapper = participantCreate(params) + let wrapper = participantCreate(params) if (data.role & Roles.PUBLISHER) { publisher.createVideoElement(wrapper, 'PREPEND') session.publish(publisher) } - resize() - sessionData.wrapper = wrapper + sessionData.element = wrapper }) .catch(error => { console.error('There was an error connecting to the session: ', error.message); }) // Prepare the chat setupChat() } /** * Leave the room (disconnect) */ function leaveRoom() { if (publisher) { volumeMeterStop() // FIXME: We have to unpublish streams only if there's no session yet if (!session && audioActive) { publisher.publishAudio(false) } if (!session && videoActive) { publisher.publishVideo(false) } publisher = null } if (session) { session.disconnect(); session = null } if (screenSession) { screenSession.disconnect(); screenSession = null } } /** * Sets the audio and video devices for the session. * This will ask user for permission to access media devices. * * @param props Setup properties (videoElement, volumeElement, onSuccess, onError) */ - function setup(props) { - setupProps = props + function setupStart(props) { + // Note: After changing media permissions in Chrome/Firefox a page refresh is required. + // That means that in a scenario where you first blocked access to media devices + // and then allowed it we can't ask for devices list again and expect a different + // result than before. + // That's why we do not bother, and return ealy when we open the media setup dialog. + if (publisher) { + volumeMeterStart() + return + } publisher = OV.initPublisher(undefined, publisherDefaults) publisher.once('accessDenied', error => { props.onError(error) }) publisher.once('accessAllowed', async () => { let mediaStream = publisher.stream.getMediaStream() let videoStream = mediaStream.getVideoTracks()[0] let audioStream = mediaStream.getAudioTracks()[0] audioActive = !!audioStream videoActive = !!videoStream volumeElement = props.volumeElement publisher.addVideoElement(props.videoElement) volumeMeterStart() const devices = await OV.getDevices() devices.forEach(device => { // device's props: deviceId, kind, label if (device.kind == 'videoinput') { cameras.push(device) if (videoStream && videoStream.label == device.label) { videoSource = device.deviceId } } else if (device.kind == 'audioinput') { microphones.push(device) if (audioStream && audioStream.label == device.label) { audioSource = device.deviceId } } }) props.onSuccess({ microphones, cameras, audioSource, videoSource, audioActive, videoActive }) }) } + /** + * Stop the setup "process", cleanup after it. + */ + function setupStop() { + volumeMeterStop() + } + /** * Change the publisher audio device * * @param deviceId Device identifier string */ async function setupSetAudioDevice(deviceId) { if (!deviceId) { publisher.publishAudio(false) volumeMeterStop() audioActive = false } else if (deviceId == audioSource) { publisher.publishAudio(true) volumeMeterStart() audioActive = true } else { const mediaStream = publisher.stream.mediaStream - const oldTrack = mediaStream.getAudioTracks()[0] - - let properties = Object.assign({}, publisherDefaults, { + const properties = Object.assign({}, publisherDefaults, { publishAudio: true, publishVideo: videoActive, audioSource: deviceId, videoSource: videoSource }) volumeMeterStop() - // Note: We're not using publisher.replaceTrack() as it wasn't working for me - - // Stop and remove the old track - if (oldTrack) { - oldTrack.stop() - mediaStream.removeTrack(oldTrack) - } + // Stop and remove the old track, otherwise you get "Concurrent mic process limit." error + mediaStream.getAudioTracks().forEach(track => { + track.stop() + mediaStream.removeTrack(track) + }) // TODO: Handle errors await OV.getUserMedia(properties) .then(async (newMediaStream) => { - publisher.stream.mediaStream = newMediaStream + await replaceTrack(newMediaStream.getAudioTracks()[0]) volumeMeterStart() audioActive = true audioSource = deviceId }) } return audioActive } /** * Change the publisher video device * * @param deviceId Device identifier string */ async function setupSetVideoDevice(deviceId) { if (!deviceId) { publisher.publishVideo(false) videoActive = false } else if (deviceId == videoSource) { publisher.publishVideo(true) videoActive = true } else { const mediaStream = publisher.stream.mediaStream - const oldTrack = mediaStream.getAudioTracks()[0] - - let properties = Object.assign({}, publisherDefaults, { + const properties = Object.assign({}, publisherDefaults, { publishAudio: audioActive, publishVideo: true, audioSource: audioSource, videoSource: deviceId }) volumeMeterStop() - // Stop and remove the old track - if (oldTrack) { - oldTrack.stop() - mediaStream.removeTrack(oldTrack) - } + // Stop and remove the old track, otherwise you get "Concurrent mic process limit." error + mediaStream.getVideoTracks().forEach(track => { + track.stop() + mediaStream.removeTrack(track) + }) // TODO: Handle errors await OV.getUserMedia(properties) .then(async (newMediaStream) => { - publisher.stream.mediaStream = newMediaStream + await replaceTrack(newMediaStream.getVideoTracks()[0]) volumeMeterStart() videoActive = true videoSource = deviceId }) } return videoActive } + /** + * A way to switch tracks in a stream. + * Note: This is close to what publisher.replaceTrack() does but it does not + * require the session. + * Note: The old track needs to be removed before OV.getUserMedia() call, + * otherwise we get "Concurrent mic process limit" error. + */ + function replaceTrack(track) { + const stream = publisher.stream + + const replaceMediaStreamTrack = () => { + stream.mediaStream.addTrack(track); + + if (session) { + session.sendVideoData(publisher.stream.streamManager, 5, true, 5); + } + } + + return new Promise((resolve, reject) => { + if (stream.isLocalStreamPublished) { + // Only if the Publisher has been published it is necessary to call the native + // Web API RTCRtpSender.replaceTrack() + const senders = stream.getRTCPeerConnection().getSenders() + let sender + + if (track.kind === 'video') { + sender = senders.find(s => !!s.track && s.track.kind === 'video') + } else { + sender = senders.find(s => !!s.track && s.track.kind === 'audio') + } + + if (!sender) return + + sender.replaceTrack(track).then(() => { + replaceMediaStreamTrack() + resolve() + }).catch(error => { + reject(error) + }) + } else { + // Publisher not published. Simply modify local MediaStream tracks + replaceMediaStreamTrack() + resolve() + } + }) + } + /** * Setup the chat UI */ function setupChat() { // The UI elements are created in the vue template // Here we add a logic for how they work const textarea = $(sessionData.chatElement).find('textarea') const button = $(sessionData.menuElement).find('.link-chat') textarea.on('keydown', e => { if (e.keyCode == 13 && !e.shiftKey) { if (textarea.val().length) { signalChat(textarea.val()) textarea.val('') } return false } }) // Add an element for the count of unread messages on the chat button button.append('') .on('click', () => { button.find('.badge').text('') chatCount = 0 }) } /** * Signal events handler */ function signalEventHandler(signal) { let conn, data let connId = signal.from ? signal.from.connectionId : null switch (signal.type) { case 'signal:userChanged': + // TODO: Use 'signal:connectionUpdate' for nickname updates? if (conn = connections[connId]) { data = JSON.parse(signal.data) - participantUpdate(conn.element, data) + conn.nickname = data.nickname + participantUpdate(conn.element, conn) nicknameUpdate(data.nickname, connId) } break case 'signal:chat': data = JSON.parse(signal.data) data.id = connId pushChatMessage(data) break case 'signal:joinRequest': - if (sessionData.onJoinRequest) { + // accept requests from the server only + if (!connId && sessionData.onJoinRequest) { sessionData.onJoinRequest(JSON.parse(signal.data)) } - break; + break + + case 'signal:connectionUpdate': + // accept requests from the server only + if (!connId) { + data = JSON.parse(signal.data) + + connectionUpdate(data) + } + break } } /** * Send the chat message to other participants * * @param message Message string */ function signalChat(message) { let data = { nickname: sessionData.params.nickname, message } session.signal({ data: JSON.stringify(data), type: 'chat' }) } /** * Add a message to the chat * * @param data Object with a message, nickname, id (of the connection, empty for self) */ function pushChatMessage(data) { let message = $('').text(data.message).text() // make the message secure // Format the message, convert emails and urls to links message = anchorme({ input: message, options: { attributes: { target: "_blank" }, // any link above 20 characters will be truncated // to 20 characters and ellipses at the end truncate: 20, // characters will be taken out of the middle middleTruncation: true } // TODO: anchorme is extensible, we could support // github/phabricator's markup e.g. backticks for code samples }) message = message.replace(/\r?\n/, '
') // Display the message let isSelf = data.id == session.connectionId let chat = $(sessionData.chatElement).find('.chat') let box = chat.find('.message').last() message = $('
').html(message) message.find('a').attr('rel', 'noreferrer') if (box.length && box.data('id') == data.id) { // A message from the same user as the last message, no new box needed message.appendTo(box) } else { box = $('
').data('id', data.id) .append($('
').text(data.nickname || '')) .append(message) .appendTo(chat) if (isSelf) { box.addClass('self') } } // Count unread messages if (!$(sessionData.chatElement).is('.open')) { if (!isSelf) { chatCount++ } } else { chatCount = 0 } $(sessionData.menuElement).find('.link-chat .badge').text(chatCount ? chatCount : '') } /** * Send the user properties update signal to other participants * * @param connection Optional connection to which the signal will be sent * If not specified the signal is sent to all participants */ function signalUserUpdate(connection) { let data = { - audioActive, - videoActive, nickname: sessionData.params.nickname } - // Note: StreamPropertyChangedEvent might be more standard way - // to propagate the audio/video state change to other users. - // It looks there's no other way to propagate nickname changes. session.signal({ data: JSON.stringify(data), type: 'userChanged', to: connection ? [connection] : undefined }) // The same nickname for screen sharing session if (screenSession) { - data.audioActive = false - data.videoActive = true screenSession.signal({ data: JSON.stringify(data), type: 'userChanged', to: connection ? [connection] : undefined }) } } /** * Mute/Unmute audio for current session publisher */ function switchAudio() { // TODO: If user has no devices or denied access to them in the setup, // the button will just not work. Find a way to make it working // after user unlocks his devices. For now he has to refresh // the page and join the room again. if (microphones.length) { try { publisher.publishAudio(!audioActive) audioActive = !audioActive - participantUpdate(sessionData.wrapper, { audioActive }) - signalUserUpdate() } catch (e) { console.error(e) } } return audioActive } /** * Mute/Unmute video for current session publisher */ function switchVideo() { // TODO: If user has no devices or denied access to them in the setup, // the button will just not work. Find a way to make it working // after user unlocks his devices. For now he has to refresh // the page and join the room again. if (cameras.length) { try { publisher.publishVideo(!videoActive) videoActive = !videoActive - participantUpdate(sessionData.wrapper, { videoActive }) - signalUserUpdate() } catch (e) { console.error(e) } } return videoActive } /** * Switch on/off screen sharing */ function switchScreen(callback) { if (screenPublisher) { screenSession.disconnect() screenSession = null screenPublisher = null if (callback) { // Note: Disconnecting invalidates the token. The callback should request // a new token for the next screen sharing session. callback(false) } return } screenConnect(callback) } /** * Detect if screen sharing is supported by the browser */ function isScreenSharingSupported() { return !!OV.checkScreenSharingCapabilities(); } + /** + * Update participant connection state + */ + function connectionUpdate(data) { + let conn = connections[data.connectionId] + + // It's me + if (session.connection.connectionId == data.connectionId) { + const rolePublisher = data.role && data.role & Roles.PUBLISHER + const isPublisher = sessionData.role & Roles.PUBLISHER + + // Inform the vue component, so it can update some UI controls + let update = () => { + if (sessionData.onSessionDataUpdate) { + sessionData.onSessionDataUpdate(data) + } + } + + // 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) + } + + // merge the changed data into internal session metadata object + Object.keys(data).forEach(key => { sessionData[key] = data[key] }) + + // update the participant element + sessionData.element = participantUpdate(sessionData.element, sessionData) + + // promoted to a publisher + if ('role' in data && !isPublisher && rolePublisher) { + publisher.createVideoElement(sessionData.element, 'PREPEND') + session.publish(publisher).then(() => { + data.audioActive = publisher.stream.audioActive + data.videoActive = publisher.stream.videoActive + update() + }) + + // TODO: Here the user is asked for media permissions again + // should we rather start the stream without asking the user? + // Or maybe we want to display the media setup/preview form? + // Need to find a way to do this. + } else { + // Inform the vue component, so it can update some UI controls + update() + } + } else if (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 nickname in chat * * @param nickname Nickname * @param connectionId Connection identifier of the user */ function nicknameUpdate(nickname, connectionId) { if (connectionId) { $(sessionData.chatElement).find('.chat').find('.message').each(function() { let elem = $(this) if (elem.data('id') == connectionId) { elem.find('.nickname').text(nickname || '') } }) } } /** * 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 * * @return The element */ function participantCreate(params) { + let element + + params.isSelf = params.isSelf || session.connection.connectionId == params.connectionId + if (params.role & Roles.PUBLISHER || params.role & Roles.SCREEN) { - return publisherCreate(params) + element = publisherCreate(params) + } else { + element = subscriberCreate(params) } - return subscriberCreate(params) + setTimeout(resize, 50); + + return element } /** * Create a