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); } @@ -372,6 +366,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 * @@ -406,4 +436,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/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 @@ -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, + * chatElement, menuElement, onDestroy, onJoinRequest, onDismiss, onConnectionChange, + * onSessionDataUpdate) */ function joinRoom(data) { resize(); @@ -108,18 +108,12 @@ // 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) + metadata.element = participantCreate(metadata) - connections[connectionId] = { element } - - resize() + connections[metadata.connectionId] = metadata // Send the current user status to the connecting user // otherwise e.g. nickname might be not up to date @@ -129,9 +123,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 +131,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 +148,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 +186,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 +204,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); @@ -433,10 +438,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 +455,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 +559,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 +570,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 +590,6 @@ try { publisher.publishAudio(!audioActive) audioActive = !audioActive - participantUpdate(sessionData.wrapper, { audioActive }) - signalUserUpdate() } catch (e) { console.error(e) } @@ -602,8 +610,6 @@ try { publisher.publishVideo(!videoActive) videoActive = !videoActive - participantUpdate(sessionData.wrapper, { videoActive }) - signalUserUpdate() } catch (e) { console.error(e) } @@ -640,6 +646,65 @@ 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 * @@ -667,11 +732,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 } /** @@ -699,7 +772,7 @@ // Append the nickname widget wrapper.find('.controls').before(nicknameWidget(params)) - if (!params.self) { + if (!params.isSelf) { // Enable audio mute button wrapper.find('.link-audio').removeClass('hidden') .on('click', e => { @@ -731,12 +804,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 +819,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 (isSelf) { + element.addClass('self') } - if (params.self) { - $element.addClass('self') + if (isModerator) { + element.addClass('moderated') } - if (sessionData.role & Roles.MODERATOR) { - $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 +877,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 +891,35 @@ // Create the element let element = $( '