diff --git a/src/resources/js/meet/room.js b/src/resources/js/meet/room.js
index 57cacd5f..c702618d 100644
--- a/src/resources/js/meet/room.js
+++ b/src/resources/js/meet/room.js
@@ -1,1080 +1,1097 @@
'use strict'
import anchorme from 'anchorme'
import { Client } from './client.js'
import { Roles } from './constants.js'
import { Dropdown } from 'bootstrap'
import { library } from '@fortawesome/fontawesome-svg-core'
function Room(container)
{
let sessionData // Room session metadata
let peers = {} // Participants in the session (including self)
let publishersContainer // Container element for publishers
let subscribersContainer // Container element for subscribers
let selfId // peer Id of the current user
let chatCount = 0
let scrollStop
let $t
let $toast
const client = new Client()
// Disconnect participant when browser's window close
window.addEventListener('beforeunload', () => {
leaveRoom()
})
window.addEventListener('resize', resize)
// Public methods
this.isScreenSharingSupported = isScreenSharingSupported
this.joinRoom = joinRoom
this.leaveRoom = leaveRoom
this.raiseHand = raiseHand
this.setupStart = setupStart
this.setupStop = setupStop
this.setupSetAudioDevice = setupSetAudioDevice
this.setupSetVideoDevice = setupSetVideoDevice
this.switchAudio = switchAudio
this.switchChannel = switchChannel
this.switchScreen = switchScreen
this.switchVideo = switchVideo
/**
* Join the room session
*
* @param data Session metadata and event handlers:
* token - A token for the main connection,
* nickname - Participant name,
* languages - Supported languages (code-to-label map)
* chatElement - DOM element for the chat widget,
* counterElement - DOM element for the participants counter,
* menuElement - DOM element of the room toolbar,
* queueElement - DOM element for the Q&A queue (users with a raised hand)
* onSuccess - Callback for session connection (join) success
* onError - Callback for session connection (join) error
* onDestroy - Callback for session disconnection event,
* onMediaSetup - Called when user clicks the Media setup button
* onUpdate - Callback for current user/session update,
* toast - Toast widget
* translate - Translation function
*/
function joinRoom(data) {
// Create a container for subscribers and publishers
publishersContainer = $('
').appendTo(container).get(0)
resize()
$t = data.translate
$toast = data.toast
// Make sure all supported callbacks exist, so we don't have to check
// their existence everywhere anymore
let events = ['Success', 'Error', 'Destroy', 'Update', 'MediaSetup']
events.map(event => 'on' + event).forEach(event => {
if (!data[event]) {
data[event] = () => {}
}
})
sessionData = data
// Handle new participants (including self)
client.on('addPeer', (event) => {
if (event.isSelf) {
selfId = event.id
}
peers[event.id] = event
event.element = participantCreate(event, event.videoElement)
if (event.raisedHand) {
peerHandUp(event)
}
})
// Handle removed participants
client.on('removePeer', (peerId) => {
let peer = peers[peerId]
if (peer) {
// Remove elements related to the participant
peerHandDown(peer)
$(peer.element).remove()
if (peer.screen) {
$(peer.screen).remove()
}
delete peers[peerId]
}
resize()
})
// Participant properties changed e.g. audio/video muted/unmuted
client.on('updatePeer', (event, changed) => {
let peer = peers[event.id]
if (!peer) {
return
}
event.element = peer.element
event.screen = peer.screen
// Video element added or removed
if (event.videoElement && event.videoElement.parentNode != event.element) {
$(event.element).prepend(event.videoElement)
} else if (!event.videoElement) {
$(event.element).find('video').remove()
}
// Video element of the shared screen added or removed
if (event.screenVideoElement && !event.screen) {
const screen = { id: event.id, role: event.role | Roles.SCREEN, nickname: event.nickname }
event.screen = participantCreate(screen, event.screenVideoElement)
} else if (!event.screenVideoElement && event.screen) {
$(event.screen).remove()
event.screen = null
resize()
}
peers[event.id] = event
if (changed && changed.length) {
if (changed && changed.includes('nickname')) {
nicknameUpdate(event.nickname, event.id)
}
if (changed.includes('raisedHand')) {
if (event.raisedHand) {
peerHandUp(event)
} else {
peerHandDown(event)
}
}
if (changed && changed.includes('screenWidth')) {
resize()
return
}
}
if (changed && changed.includes('interpreterRole')
&& !event.isSelf && $(event.element).find('video').length
) {
// Publisher-to-interpreter or vice-versa, move element to the subscribers list or vice-versa,
// but keep the existing video element
let wrapper = participantCreate(event, $(event.element).find('video'))
event.element.remove()
event.element = wrapper
} else if (changed && changed.includes('publisherRole') && !event.language) {
// Handle publisher-to-subscriber and subscriber-to-publisher change
event.element.remove()
event.element = participantCreate(event, event.videoElement)
} else {
participantUpdate(event.element, event)
}
// It's me, got publisher role
if (event.isSelf && (event.role & Roles.PUBLISHER) && changed && changed.includes('publisherRole')) {
// Open the media setup dialog
sessionData.onMediaSetup()
}
if (changed && changed.includes('moderatorRole')) {
participantUpdateAll()
}
})
// Handle successful connection to the room
client.on('joinSuccess', () => {
data.onSuccess()
client.media.setupStop()
})
// Handle join requests from other users (knocking to the room)
client.on('joinRequest', event => {
joinRequest(event)
})
// Handle session disconnection events
client.on('closeSession', event => {
// Notify the UI
data.onDestroy(event)
// Remove all participant elements
Object.keys(peers).forEach(peerId => {
$(peers[peerId].element).remove()
})
peers = {}
// refresh the matrix
resize()
})
// Handle session update events (e.g. channel, channels list changes)
client.on('updateSession', event => {
// Inform the vue component, so it can update some UI controls
sessionData.onUpdate(event)
})
const { audioSource, videoSource } = client.media.setupData()
// Start the session
client.joinSession(data.token, { videoSource, audioSource, nickname: data.nickname })
// Prepare the chat
initChat()
}
/**
* Leave the room (disconnect)
*/
function leaveRoom(forced) {
client.closeSession(forced)
peers = {}
}
/**
* Handler for an event received by the moderator when a participant
* is asking for a permission to join the room
*/
function joinRequest(data) {
const id = data.requestId
// The toast for this user request already exists, ignore
// It's not really needed as we do this on server-side already
if ($('#i' + id).length) {
return
}
const body = $(
`
`
+ ``
+ `
`
+ ``
+ `
`
+ ``
+ ``
)
$toast.message({
className: 'join-request',
icon: 'user',
timeout: 0,
title: $t('meet.join-request'),
// titleClassName: '',
body: body.html(),
onShow: element => {
$(element).find('p').text($t('meet.join-requested', { user: data.nickname || '' }))
// add id attribute, so we can identify it
$(element).attr('id', 'i' + id)
// add action to the buttons
.find('button.accept,button.deny').on('click', e => {
const action = $(e.target).is('.accept') ? 'Accept' : 'Deny'
client['joinRequest' + action](id)
$('#i' + id).remove()
})
}
})
}
/**
* Raise or lower the hand
*
* @param status Hand raised or not
*/
async function raiseHand(status) {
return await client.raiseHand(status)
}
/**
* Sets the audio and video devices for the session.
* This will ask user for permission to access media devices.
*
* @param props Setup properties (videoElement, volumeElement, onSuccess, onError)
*/
function setupStart(props) {
client.media.setupStart(props)
// When setting up devices while the session is ongoing we have to
// disable currently selected devices (temporarily) otherwise e.g.
// changing a mic or camera to another device will not be possible.
if (client.isJoined()) {
client.setMic('')
client.setCamera('')
}
}
/**
* Stop the setup "process", cleanup after it.
*/
async function setupStop() {
client.media.setupStop()
// Apply device changes to the client
const { audioSource, videoSource } = client.media.setupData()
await client.setMic(audioSource)
await client.setCamera(videoSource)
}
/**
* Change the publisher audio device
*
* @param deviceId Device identifier string
*/
async function setupSetAudioDevice(deviceId) {
return await client.media.setupSetAudio(deviceId)
}
/**
* Change the publisher video device
*
* @param deviceId Device identifier string
*/
async function setupSetVideoDevice(deviceId) {
return await client.media.setupSetVideo(deviceId)
}
/**
* Setup the chat UI
*/
function initChat() {
// Handle arriving chat messages
client.on('chatMessage', pushChatMessage)
// The UI elements are created in the vue template
// Here we add a logic for how they work
const chat = $(sessionData.chatElement).find('.chat').get(0)
const textarea = $(sessionData.chatElement).find('textarea')
const button = $(sessionData.menuElement).find('.link-chat')
textarea.on('keydown', e => {
if (e.keyCode == 13 && !e.shiftKey) {
if (textarea.val().length) {
client.chatMessage(textarea.val())
textarea.val('')
}
return false
}
})
// Add an element for the count of unread messages on the chat button
button.append('')
.on('click', () => {
button.find('.badge').text('')
chatCount = 0
// When opening the chat scroll it to the bottom, or we shouldn't?
scrollStop = false
chat.scrollTop = chat.scrollHeight
})
$(chat).on('scroll', event => {
// Detect manual scrollbar moves, disable auto-scrolling until
// the scrollbar is positioned on the element bottom again
scrollStop = chat.scrollTop + chat.offsetHeight < chat.scrollHeight
})
}
/**
* Add a message to the chat
*
* @param data Object with a message, nickname, id (of the connection, empty for self)
*/
function pushChatMessage(data) {
let message = $('').text(data.message).text() // make the message secure
// Format the message, convert emails and urls to links
message = anchorme({
input: message,
options: {
attributes: {
target: "_blank"
},
// any link above 20 characters will be truncated
// to 20 characters and ellipses at the end
truncate: 20,
// characters will be taken out of the middle
middleTruncation: true
}
// TODO: anchorme is extensible, we could support
// github/phabricator's markup e.g. backticks for code samples
})
message = message.replace(/\r?\n/, ' ')
// Display the message
let chat = $(sessionData.chatElement).find('.chat')
let box = chat.find('.message').last()
message = $('
').html(message)
message.find('a').attr('rel', 'noreferrer')
if (box.length && box.data('id') == data.peerId) {
// A message from the same user as the last message, no new box needed
message.appendTo(box)
} else {
box = $('
').data('id', data.peerId)
.append($('
').text(data.nickname || ''))
.append(message)
.appendTo(chat)
if (data.isSelf) {
box.addClass('self')
}
}
// Count unread messages
if (!$(sessionData.chatElement).is('.open')) {
if (!data.isSelf) {
chatCount++
}
} else {
chatCount = 0
}
$(sessionData.menuElement).find('.link-chat .badge').text(chatCount ? chatCount : '')
// Scroll the chat element to the end
if (!scrollStop) {
chat.get(0).scrollTop = chat.get(0).scrollHeight
}
}
/**
* Switch interpreted language channel
*
* @param channel Two-letter language code
*/
function switchChannel(channel) {
client.setLanguageChannel(channel)
}
/**
* Mute/Unmute audio for current session publisher
*/
async function switchAudio() {
const isActive = client.micStatus()
if (isActive) {
return await client.micMute()
} else {
return await client.micUnmute()
}
}
/**
* Mute/Unmute video for current session publisher
*/
async function switchVideo() {
const isActive = client.camStatus()
if (isActive) {
return await client.camMute()
} else {
return await client.camUnmute()
}
}
/**
* Switch on/off screen sharing
*/
async function switchScreen() {
const isActive = client.screenStatus()
if (isActive) {
return await client.screenUnshare()
} else {
return await client.screenShare()
}
}
/**
* Detect if screen sharing is supported by the browser
*/
function isScreenSharingSupported() {
return !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia)
}
/**
* Handler for Hand-Up "signal"
*/
function peerHandUp(peer) {
let element = $(nicknameWidget(peer))
participantUpdate(element, peer)
element.attr('id', 'qa' + peer.id).appendTo($(sessionData.queueElement).show())
setTimeout(() => element.addClass('wiggle'), 50)
}
/**
* Handler for Hand-Down "signal"
*/
function peerHandDown(peer) {
let list = $(sessionData.queueElement)
list.find('#qa' + peer.id).remove()
if (!list.find('.meet-nickname').length) {
list.hide()
}
}
/**
* Update participant nickname in the UI
*
* @param nickname Nickname
* @param peerId Connection identifier of the user
*/
function nicknameUpdate(nickname, peerId) {
if (peerId) {
$(sessionData.chatElement).find('.chat').find('.message').each(function() {
let elem = $(this)
if (elem.data('id') == peerId) {
elem.find('.nickname').text(nickname || '')
}
})
$(sessionData.queueElement).find('#qa' + peerId + ' .content').text(nickname || '')
// Also update the nickname for the shared screen as we do not call
// participantUpdate() for this element
$('#screen-' + peerId).find('.meet-nickname .content').text(nickname || '')
}
}
/**
* Create a participant element in the matrix. Depending on the peer role
* parameter it will be a video element wrapper inside the matrix or a simple
* tag-like element on the subscribers list.
*
* @param params Peer metadata/params
* @param content Optional content to prepend to the element, e.g. video element
*
* @return The element
*/
function participantCreate(params, content) {
let element
if ((!params.language && params.role & Roles.PUBLISHER) || params.role & Roles.SCREEN) {
// publishers and shared screens
element = publisherCreate(params, content)
} else {
// subscribers and language interpreters
element = subscriberCreate(params, content)
}
setTimeout(resize, 50)
return element
}
/**
* Create a