Page MenuHomePhorge

D2149.1775189892.diff
No OneTemporary

Authored By
Unknown
Size
64 KB
Referenced Files
None
Subscribers
None

D2149.1775189892.diff

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;
@@ -372,6 +369,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 +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 @@
+<?php
+
+namespace App\Observers\OpenVidu;
+
+use App\OpenVidu\Connection;
+
+class ConnectionObserver
+{
+ /**
+ * Handle the OpenVidu connection "updated" event.
+ *
+ * @param \App\OpenVidu\Connection $connection The connection.
+ *
+ * @return void
+ */
+ public function updated(Connection $connection)
+ {
+ if ($connection->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
@@ -172,6 +172,31 @@
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
*
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)
@@ -301,6 +324,13 @@
})
}
+ /**
+ * Stop the setup "process", cleanup after it.
+ */
+ function setupStop() {
+ volumeMeterStop()
+ }
+
/**
* Change the publisher audio device
*
@@ -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
@@ -395,6 +419,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 +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)
}
@@ -640,6 +712,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 +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 @@
'<div class="meet-video">'
+ svgIcon('user', 'fas', 'watermark')
+ '<div class="controls">'
+ + '<button type="button" class="btn btn-link link-setup hidden" title="Media setup">' + svgIcon('cog') + '</button>'
+ '<button type="button" class="btn btn-link link-audio hidden" title="Mute audio">' + svgIcon('volume-mute') + '</button>'
+ '<button type="button" class="btn btn-link link-fullscreen closed hidden" title="Full screen">' + svgIcon('expand') + '</button>'
+ '<button type="button" class="btn btn-link link-fullscreen open hidden" title="Full screen">' + svgIcon('compress') + '</button>'
@@ -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 = $(
'<div class="dropdown">'
- + '<a href="#" class="meet-nickname btn" title="Nickname" aria-haspopup="true" aria-expanded="false" role="button">'
+ + '<a href="#" class="meet-nickname btn" aria-haspopup="true" aria-expanded="false" role="button">'
+ '<span class="content"></span>'
+ '<span class="icon">' + svgIcon('user') + '</span>'
+ '</a>'
+ '<div class="dropdown-menu">'
+ + '<a class="dropdown-item action-nickname" href="#">Nickname</a>'
+ '<a class="dropdown-item action-dismiss" href="#">Dismiss</a>'
+ + '<div class="dropdown-divider permissions"></div>'
+ + '<div class="permissions">'
+ + '<h6 class="dropdown-header">Permissions</h6>'
+ + '<label class="dropdown-item action-role-publisher custom-control custom-switch">'
+ + '<input type="checkbox" class="custom-control-input">'
+ + ' <span class="custom-control-label">Audio &amp; Video publishing</span>'
+ + '</label>'
+ //+ '<label class="dropdown-item action-role-moderator custom-control custom-switch">'
+ // + '<input type="checkbox" class="custom-control-input">'
+ // + ' <span class="custom-control-label">Moderation</span>'
+ //+ '</label>'
+ + '</div>'
+ '</div>'
+ '</div>'
)
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 @@
<div class="card-body">
<div class="card-title">Set up your session</div>
<div class="card-text">
- <form class="setup-form row" @submit.prevent="joinSession">
- <div id="setup-preview" class="col-sm-6 mb-3 mb-sm-0">
+ <form class="media-setup-form row" @submit.prevent="joinSession">
+ <div class="media-setup-preview col-sm-6 mb-3 mb-sm-0">
<video class="rounded"></video>
<div class="volume"><div class="bar"></div></div>
</div>
@@ -118,6 +118,45 @@
</div>
</div>
+ <div id="media-setup-dialog" class="modal" tabindex="-1" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">Media setup</h5>
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <span aria-hidden="true">&times;</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <form class="media-setup-form">
+ <div class="media-setup-preview"></div>
+ <div class="input-group mt-2">
+ <label for="setup-microphone" class="input-group-prepend mb-0">
+ <span class="input-group-text" title="Microphone"><svg-icon icon="microphone"></svg-icon></span>
+ </label>
+ <select class="custom-select" id="setup-microphone" v-model="microphone" @change="setupMicrophoneChange">
+ <option value="">None</option>
+ <option v-for="mic in setup.microphones" :value="mic.deviceId" :key="mic.deviceId">{{ mic.label }}</option>
+ </select>
+ </div>
+ <div class="input-group mt-2">
+ <label for="setup-camera" class="input-group-prepend mb-0">
+ <span class="input-group-text" title="Camera"><svg-icon icon="video"></svg-icon></span>
+ </label>
+ <select class="custom-select" id="setup-camera" v-model="camera" @change="setupCameraChange">
+ <option value="">None</option>
+ <option v-for="cam in setup.cameras" :value="cam.deviceId" :key="cam.deviceId">{{ cam.label }}</option>
+ </select>
+ </div>
+ </form>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary modal-action" data-dismiss="modal">Close</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
<session-security-options v-if="session.config" :config="session.config" :room="room" @config-update="configUpdate"></session-security-options>
</div>
</template>
@@ -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);
+ }
}

File Metadata

Mime Type
text/plain
Expires
Fri, Apr 3, 4:18 AM (19 h, 14 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18822552
Default Alt Text
D2149.1775189892.diff (64 KB)

Event Timeline