Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117752536
D2149.1775189892.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
64 KB
Referenced Files
None
Subscribers
None
D2149.1775189892.diff
View Options
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 & 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">×</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
Details
Attached
Mime Type
text/plain
Expires
Fri, Apr 3, 4:18 AM (12 h, 32 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18822552
Default Alt Text
D2149.1775189892.diff (64 KB)
Attached To
Mode
D2149: Publisher-to-subscriber and vice-versa
Attached
Detach File
Event Timeline