Page MenuHomePhorge

D2245.1775206875.diff
No OneTemporary

Authored By
Unknown
Size
26 KB
Referenced Files
None
Subscribers
None

D2245.1775206875.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
@@ -429,6 +429,22 @@
break;
+ case 'language':
+ // Only the moderator can do it
+ if (!$this->isModerator($connection->room)) {
+ return $this->errorResponse(403);
+ }
+
+ if ($value) {
+ if (preg_match('/^[a-z]{2}$/', $value)) {
+ $connection->metadata = ['language' => $value] + $connection->metadata;
+ }
+ } else {
+ $connection->metadata = array_diff_key($connection->metadata, ['language' => 0]);
+ }
+
+ break;
+
case 'role':
// Only the moderator can do it
if (!$this->isModerator($connection->room)) {
@@ -452,6 +468,11 @@
$connection->metadata = array_diff_key($connection->metadata, ['hand' => 0]);
}
+ // Non-publisher cannot be a language interpreter
+ if (!($value & Room::ROLE_PUBLISHER)) {
+ $connection->metadata = array_diff_key($connection->metadata, ['language' => 0]);
+ }
+
$connection->{$key} = $value;
break;
}
diff --git a/src/app/Observers/OpenVidu/ConnectionObserver.php b/src/app/Observers/OpenVidu/ConnectionObserver.php
--- a/src/app/Observers/OpenVidu/ConnectionObserver.php
+++ b/src/app/Observers/OpenVidu/ConnectionObserver.php
@@ -26,12 +26,19 @@
// participant browser to do this.
}
- // Rised hand state change
- $newState = $connection->metadata['hand'] ?? null;
- $oldState = $this->getOriginal($connection, 'metadata')['hand'] ?? null;
+ // Detect metadata changes for specified properties
+ $keys = [
+ 'hand' => 'bool',
+ 'language' => '',
+ ];
- if ($newState !== $oldState) {
- $params['hand'] = !empty($newState);
+ foreach ($keys as $key => $type) {
+ $newState = $connection->metadata[$key] ?? null;
+ $oldState = $this->getOriginal($connection, 'metadata')[$key] ?? null;
+
+ if ($newState !== $oldState) {
+ $params[$key] = $type == 'bool' ? !empty($newState) : $newState;
+ }
}
// Send the signal to all participants
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
@@ -194,6 +194,7 @@
return [
'role' => $item->role,
'hand' => $item->metadata['hand'] ?? 0,
+ 'language' => $item->metadata['language'] ?? null,
];
})
// Sort by order in the queue, so UI can re-build the existing queue in order
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
@@ -62,6 +62,7 @@
this.setupSetAudioDevice = setupSetAudioDevice
this.setupSetVideoDevice = setupSetVideoDevice
this.switchAudio = switchAudio
+ this.switchChannel = switchChannel
this.switchScreen = switchScreen
this.switchVideo = switchVideo
this.updateSession = updateSession
@@ -91,6 +92,8 @@
* nickname - Participant name,
* role - connection (participant) role(s),
* connections - Optional metadata for other users connections (current state),
+ * channel - Selected interpreted language channel (two-letter language code)
+ * languages - Supported languages (code-to-label map)
* chatElement - DOM element for the chat widget,
* menuElement - DOM element of the room toolbar,
* queueElement - DOM element for the Q&A queue (users with a raised hand)
@@ -117,6 +120,9 @@
subscribersContainer = $('<div id="meet-subscribers">').appendTo(container).get(0)
}
+ // TODO: Make sure all supported callbacks exist, so we don't have to check
+ // their existence everywhere anymore
+
sessionData = data
// Init a session
@@ -201,6 +207,8 @@
if (session.connection.connectionId == connectionId) {
metadata = sessionData
+ metadata.audioActive = audioActive
+ metadata.videoActive = videoActive
}
if (metadata) {
@@ -259,11 +267,19 @@
// Here we expect connections in a proper queue order
Object.keys(data.connections || {}).forEach(key => {
let conn = data.connections[key]
+
if (conn.hand) {
conn.connectionId = key
connectionHandUp(conn)
}
})
+
+ sessionData.channels = getChannels(data.connections)
+
+ // Inform the vue component, so it can update some UI controls
+ if (sessionData.channels.length && sessionData.onSessionDataUpdate) {
+ sessionData.onSessionDataUpdate(sessionData)
+ }
})
.catch(error => {
console.error('There was an error connecting to the session: ', error.message);
@@ -709,6 +725,18 @@
}
}
+ /**
+ * Switch interpreted language channel
+ *
+ * @param channel Two-letter language code
+ */
+ function switchChannel(channel) {
+ sessionData.channel = channel
+
+ // Mute/unmute all connections depending on the selected channel
+ participantUpdateAll()
+ }
+
/**
* Mute/Unmute audio for current session publisher
*/
@@ -785,7 +813,7 @@
*/
function connectionUpdate(data) {
let conn = connections[data.connectionId]
-
+ let refresh = false
let handUpdate = conn => {
if ('hand' in data && data.hand != conn.hand) {
if (data.hand) {
@@ -803,13 +831,6 @@
const isPublisher = sessionData.role & Roles.PUBLISHER
const isModerator = sessionData.role & Roles.MODERATOR
- // 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)
@@ -823,20 +844,15 @@
handUpdate(sessionData)
// merge the changed data into internal session metadata object
- Object.keys(data).forEach(key => { sessionData[key] = data[key] })
+ sessionData = Object.assign({}, sessionData, data, { audioActive, videoActive })
// update the participant element
sessionData.element = participantUpdate(sessionData.element, sessionData)
// promoted/demoted to/from a moderator
if ('role' in data) {
- if ((!isModerator && roleModerator) || (isModerator && !roleModerator)) {
- // Update all participants, to enable/disable the popup menu
- Object.keys(connections).forEach(key => {
- const conn = connections[key]
- participantUpdate(conn.element, conn)
- })
- }
+ // Update all participants, to enable/disable the popup menu
+ refresh = (!isModerator && roleModerator) || (isModerator && !roleModerator)
}
// Inform the vue component, so it can update some UI controls
@@ -846,9 +862,12 @@
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()
+ sessionData.audioActive = publisher.stream.audioActive
+ sessionData.videoActive = publisher.stream.videoActive
+
+ if (sessionData.onSessionDataUpdate) {
+ sessionData.onSessionDataUpdate(sessionData)
+ }
})
// Open the media setup dialog
@@ -873,6 +892,24 @@
conn.element = participantUpdate(conn.element, conn)
}
+
+ // Update channels list
+ sessionData.channels = getChannels(connections)
+
+ // The channel user was using has been removed (or rather the participant stopped being an interpreter)
+ if (sessionData.channel && !sessionData.channels.includes(sessionData.channel)) {
+ sessionData.channel = null
+ refresh = true
+ }
+
+ if (refresh) {
+ participantUpdateAll()
+ }
+
+ // Inform the vue component, so it can update some UI controls
+ if (sessionData.onSessionDataUpdate) {
+ sessionData.onSessionDataUpdate(sessionData)
+ }
}
/**
@@ -928,19 +965,22 @@
* parameter it will be a video element wrapper inside the matrix or a simple
* tag-like element on the subscribers list.
*
- * @param params Connection metadata/params
+ * @param params Connection metadata/params
+ * @param content Optional content to prepend to the element
*
* @return The element
*/
- function participantCreate(params) {
+ function participantCreate(params, content) {
let element
params.isSelf = params.isSelf || session.connection.connectionId == params.connectionId
- if (params.role & Roles.PUBLISHER || params.role & Roles.SCREEN) {
- element = publisherCreate(params)
+ if ((!params.language && params.role & Roles.PUBLISHER) || params.role & Roles.SCREEN) {
+ // publishers and shared screens
+ element = publisherCreate(params, content)
} else {
- element = subscriberCreate(params)
+ // subscribers and language interpreters
+ element = subscriberCreate(params, content)
}
setTimeout(resize, 50);
@@ -951,9 +991,10 @@
/**
* Create a <video> element wrapper with controls
*
- * @param params Connection metadata/params
+ * @param params Connection metadata/params
+ * @param content Optional content to prepend to the element
*/
- function publisherCreate(params) {
+ function publisherCreate(params, content) {
// Create the element
let wrapper = $(
'<div class="meet-video">'
@@ -974,6 +1015,10 @@
// Append the nickname widget
wrapper.find('.controls').before(nicknameWidget(params))
+ if (content) {
+ wrapper.prepend(content)
+ }
+
if (params.isSelf) {
if (sessionData.onMediaSetup) {
wrapper.find('.link-setup').removeClass('hidden')
@@ -989,7 +1034,7 @@
})
}
- participantUpdate(wrapper, params)
+ participantUpdate(wrapper, params, true)
// Fullscreen control
if (document.fullscreenEnabled) {
@@ -1020,12 +1065,12 @@
}
/**
- * Update the <video> wrapper controls
+ * Update the publisher/subscriber element controls
*
* @param wrapper The wrapper element
* @param params Connection metadata/params
*/
- function participantUpdate(wrapper, params) {
+ function participantUpdate(wrapper, params, noupdate) {
const element = $(wrapper)
const isModerator = sessionData.role & Roles.MODERATOR
const isSelf = session.connection.connectionId == params.connectionId
@@ -1033,23 +1078,61 @@
const roleModerator = params.role & Roles.MODERATOR
const roleScreen = params.role & Roles.SCREEN
const roleOwner = params.role & Roles.OWNER
+ const roleInterpreter = rolePublisher && !!params.language
- // Handle publisher-to-subscriber and subscriber-to-publisher change
- if (!roleScreen) {
+ if (!noupdate && !roleScreen) {
const isPublisher = element.is('.meet-video')
- if ((rolePublisher && !isPublisher) || (!rolePublisher && isPublisher)) {
+ // Publisher-to-interpreter or vice-versa, move element to the subscribers list or vice-versa,
+ // but keep the existing video element
+ if (
+ !isSelf
+ && element.find('video').length
+ && ((roleInterpreter && isPublisher) || (!roleInterpreter && !isPublisher && rolePublisher))
+ ) {
+ wrapper = participantCreate(params, element.find('video'))
+ element.remove()
+ return wrapper
+ }
+
+ // Handle publisher-to-subscriber and subscriber-to-publisher change
+ if (
+ !roleInterpreter
+ && (rolePublisher && !isPublisher) || (!rolePublisher && isPublisher)
+ ) {
element.remove()
return participantCreate(params)
}
}
- if ('audioActive' in params) {
+ let muted = false
+ let video = element.find('video')[0]
+
+ // When a channel is selected - mute everyone except the interpreter of the language.
+ // When a channel is not selected - mute language interpreters only
+ if (sessionData.channel) {
+ muted = !(roleInterpreter && params.language == sessionData.channel)
+ } else {
+ muted = roleInterpreter
+ }
+
+ if (muted && !isSelf) {
+ element.find('.status-audio').removeClass('hidden')
+ element.find('.link-audio').addClass('hidden')
+ } else {
element.find('.status-audio')[params.audioActive ? 'addClass' : 'removeClass']('hidden')
+
+ if (!isSelf) {
+ element.find('.link-audio').removeClass('hidden')
+ }
+
+ muted = !params.audioActive || isSelf
}
- if ('videoActive' in params) {
- element.find('.status-video')[params.videoActive ? 'addClass' : 'removeClass']('hidden')
+ element.find('.status-video')[params.videoActive ? 'addClass' : 'removeClass']('hidden')
+
+ if (video) {
+ video.muted = muted
}
if ('nickname' in params) {
@@ -1067,11 +1150,14 @@
const withPerm = isModerator && !roleScreen && !(roleOwner && !isSelf);
const withMenu = isSelf || (isModerator && !roleOwner)
+ // TODO: This probably could be better done with css
let elements = {
'.dropdown-menu': withMenu,
'.permissions': withPerm,
+ '.interpreting': withPerm && rolePublisher,
'svg.moderator': roleModerator,
- 'svg.user': !roleModerator
+ 'svg.user': !roleModerator && !roleInterpreter,
+ 'svg.interpreter': !roleModerator && roleInterpreter
}
Object.keys(elements).forEach(key => {
@@ -1082,19 +1168,36 @@
element.find('.action-role-moderator input').prop('checked', roleModerator)
.prop('disabled', roleOwner)
+ element.find('.interpreting select').val(roleInterpreter ? params.language : '')
+
return wrapper
}
+ /**
+ * Update/refresh state of all participants' elements
+ */
+ function participantUpdateAll() {
+ Object.keys(connections).forEach(key => {
+ const conn = connections[key]
+ participantUpdate(conn.element, conn)
+ })
+ }
+
/**
* Create a tag-like element for a subscriber participant
*
- * @param params Connection metadata/params
+ * @param params Connection metadata/params
+ * @param content Optional content to prepend to the element
*/
- function subscriberCreate(params) {
+ function subscriberCreate(params, content) {
// Create the element
let wrapper = $('<div class="meet-subscriber">').append(nicknameWidget(params))
- participantUpdate(wrapper, params)
+ if (content) {
+ wrapper.prepend(content)
+ }
+
+ participantUpdate(wrapper, params, true)
return wrapper[params.isSelf ? 'prependTo' : 'appendTo'](subscribersContainer)
.attr('id', 'subscriber-' + params.connectionId)
@@ -1107,6 +1210,13 @@
* @param object params Connection metadata/params
*/
function nicknameWidget(params) {
+ let languages = []
+
+ // Append languages selection options
+ Object.keys(sessionData.languages).forEach(code => {
+ languages.push(`<option value="${code}">${sessionData.languages[code]}</option>`)
+ })
+
// Create the element
let element = $(
'<div class="dropdown">'
@@ -1115,6 +1225,7 @@
+ '<span class="icon">'
+ svgIcon('user', null, 'user')
+ svgIcon('crown', null, 'moderator hidden')
+ + svgIcon('headphones', null, 'interpreter hidden')
+ '</span>'
+ '</a>'
+ '<div class="dropdown-menu">'
@@ -1132,6 +1243,14 @@
+ ' <span class="custom-control-label">Moderation</span>'
+ '</label>'
+ '</div>'
+ + '<div class="dropdown-divider interpreting"></div>'
+ + '<div class="interpreting">'
+ + '<h6 class="dropdown-header">Language interpreter</h6>'
+ + '<div class="ml-4 mr-4"><select class="custom-select">'
+ + '<option value="">- none -</option>'
+ + languages.join('')
+ + '</select></div>'
+ + '</div>'
+ '</div>'
+ '</div>'
)
@@ -1218,6 +1337,17 @@
sessionData.onConnectionChange(params.connectionId, { role })
})
+
+ element.find('.interpreting select')
+ .on('change', e => {
+ const language = $(e.target).val()
+ sessionData.onConnectionChange(params.connectionId, { language })
+ element.find('.meet-nickname').dropdown('hide')
+ })
+ .on('click', e => {
+ // Prevents from closing the dropdown menu on click
+ e.stopPropagation()
+ })
}
return element.get(0)
@@ -1412,7 +1542,7 @@
/**
* A way to update some session data, after you joined the room
*
- * @param data Same input as for joinRoom(), but for now it supports only shareToken
+ * @param data Same input as for joinRoom()
*/
function updateSession(data) {
sessionData.shareToken = data.shareToken
@@ -1467,6 +1597,26 @@
return data
}
+
+ /**
+ * Get all existing language interpretation channels
+ */
+ function getChannels(connections) {
+ let channels = []
+
+ Object.keys(connections || {}).forEach(key => {
+ let conn = connections[key]
+
+ if (
+ conn.language
+ && !channels.includes(conn.language)
+ ) {
+ channels.push(conn.language)
+ }
+ })
+
+ return channels
+ }
}
export { Meet, Roles }
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
@@ -230,6 +230,12 @@
&:not(:only-child) {
max-height: 30%;
}
+
+ // Language interpreters will be displayed as subscribers, but will have still
+ // the video element that we will hide
+ video {
+ display: none;
+ }
}
#meet-chat {
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
@@ -14,6 +14,18 @@
<button class="btn btn-link link-hand text-danger" v-if="!isPublisher()" @click="switchHand" title="Raise hand">
<svg-icon icon="hand-paper"></svg-icon>
</button>
+ <span id="channel-select" :style="'display:' + (channels.length ? '' : 'none')" class="dropdown">
+ <button class="btn btn-link link-channel" title="Interpreted language channel" aria-haspopup="true" aria-expanded="false" data-toggle="dropdown">
+ <svg-icon icon="headphones"></svg-icon>
+ <span class="badge badge-danger" v-if="session.channel">{{ session.channel.toUpperCase() }}</span>
+ </button>
+ <div class="dropdown-menu">
+ <a :class="'dropdown-item' + (!session.channel ? ' active' : '')" href="#" data-code="" @click="switchChannel">- none -</a>
+ <a v-for="code in channels" :key="code" href="#" @click="switchChannel" :data-code="code"
+ :class="'dropdown-item' + (session.channel == code ? ' active' : '')"
+ >{{ languages[code] }}</a>
+ </div>
+ </span>
<button class="btn btn-link link-chat text-danger" @click="switchChat" title="Chat">
<svg-icon icon="align-left"></svg-icon>
</button>
@@ -184,6 +196,7 @@
faDesktop,
faExpand,
faHandPaper,
+ faHeadphones,
faMicrophone,
faMicrophoneAlt,
faPowerOff,
@@ -202,6 +215,7 @@
faDesktop,
faExpand,
faHandPaper,
+ faHeadphones,
faMicrophone,
faMicrophoneAlt,
faPowerOff,
@@ -228,6 +242,13 @@
},
canShareScreen: false,
camera: '',
+ channels: [],
+ languages: {
+ en: 'English',
+ de: 'German',
+ fr: 'French',
+ it: 'Italian'
+ },
meet: null,
microphone: '',
nickname: '',
@@ -467,6 +488,7 @@
clearTimeout(roomRequest)
this.session.nickname = this.nickname
+ this.session.languages = this.languages
this.session.menuElement = $('#meet-session-menu')[0]
this.session.chatElement = $('#meet-chat')[0]
this.session.queueElement = $('#meet-queue')[0]
@@ -612,6 +634,12 @@
this.setMenuItem('audio', enabled)
})
},
+ switchChannel(e) {
+ let channel = $(e.target).data('code')
+
+ this.$set(this.session, 'channel', channel)
+ this.meet.switchChannel(channel)
+ },
switchChat() {
let chat = $('#meet-chat')
let enabled = chat.is('.open')
@@ -691,31 +719,14 @@
})
},
updateSession(data) {
- let params = {}
-
- if ('role' in data) {
- params.role = data.role
- }
+ this.session = data
+ this.channels = data.channels || []
- // merge new params into the object
- this.session = Object.assign({}, this.session, params)
+ const isPublisher = this.isPublisher()
- if ('hand' in data) {
- this.setMenuItem('hand', data.hand)
- }
-
- // 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)
- }
- }
+ this.setMenuItem('video', isPublisher ? data.videoActive : false)
+ this.setMenuItem('audio', isPublisher ? data.audioActive : false)
+ this.setMenuItem('hand', data.hand)
}
}
}
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
@@ -252,7 +252,7 @@
->assertSeeIn('@chat-list .message div:last-child', 'test1');
$guest->waitFor('@menu button.link-chat .badge')
- ->assertSeeIn('@menu button.link-chat .badge', '1')
+ ->assertTextRegExp('@menu button.link-chat .badge', '/^1$/')
->click('@menu button.link-chat')
->assertToolbarButtonState('chat', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
->assertMissing('@menu button.link-chat .badge')
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
@@ -12,6 +12,10 @@
public const BUTTON_INACTIVE = 4;
public const BUTTON_DISABLED = 8;
+ public const ICO_MODERATOR = 'moderator';
+ public const ICO_USER = 'user';
+ public const ICO_INTERPRETER = 'interpreter';
+
protected $roomName;
/**
@@ -153,6 +157,19 @@
Assert::assertSame((bool) $result[0], $state);
}
+ /**
+ * Assert the participant icon type
+ *
+ * @param \Tests\Browser $browser The browser object
+ * @param string $selector Element selector
+ * @param string $type Participant icon type
+ */
+ public function assertUserIcon($browser, $selector, $type): void
+ {
+ $browser->assertVisible("{$selector} svg.{$type}")
+ ->assertMissing("{$selector} svg:not(.{$type})");
+ }
+
/**
* Set the nickname for the participant
*
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
@@ -743,6 +743,8 @@
->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
$response->assertStatus(403);
+
+ // TODO: Test updating 'language' and 'hand' properties
}
/**

File Metadata

Mime Type
text/plain
Expires
Fri, Apr 3, 9:01 AM (19 h, 9 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18819968
Default Alt Text
D2245.1775206875.diff (26 KB)

Event Timeline