Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117736558
D2245.1775150353.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
26 KB
Referenced Files
None
Subscribers
None
D2245.1775150353.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
@@ -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
Details
Attached
Mime Type
text/plain
Expires
Thu, Apr 2, 5:19 PM (6 h, 18 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18819968
Default Alt Text
D2245.1775150353.diff (26 KB)
Attached To
Mode
D2245: Meet: Language interpreters
Attached
Detach File
Event Timeline