diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php --- a/src/app/Http/Controllers/API/V4/OpenViduController.php +++ b/src/app/Http/Controllers/API/V4/OpenViduController.php @@ -12,7 +12,7 @@ class OpenViduController extends Controller { /** - * Accepting the room join request. + * Accept the room join request. * * @param string $id Room identifier (name) * @param string $reqid Request identifier @@ -28,10 +28,8 @@ 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); } @@ -43,7 +41,7 @@ } /** - * Denying the room join request. + * Deny the room join request. * * @param string $id Room identifier (name) * @param string $reqid Request identifier @@ -59,10 +57,8 @@ 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); } @@ -107,7 +103,7 @@ } /** - * Accepting the room join request. + * Dismiss the participant/connection from the session. * * @param string $id Room identifier (name) * @param string $conn Connection identifier @@ -123,10 +119,8 @@ 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); } @@ -302,6 +296,9 @@ $response['shareToken'] = $add_token['token']; } + // Get up-to-date connections metadata + $response['connections'] = $room->getSessionConnections(); + $response_code = 200; $response['role'] = $role; $response['config'] = $config; @@ -373,6 +370,42 @@ } /** + * 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. @@ -406,4 +439,25 @@ 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 --- /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 --- a/src/app/OpenVidu/Connection.php +++ b/src/app/OpenVidu/Connection.php @@ -54,4 +54,45 @@ { 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 --- a/src/app/OpenVidu/Room.php +++ b/src/app/OpenVidu/Room.php @@ -173,6 +173,31 @@ } /** + * 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) diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -30,6 +30,7 @@ \App\Domain::observe(\App\Observers\DomainObserver::class); \App\Entitlement::observe(\App\Observers\EntitlementObserver::class); \App\Group::observe(\App\Observers\GroupObserver::class); + \App\OpenVidu\Connection::observe(\App\Observers\OpenVidu\ConnectionObserver::class); \App\Package::observe(\App\Observers\PackageObserver::class); \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\Plan::observe(\App\Observers\PlanObserver::class); diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js --- a/src/resources/js/meet/app.js +++ b/src/resources/js/meet/app.js @@ -17,7 +17,6 @@ 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 @@ -42,7 +41,6 @@ let containerHeight let chatCount = 0 let volumeElement - let setupProps let subscribersContainer OV = new OpenVidu() @@ -65,7 +63,8 @@ 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 @@ -76,8 +75,9 @@ /** * 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(); @@ -108,18 +108,22 @@ // 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 @@ -129,9 +133,6 @@ 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] } @@ -140,17 +141,15 @@ // 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({ @@ -159,17 +158,29 @@ 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) { @@ -185,8 +196,13 @@ // 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 => { @@ -198,15 +214,14 @@ 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); @@ -251,8 +266,16 @@ * * @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) @@ -302,6 +325,13 @@ } /** + * Stop the setup "process", cleanup after it. + */ + function setupStop() { + volumeMeterStop() + } + + /** * Change the publisher audio device * * @param deviceId Device identifier string @@ -317,9 +347,7 @@ 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, @@ -328,19 +356,17 @@ 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 @@ -364,9 +390,7 @@ 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, @@ -375,17 +399,17 @@ 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 @@ -396,6 +420,53 @@ } /** + * 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() { @@ -433,10 +504,12 @@ 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 @@ -448,10 +521,20 @@ 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 } } @@ -542,14 +625,9 @@ */ 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', @@ -558,8 +636,6 @@ // The same nickname for screen sharing session if (screenSession) { - data.audioActive = false - data.videoActive = true screenSession.signal({ data: JSON.stringify(data), type: 'userChanged', @@ -580,8 +656,6 @@ try { publisher.publishAudio(!audioActive) audioActive = !audioActive - participantUpdate(sessionData.wrapper, { audioActive }) - signalUserUpdate() } catch (e) { console.error(e) } @@ -602,8 +676,6 @@ try { publisher.publishVideo(!videoActive) videoActive = !videoActive - participantUpdate(sessionData.wrapper, { videoActive }) - signalUserUpdate() } catch (e) { console.error(e) } @@ -641,6 +713,65 @@ } /** + * 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 @@ -667,11 +798,19 @@ * @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 } /** @@ -685,6 +824,7 @@ '
' + svgIcon('user', 'fas', 'watermark') + '
' + + '' + '' + '' + '' @@ -699,7 +839,12 @@ // Append the nickname widget wrapper.find('.controls').before(nicknameWidget(params)) - if (!params.self) { + if (params.isSelf) { + if (sessionData.onMediaSetup) { + wrapper.find('.link-setup').removeClass('hidden') + .click(() => sessionData.onMediaSetup()) + } + } else { // Enable audio mute button wrapper.find('.link-audio').removeClass('hidden') .on('click', e => { @@ -731,12 +876,12 @@ }) } - numOfVideos++ - // Remove the subscriber element, if exists - $('#subscriber-' + params.connId).remove() + $('#subscriber-' + params.connectionId).remove() - return wrapper[params.self ? 'prependTo' : 'appendTo'](container).get(0) + return wrapper[params.isSelf ? 'prependTo' : 'appendTo'](container) + .attr('id', 'publisher-' + params.connectionId) + .get(0) } /** @@ -746,27 +891,51 @@ * @param params Connection metadata/params */ function participantUpdate(wrapper, params) { - const $element = $(wrapper) + const element = $(wrapper) + const isModerator = sessionData.role & Roles.MODERATOR + const isSelf = session.connection.connectionId == params.connectionId + + // Handle publisher-to-subscriber and subscriber-to-publisher change + if ('role' in params && !(params.role & Roles.SCREEN)) { + const rolePublisher = params.role & Roles.PUBLISHER + const isPublisher = element.is('.meet-video') + + if ((rolePublisher && !isPublisher) || (!rolePublisher && isPublisher)) { + element.remove() + return participantCreate(params) + } + + element.find('.action-role-publisher input').prop('checked', params.role & Roles.PUBLISHER) + } if ('audioActive' in params) { - $element.find('.status-audio')[params.audioActive ? 'addClass' : 'removeClass']('hidden') + element.find('.status-audio')[params.audioActive ? 'addClass' : 'removeClass']('hidden') } if ('videoActive' in params) { - $element.find('.status-video')[params.videoActive ? 'addClass' : 'removeClass']('hidden') + element.find('.status-video')[params.videoActive ? 'addClass' : 'removeClass']('hidden') } if ('nickname' in params) { - $element.find('.meet-nickname > .content').text(params.nickname) + element.find('.meet-nickname > .content').text(params.nickname) } - if (params.self) { - $element.addClass('self') + if (isSelf) { + element.addClass('self') } - if (sessionData.role & Roles.MODERATOR) { - $element.addClass('moderated') + if (isModerator) { + element.addClass('moderated') } + + element.find('.dropdown-menu')[isSelf || isModerator ? 'removeClass' : 'addClass']('hidden') + element.find('.permissions')[isModerator ? 'removeClass' : 'addClass']('hidden') + + if ('role' in params && params.role & Roles.SCREEN) { + element.find('.permissions').addClass('hidden') + } + + return wrapper } /** @@ -780,8 +949,8 @@ participantUpdate(wrapper, params) - return wrapper[params.self ? 'prependTo' : 'appendTo'](subscribersContainer) - .attr('id', 'subscriber-' + params.connId) + return wrapper[params.isSelf ? 'prependTo' : 'appendTo'](subscribersContainer) + .attr('id', 'subscriber-' + params.connectionId) .get(0) } @@ -794,20 +963,35 @@ // Create the element let element = $( '' ) let nickname = element.find('.meet-nickname') - .addClass('btn btn-outline-' + (params.self ? 'primary' : 'secondary')) + .addClass('btn btn-outline-' + (params.isSelf ? 'primary' : 'secondary')) + .attr({title: 'Options', 'data-toggle': 'dropdown'}) + .dropdown({boundary: container}) - if (params.self) { + if (params.isSelf) { // Add events for nickname change let editable = element.find('.content')[0] let editableEnable = () => { @@ -821,7 +1005,8 @@ nicknameUpdate(editable.innerText, session.connection.connectionId) } - nickname.on('click', editableEnable) + element.find('.action-nickname').on('click', editableEnable) + element.find('.action-dismiss').remove() $(editable).on('blur', editableUpdate) .on('keydown', e => { @@ -831,14 +1016,47 @@ return false } }) - } else if (sessionData.role & Roles.MODERATOR) { - nickname.attr({title: 'Options', 'data-toggle': 'dropdown'}) - .dropdown({boundary: container}) + } else { + element.find('.action-nickname').remove() element.find('.action-dismiss').on('click', e => { if (sessionData.onDismiss) { - sessionData.onDismiss(params.connId) + sessionData.onDismiss(params.connectionId) + } + }) + } + + // Don't close the menu on permission change + element.find('.dropdown-menu > label').on('click', e => { e.stopPropagation() }) + + if (sessionData.onConnectionChange) { + element.find('.action-role-publisher input').on('change', e => { + const enabled = e.target.checked + let role = params.role + + if (enabled) { + role |= Roles.PUBLISHER + } else { + role |= Roles.SUBSCRIBER + if (role & Roles.PUBLISHER) { + role ^= Roles.PUBLISHER + } } + + sessionData.onConnectionChange(params.connectionId, { role }) + }) + + element.find('.action-role-moderator input').on('change', e => { + const enabled = e.target.checked + let role = params.role + + if (enabled) { + role |= Roles.MODERATOR + } else if (role & Roles.MODERATOR) { + role ^= Roles.MODERATOR + } + + sessionData.onConnectionChange(params.connectionId, { role }) }) } @@ -864,6 +1082,7 @@ * Update the room "matrix" layout */ function updateLayout() { + let numOfVideos = $(container).find('.meet-video').length if (!numOfVideos) { return } @@ -1071,7 +1290,11 @@ // OpenVidu is unable to merge these two objects into one, for it it is only // two strings, so it puts a "%/%" separator in between, we'll replace it with comma // to get one parseable json object - return JSON.parse(connection.data.replace('}%/%{', ',')) + let data = JSON.parse(connection.data.replace('}%/%{', ',')) + + data.connectionId = connection.connectionId + + return data } } diff --git a/src/resources/themes/meet.scss b/src/resources/themes/meet.scss --- a/src/resources/themes/meet.scss +++ b/src/resources/themes/meet.scss @@ -35,6 +35,13 @@ } } } + + & + .dropdown-menu { + .permissions > label { + margin: 0; + padding-left: 3.75rem; + } + } } .meet-video { @@ -151,10 +158,6 @@ #meet-setup { max-width: 720px; - - .input-group svg { - width: 1em; - } } #meet-auth { @@ -271,12 +274,18 @@ // TODO: mobile mode } -#setup-preview { +.media-setup-form { + .input-group svg { + width: 1em; + } +} + +.media-setup-preview { display: flex; + position: relative; video { width: 100%; - transform: rotateY(180deg); background: #000; } @@ -293,6 +302,10 @@ position: absolute; bottom: 0; } + + #media-setup-dialog & { + right: 1em; + } } } diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue --- a/src/resources/vue/Meet/Room.vue +++ b/src/resources/vue/Meet/Room.vue @@ -33,8 +33,8 @@
Set up your session
-
-
+ +
@@ -118,6 +118,45 @@
+ +
@@ -128,35 +167,37 @@ import LogonForm from '../Login' import SessionSecurityOptions from './SessionSecurityOptions' -// Register additional icons -import { library } from '@fortawesome/fontawesome-svg-core' - -import { - faAlignLeft, - faCompress, - faDesktop, - faExpand, - faMicrophone, - faPowerOff, - faUser, - faShieldAlt, - faVideo, - faVolumeMute -} from '@fortawesome/free-solid-svg-icons' - -// Register only these icons we need -library.add( - faAlignLeft, - faCompress, - faDesktop, - faExpand, - faMicrophone, - faPowerOff, - faUser, - faShieldAlt, - faVideo, - faVolumeMute -) + // Register additional icons + import { library } from '@fortawesome/fontawesome-svg-core' + + import { + faAlignLeft, + faCog, + faCompress, + faDesktop, + faExpand, + faMicrophone, + faPowerOff, + faUser, + faShieldAlt, + faVideo, + faVolumeMute + } from '@fortawesome/free-solid-svg-icons' + + // Register only these icons we need + library.add( + faAlignLeft, + faCog, + faCompress, + faDesktop, + faExpand, + faMicrophone, + faPowerOff, + faUser, + faShieldAlt, + faVideo, + faVolumeMute + ) let roomRequest @@ -316,6 +357,9 @@ $('#meet-session-menu').find('.link-fullscreen.closed').removeClass('hidden') } }, + isModerator() { + return this.isRoomOwner() || (!!this.session.role && (this.session.role & Roles.MODERATOR) > 0) + }, isPublisher() { return !!this.session.role && (this.session.role & Roles.PUBLISHER) > 0 }, @@ -414,12 +458,11 @@ }).modal() } } - this.session.onDismiss = connId => { this.dismissParticipant(connId) } - - if (this.isRoomOwner()) { - this.session.onJoinRequest = data => { this.joinRequest(data) } - } + this.session.onSessionDataUpdate = data => { this.updateSession(data) } + this.session.onConnectionChange = (connId, data) => { this.updateParticipant(connId, data) } + this.session.onJoinRequest = data => { this.joinRequest(data) } + this.session.onMediaSetup = () => { this.setupMedia() } this.meet.joinRoom(this.session) }, @@ -437,7 +480,7 @@ } }, makePicture() { - const video = $("#setup-preview video")[0]; + const video = $("#meet-setup video")[0]; // Skip if video is not "playing" if (!video.videoWidth || !this.camera) { @@ -494,10 +537,21 @@ button.prop('disabled', disabled) } }, + setupMedia() { + let dialog = $('#media-setup-dialog') + + if (!dialog.find('video').length) { + $('#meet-setup').find('video,div.volume').appendTo(dialog.find('.media-setup-preview')) + } + + dialog.on('show.bs.modal', () => { this.meet.setupStart() }) + .on('hide.bs.modal', () => { this.meet.setupStop() }) + .modal() + }, setupSession() { - this.meet.setup({ - videoElement: $('#setup-preview video')[0], - volumeElement: $('#setup-preview .volume')[0], + this.meet.setupStart({ + videoElement: $('#meet-setup video')[0], + volumeElement: $('#meet-setup .volume')[0], onSuccess: setup => { this.setup = setup this.microphone = setup.audioSource @@ -570,14 +624,43 @@ if (!enabled) { // TODO: This might need to be a different route. E.g. the room password might have // changed since user joined the session + // Also because it creates a redundant connection (token) axios.post('/api/v4/openvidu/rooms/' + this.room, this.post, { ignoreErrors: true }) .then(response => { // Response data contains: session, token and shareToken - this.session.shareToken = response.data.token + this.session.shareToken = response.data.shareToken this.meet.updateSession(this.session) }) } }) + }, + updateParticipant(connId, params) { + if (this.isModerator()) { + axios.put('/api/v4/openvidu/rooms/' + this.room + '/connections/' + connId, params) + } + }, + updateSession(data) { + let params = {} + + if ('role' in data) { + params.role = data.role + } + + // merge new params into the object + this.session = Object.assign({}, this.session, params) + + // update some buttons state e.g. when switching from publisher to subscriber + if (!this.isPublisher()) { + this.setMenuItem('audio', false) + this.setMenuItem('video', false) + } else { + if ('videoActive' in data) { + this.setMenuItem('video', data.videoActive) + } + if ('audioActive' in data) { + this.setMenuItem('audio', data.audioActive) + } + } } } } diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -86,6 +86,7 @@ Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); + Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } diff --git a/src/tests/Browser/Meet/RoomControlsTest.php b/src/tests/Browser/Meet/RoomControlsTest.php --- a/src/tests/Browser/Meet/RoomControlsTest.php +++ b/src/tests/Browser/Meet/RoomControlsTest.php @@ -178,7 +178,6 @@ // Test nickname change propagation - // Use script() because type() does not work with this contenteditable widget $guest->setNickname('div.meet-video.self', 'guest'); $owner->waitFor('div.meet-video:not(.self) .meet-nickname') ->assertSeeIn('div.meet-video:not(.self) .meet-nickname', 'guest'); diff --git a/src/tests/Browser/Meet/RoomSetupTest.php b/src/tests/Browser/Meet/RoomSetupTest.php --- a/src/tests/Browser/Meet/RoomSetupTest.php +++ b/src/tests/Browser/Meet/RoomSetupTest.php @@ -234,6 +234,7 @@ ->assertVisible('.meet-nickname') ->assertVisible('.controls button.link-fullscreen') ->assertVisible('.controls button.link-audio') + ->assertMissing('.controls button.link-setup') ->assertVisible('.status .status-audio') ->assertMissing('.status .status-video'); }) @@ -370,4 +371,213 @@ $browser->waitUntilMissing('@session .meet-subscriber:not(.self)'); }); } + + /** + * Test demoting publisher to a subscriber + * + * @group openvidu + * @depends testSubscribers + */ + public function testDemoteToSubscriber(): void + { + $this->assignMeetEntitlement('john@kolab.org'); + + $this->browse(function (Browser $browser, Browser $guest1, Browser $guest2) { + // Join the room as the owner + $browser->visit(new RoomPage('john')) + ->waitFor('@setup-form') + ->waitUntilMissing('@setup-status-message.loading') + ->waitFor('@setup-status-message') + ->type('@setup-nickname-input', 'john') + ->clickWhenEnabled('@setup-button') + ->waitFor('@session') + ->assertMissing('@setup-form') + ->waitFor('@session video'); + + // In one browser window act as a guest + $guest1->visit(new RoomPage('john')) + ->waitUntilMissing('@setup-status-message', 10) + ->assertSeeIn('@setup-button', "JOIN") + ->clickWhenEnabled('@setup-button') + ->waitFor('@session') + ->assertMissing('@setup-form') + ->waitFor('div.meet-video.self') + ->waitFor('div.meet-video:not(.self)') + ->assertElementsCount('@session div.meet-video', 2) + ->assertElementsCount('@session video', 2) + ->assertElementsCount('@session div.meet-subscriber', 0) + // assert there's no moderator-related features for this guess available + ->click('@session .meet-video.self .meet-nickname') + ->whenAvailable('@session .meet-video.self .dropdown-menu', function (Browser $browser) { + $browser->assertMissing('.permissions'); + }) + ->click('@session .meet-video:not(.self) .meet-nickname') + ->pause(50) + ->assertMissing('.dropdown-menu'); + + // Demote the guest to a subscriber + $browser + ->waitFor('div.meet-video.self') + ->waitFor('div.meet-video:not(.self)') + ->assertElementsCount('@session div.meet-video', 2) + ->assertElementsCount('@session video', 2) + ->assertElementsCount('@session .meet-subscriber', 0) + ->click('@session .meet-video:not(.self) .meet-nickname') + ->whenAvailable('@session .meet-video:not(.self) .dropdown-menu', function (Browser $browser) { + $browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing') + ->click('.action-role-publisher') + ->waitUntilMissing('.dropdown-menu'); + }) + ->waitUntilMissing('@session .meet-video:not(.self)') + ->waitFor('@session div.meet-subscriber') + ->assertElementsCount('@session div.meet-video', 1) + ->assertElementsCount('@session video', 1) + ->assertElementsCount('@session div.meet-subscriber', 1); + + $guest1 + ->waitUntilMissing('@session .meet-video.self') + ->waitFor('@session div.meet-subscriber') + ->assertElementsCount('@session div.meet-video', 1) + ->assertElementsCount('@session video', 1) + ->assertElementsCount('@session div.meet-subscriber', 1); + + // Join as another user to make sure the role change is propagated to new connections + $guest2->visit(new RoomPage('john')) + ->waitUntilMissing('@setup-status-message', 10) + ->assertSeeIn('@setup-button', "JOIN") + ->select('@setup-mic-select', '') + ->select('@setup-cam-select', '') + ->clickWhenEnabled('@setup-button') + ->waitFor('@session') + ->assertMissing('@setup-form') + ->waitFor('div.meet-subscriber:not(.self)') + ->assertElementsCount('@session div.meet-video', 1) + ->assertElementsCount('@session video', 1) + ->assertElementsCount('@session div.meet-subscriber', 2) + ->click('@toolbar .link-logout'); + + // Promote the guest back to a publisher + $browser + ->click('@session .meet-subscriber .meet-nickname') + ->whenAvailable('@session .meet-subscriber .dropdown-menu', function (Browser $browser) { + $browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing') + ->assertNotChecked('.action-role-publisher input') + ->click('.action-role-publisher') + ->waitUntilMissing('.dropdown-menu'); + }) + ->waitFor('@session .meet-video:not(.self) video') + ->assertElementsCount('@session div.meet-video', 2) + ->assertElementsCount('@session video', 2) + ->assertElementsCount('@session div.meet-subscriber', 0); + + $guest1 + ->waitFor('@session .meet-video.self') + ->assertElementsCount('@session div.meet-video', 2) + ->assertElementsCount('@session video', 2) + ->assertElementsCount('@session div.meet-subscriber', 0); + + // Demote the owner to a subscriber + $browser + ->click('@session .meet-video.self .meet-nickname') + ->whenAvailable('@session .meet-video.self .dropdown-menu', function (Browser $browser) { + $browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing') + ->assertChecked('.action-role-publisher input') + ->click('.action-role-publisher') + ->waitUntilMissing('.dropdown-menu'); + }) + ->waitUntilMissing('@session .meet-video.self') + ->waitFor('@session div.meet-subscriber.self') + ->assertElementsCount('@session div.meet-video', 1) + ->assertElementsCount('@session video', 1) + ->assertElementsCount('@session div.meet-subscriber', 1); + + // Promote the owner to a publisher + $browser + ->click('@session .meet-subscriber.self .meet-nickname') + ->whenAvailable('@session .meet-subscriber.self .dropdown-menu', function (Browser $browser) { + $browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing') + ->assertNotChecked('.action-role-publisher input') + ->click('.action-role-publisher') + ->waitUntilMissing('.dropdown-menu'); + }) + ->waitUntilMissing('@session .meet-subscriber.self') + ->waitFor('@session div.meet-video.self') + ->assertElementsCount('@session div.meet-video', 2) + ->assertElementsCount('@session video', 2) + ->assertElementsCount('@session div.meet-subscriber', 0); + }); + } + + /** + * Test the media setup dialog + * + * @group openvidu + * @depends testDemoteToSubscriber + */ + public function testMediaSetupDialog(): void + { + // Make sure there's no session yet + $room = Room::where('name', 'john')->first(); + if ($room->session_id) { + $room->session_id = null; + $room->save(); + } + + $this->assignMeetEntitlement('john@kolab.org'); + + $this->browse(function (Browser $browser, $guest) { + // Join the room as the owner + $browser->visit(new RoomPage('john')) + ->waitFor('@setup-form') + ->waitUntilMissing('@setup-status-message.loading') + ->waitFor('@setup-status-message') + ->type('@setup-nickname-input', 'john') + ->clickWhenEnabled('@setup-button') + ->waitFor('@session') + ->assertMissing('@setup-form'); + + // In one browser window act as a guest + $guest->visit(new RoomPage('john')) + ->waitUntilMissing('@setup-status-message', 10) + ->assertSeeIn('@setup-button', "JOIN") + ->select('@setup-mic-select', '') + ->select('@setup-cam-select', '') + ->clickWhenEnabled('@setup-button') + ->waitFor('@session') + ->assertMissing('@setup-form'); + + $browser->waitFor('@session video') + ->click('.controls button.link-setup') + ->with(new Dialog('#media-setup-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Media setup') + ->assertVisible('form video') + ->assertVisible('form > div:nth-child(1) video') + ->assertVisible('form > div:nth-child(1) .volume') + ->assertVisible('form > div:nth-child(2) svg') + ->assertAttribute('form > div:nth-child(2) .input-group-text', 'title', 'Microphone') + ->assertVisible('form > div:nth-child(2) select') + ->assertVisible('form > div:nth-child(3) svg') + ->assertAttribute('form > div:nth-child(3) .input-group-text', 'title', 'Camera') + ->assertVisible('form > div:nth-child(3) select') + ->assertMissing('@button-cancel') + ->assertSeeIn('@button-action', 'Close') + ->click('@button-action'); + }) + ->assertMissing('#media-setup-dialog') + // Test mute audio and video + ->click('.controls button.link-setup') + ->with(new Dialog('#media-setup-dialog'), function (Browser $browser) { + $browser->select('form > div:nth-child(2) select', '') + ->select('form > div:nth-child(3) select', '') + ->click('@button-action'); + }) + ->assertMissing('#media-setup-dialog') + ->assertVisible('@session .meet-video .status .status-audio') + ->assertVisible('@session .meet-video .status .status-video'); + + $guest->waitFor('@session video') + ->assertVisible('@session .meet-video .status .status-audio') + ->assertVisible('@session .meet-video .status .status-video'); + }); + } } diff --git a/src/tests/Browser/Meet/RoomsTest.php b/src/tests/Browser/Meet/RoomsTest.php --- a/src/tests/Browser/Meet/RoomsTest.php +++ b/src/tests/Browser/Meet/RoomsTest.php @@ -72,7 +72,7 @@ // Goto user subscriptions, and enable 'meet' subscription $browser->visit('/user/' . $john->id) ->on(new UserInfo()) - ->with('@skus', function ($browser) { + ->whenAvailable('@skus', function ($browser) { $browser->click('#sku-input-meet'); }) ->click('button[type=submit]') diff --git a/src/tests/Browser/Pages/Meet/Room.php b/src/tests/Browser/Pages/Meet/Room.php --- a/src/tests/Browser/Pages/Meet/Room.php +++ b/src/tests/Browser/Pages/Meet/Room.php @@ -64,9 +64,9 @@ '@setup-cam-select' => '#setup-camera', '@setup-nickname-input' => '#setup-nickname', '@setup-password-input' => '#setup-password', - '@setup-preview' => '#setup-preview', - '@setup-volume' => '#setup-preview .volume', - '@setup-video' => '#setup-preview video', + '@setup-preview' => '#meet-setup .media-setup-preview', + '@setup-volume' => '#meet-setup .media-setup-preview .volume', + '@setup-video' => '#meet-setup .media-setup-preview video', '@setup-status-message' => '#meet-setup div.status-message', '@setup-button' => '#join-button', @@ -161,14 +161,19 @@ */ public function setNickname($browser, $selector, $nickname): void { - // Use script() because type() does not work with this contenteditable widget - $selector = $selector . ' .meet-nickname .content'; - $browser->script( - "var element = document.querySelector('$selector');" - . "element.focus();" - . "element.innerText = '$nickname';" - . "element.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 27 }))" - ); + $element = "$selector .meet-nickname .content"; + + $browser->click("$selector .meet-nickname") + ->waitFor("$selector .dropdown-menu") + ->assertSeeIn("$selector .dropdown-menu > .action-nickname", 'Nickname') + ->click("$selector .dropdown-menu > .action-nickname") + ->waitUntilMissing('.dropdown-menu') + // Use script() because type() does not work with this contenteditable widget + ->script( + "var element = document.querySelector('$element');" + . "element.innerText = '$nickname';" + . "element.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 27 }))" + ); } /** diff --git a/src/tests/Feature/Controller/OpenViduTest.php b/src/tests/Feature/Controller/OpenViduTest.php --- a/src/tests/Feature/Controller/OpenViduTest.php +++ b/src/tests/Feature/Controller/OpenViduTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Controller; use App\Http\Controllers\API\V4\OpenViduController; +use App\OpenVidu\Connection; use App\OpenVidu\Room; use Tests\TestCase; @@ -554,4 +555,66 @@ $room->refresh(); $this->assertSame(null, $room->getSetting('password')); } + + /** + * Test updating a participant (connection) + * + * @group openvidu + */ + public function testUpdateConnection(): void + { + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $room = Room::where('name', 'john')->first(); + $room->session_id = null; + $room->save(); + + $this->assignMeetEntitlement($john); + + // First we create the session + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]); + $response->assertStatus(200); + + $json = $response->json(); + + // And the other user connection + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]); + $response->assertStatus(200); + + $json = $response->json(); + + $conn_id = $json['connectionId']; + $room->refresh(); + $conn_data = $room->getOVConnection($conn_id); + + $this->assertSame($conn_id, $conn_data['connectionId']); + + // Non-existing room name + $response = $this->actingAs($john)->put("api/v4/openvidu/rooms/non-existing/connections/{$conn_id}", []); + $response->assertStatus(404); + + // Non-existing connection + $response = $this->actingAs($john)->put("api/v4/openvidu/rooms/{$room->name}/connections/123", []); + $response->assertStatus(404); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('error', $json['status']); + $this->assertSame('The connection does not exist.', $json['message']); + + // Non-owner access + $response = $this->actingAs($jack)->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", []); + $response->assertStatus(403); + + // Expected success + $post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_MODERATOR]; + $response = $this->actingAs($john)->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame($post['role'], Connection::find($conn_id)->role); + } }