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 @@ -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, + * chatElement, menuElement, onDestroy, onJoinRequest, onDismiss, onConnectionChange, + * onSessionDataUpdate, onMediaSetup) */ 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); @@ -251,8 +256,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) @@ -301,6 +314,13 @@ }) } + /** + * Stop the setup "process", cleanup after it. + */ + function setupStop() { + volumeMeterStop() + } + /** * Change the publisher audio device * @@ -317,9 +337,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 +346,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 +380,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 +389,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 @@ -395,6 +409,53 @@ 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 */ @@ -433,10 +494,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 +511,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 +615,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 +626,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 +646,6 @@ try { publisher.publishAudio(!audioActive) audioActive = !audioActive - participantUpdate(sessionData.wrapper, { audioActive }) - signalUserUpdate() } catch (e) { console.error(e) } @@ -602,8 +666,6 @@ try { publisher.publishVideo(!videoActive) videoActive = !videoActive - participantUpdate(sessionData.wrapper, { videoActive }) - signalUserUpdate() } catch (e) { console.error(e) } @@ -640,6 +702,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 +788,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 +814,7 @@ '