Page MenuHomePhorge

room.js
No OneTemporary

Authored By
Unknown
Size
38 KB
Referenced Files
None
Subscribers
None
'use strict'
import { Client } from './client.js'
import { Roles } from './constants.js'
import { Dropdown } from 'bootstrap'
import { library } from '@fortawesome/fontawesome-svg-core'
import linkifyStr from 'linkify-string'
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
this.getStats = getStats
/**
* 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 = $('<div id="meet-publishers">').appendTo(container).get(0)
subscribersContainer = $('<div id="meet-subscribers">').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.screenVideoElement && !event.screen) {
const screen = { id: event.id, role: event.role | Roles.SCREEN, nickname: event.nickname }
event.screen = participantCreate(screen, event.screenVideoElement)
}
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()
}
async function getStats() {
return await client.getStats()
}
/**
* 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 = $(
`<div>`
+ `<div class="picture"><img src="${data.picture}"></div>`
+ `<div class="content">`
+ `<p class="mb-2"></p>`
+ `<div class="text-end">`
+ `<button type="button" class="btn btn-sm btn-success accept">${$t('btn.accept')}</button>`
+ `<button type="button" class="btn btn-sm btn-danger deny ms-2">${$t('btn.deny')}</button>`
)
$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)
*/
async 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('<span class="badge bg-dark blinker">')
.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 = $('<span>').text(data.message).text() // make the message secure
// Format the message, convert emails and urls to links
message = linkifyStr(message, {
nl2br: true,
rel: 'noreferrer',
target: '_blank',
truncate: 25,
// Skip links that don't begin with a protocol
// e.g., "http://github.com" will be linkified, but "github.com" will not.
validate: {
url: value => /^https?:\/\//.test(value)
}
})
// Display the message
let chat = $(sessionData.chatElement).find('.chat')
let box = chat.find('.message').last()
message = $('<div>').html(message)
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 = $('<div class="message">').data('id', data.peerId)
.append($('<div class="nickname">').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 <video> element wrapper with controls
*
* @param params Connection metadata/params
* @param content Optional content to prepend to the element
*/
function publisherCreate(params, content) {
let isScreen = params.role & Roles.SCREEN
// Create the element
let wrapper = $(
'<div class="meet-video">'
+ svgIcon('user', 'fas', 'watermark')
+ '<div class="controls">'
+ '<button type="button" class="btn btn-link link-setup hidden" title="' + $t('meet.media-setup') + '">' + svgIcon('gear') + '</button>'
+ '<div class="volume hidden"><input type="range" min="0" max="1" step="0.1" /></div>'
+ '<button type="button" class="btn btn-link link-audio hidden" title="' + $t('meet.menu-audio-mute') + '">' + svgIcon('volume-xmark') + '</button>'
+ '<button type="button" class="btn btn-link link-fullscreen closed hidden" title="' + $t('meet.menu-fullscreen') + '">' + svgIcon('expand') + '</button>'
+ '<button type="button" class="btn btn-link link-fullscreen open hidden" title="' + $t('meet.menu-fullscreen') + '">' + svgIcon('compress') + '</button>'
+ '</div>'
+ '<div class="status">'
+ '<span class="bg-warning status-audio hidden">' + svgIcon('microphone-slash') + '</span>'
+ '<span class="bg-warning status-video hidden">' + svgIcon('video-slash') + '</span>'
+ '</div>'
+ '</div>'
)
// Append the nickname widget
wrapper.find('.controls').before(nicknameWidget(params))
if (content) {
wrapper.prepend(content)
}
if (isScreen) {
wrapper.addClass('screen')
}
if (params.isSelf) {
wrapper.find('.link-setup').removeClass('hidden').on('click', () => sessionData.onMediaSetup())
} else if (!isScreen) {
let volumeInput = wrapper.find('.volume input')
let audioButton = wrapper.find('.link-audio')
let inVolume = false
let hideVolumeTimeout
let hideVolume = () => {
if (inVolume) {
hideVolumeTimeout = setTimeout(hideVolume, 1000)
} else {
volumeInput.parent().addClass('hidden')
}
}
let setVolume = (video, volume) => {
video.volume = volume
video.muted = volume == 0
audioButton[video.muted ? 'addClass' : 'removeClass']('text-danger')
client[video.muted ? 'peerMicMute' : 'peerMicUnmute'](params.id)
}
// Enable and set up the audio mute button
audioButton.removeClass('hidden')
.on('click', e => {
let video = wrapper.find('video')[0]
setVolume(video, !video.muted ? 0 : 1)
volumeInput.val(video.volume)
})
// Show the volume slider when mouse is over the audio mute/unmute button
.on('mouseenter', () => {
let video = wrapper.find('video')[0]
clearTimeout(hideVolumeTimeout)
volumeInput.parent().removeClass('hidden')
volumeInput.val(video.volume)
})
.on('mouseleave', () => {
hideVolumeTimeout = setTimeout(hideVolume, 1000)
})
// Set up the audio volume control
volumeInput
.on('mouseenter', () => { inVolume = true })
.on('mouseleave', () => { inVolume = false })
.on('change input', () => {
setVolume(wrapper.find('video')[0], volumeInput.val())
})
}
participantUpdate(wrapper, params)
// Fullscreen control
if (document.fullscreenEnabled) {
wrapper.find('.link-fullscreen.closed').removeClass('hidden')
.on('click', () => {
wrapper.get(0).requestFullscreen()
})
wrapper.find('.link-fullscreen.open')
.on('click', () => {
document.exitFullscreen()
})
wrapper.on('fullscreenchange', () => {
// const enabled = document.fullscreenElement
wrapper.find('.link-fullscreen').toggleClass('hidden')
})
}
// Remove the subscriber element, if exists
$('#subscriber-' + params.id).remove()
let prio = params.isSelf || (isScreen && !$(publishersContainer).children('.screen').length)
return wrapper[prio ? 'prependTo' : 'appendTo'](publishersContainer)
.attr('id', (isScreen ? 'screen-' : 'publisher-') + params.id)
.get(0)
}
/**
* Update the publisher/subscriber element controls
*
* @param wrapper The wrapper element
* @param params Peer metadata/params
*/
function participantUpdate(wrapper, params) {
const element = $(wrapper)
const isSelf = params.isSelf
const rolePublisher = params.role & Roles.PUBLISHER
const roleModerator = params.role & Roles.MODERATOR
const roleScreen = params.role & Roles.SCREEN
const roleOwner = params.role & Roles.OWNER
const roleInterpreter = rolePublisher && !!params.language
const audioActive = roleScreen ? true : params.audioActive
const videoActive = roleScreen ? true : params.videoActive
element.find('.status-audio')[audioActive ? 'addClass' : 'removeClass']('hidden')
element.find('.status-video')[videoActive ? 'addClass' : 'removeClass']('hidden')
element.find('.meet-nickname > .content').text(params.nickname || '')
if (isSelf) {
element.addClass('self')
} else if (!roleScreen) {
element.find('.link-audio').removeClass('hidden')
}
const isModerator = peers[selfId] && peers[selfId].role & Roles.MODERATOR
const withPerm = isModerator && !roleScreen && !(roleOwner && !isSelf)
const withMenu = isSelf || (isModerator && !roleOwner)
if (isModerator) {
element.addClass('moderated')
}
// TODO: This probably could be better done with css
let elements = {
'.permissions': withPerm,
'.interpreting': withPerm && rolePublisher,
'svg.moderator': roleModerator,
'svg.user': !roleModerator && !roleInterpreter,
'svg.interpreter': !roleModerator && roleInterpreter
}
Object.keys(elements).forEach(key => {
element.find(key)[elements[key] ? 'removeClass' : 'addClass']('hidden')
})
element.find('.action-role-publisher input').prop('checked', rolePublisher)
element.find('.action-role-moderator input').prop('checked', roleModerator)
.prop('disabled', roleOwner)
element.find('.interpreting select').val(roleInterpreter ? params.language : '')
}
/**
* Update/refresh state of all participants' elements
*/
function participantUpdateAll() {
Object.keys(peers).forEach(peerId => {
const peer = peers[peerId]
participantUpdate(peer.element, peer)
})
}
/**
* Create a tag-like element for a subscriber participant
*
* @param params Connection metadata/params
* @param content Optional content to prepend to the element
*/
function subscriberCreate(params, content) {
// Create the element
let wrapper = $('<div class="meet-subscriber">').append(nicknameWidget(params))
if (content) {
wrapper.prepend(content)
}
participantUpdate(wrapper, params)
// Two event handlers below fix the dropdown in the subscribers list.
// Normally it gets hidden because of the overflow/height of the container.
// FIXME: I think it wasn't needed in BS4, but it's a problem with BS5.
const nickname = wrapper.children('.dropdown')[0]
nickname.addEventListener('show.bs.dropdown', () => {
$(subscribersContainer).css({
'overflow-y': 'unset',
height: $(subscribersContainer).height() + 'px'
})
})
nickname.addEventListener('hide.bs.dropdown', () => {
$(subscribersContainer).css({
'overflow-y': 'auto',
height: 'unset'
})
})
return wrapper[params.isSelf ? 'prependTo' : 'appendTo'](subscribersContainer)
.attr('id', 'subscriber-' + params.id)
.get(0)
}
/**
* Create a tag-like nickname widget
*
* @param object params Peer metadata/params
*/
function nicknameWidget(params) {
let languages = []
// Append languages selection options
Object.keys(sessionData.languages).forEach(code => {
languages.push(`<option value="${code}">${$t(sessionData.languages[code])}</option>`)
})
// Create the element
let element = $(
'<div class="dropdown">'
+ '<a href="#" class="meet-nickname btn" aria-haspopup="true" aria-expanded="false" role="button">'
+ '<span class="content"></span>'
+ '<span class="icon">'
+ svgIcon('user', null, 'user')
+ svgIcon('crown', null, 'moderator hidden')
+ svgIcon('headphones', null, 'interpreter hidden')
+ '</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">' + $t('meet.perm') + '</h6>'
+ '<label class="dropdown-item action-role-publisher form-check form-switch">'
+ '<input type="checkbox" class="form-check-input">'
+ ' <span class="form-check-label">' + $t('meet.perm-av') + '</span>'
+ '</label>'
+ '<label class="dropdown-item action-role-moderator form-check form-switch">'
+ '<input type="checkbox" class="form-check-input">'
+ ' <span class="form-check-label">' + $t('meet.perm-mod') + '</span>'
+ '</label>'
+ '</div>'
+ '<div class="dropdown-divider interpreting"></div>'
+ '<div class="interpreting">'
+ '<h6 class="dropdown-header">' + $t('meet.lang-int') + '</h6>'
+ '<div class="ps-3 pe-3"><select class="form-select">'
+ '<option value="">- ' + $t('form.none') + ' -</option>'
+ languages.join('')
+ '</select></div>'
+ '</div>'
+ '</div>'
+ '</div>'
)
let nickname = element.find('.meet-nickname')
.addClass('btn btn-outline-' + (params.isSelf ? 'primary' : 'secondary'))
.attr({title: $t('meet.menu-options'), 'data-bs-toggle': 'dropdown'})
const dropdown = new Dropdown(nickname[0], { boundary: container.parentNode })
if (params.isSelf) {
// Add events for nickname change
let editable = element.find('.content')[0]
let editableEnable = () => {
editable.contentEditable = true
editable.focus()
}
let editableUpdate = () => {
// Skip redundant update on blur, if it was already updated
if (editable.contentEditable !== 'false') {
editable.contentEditable = false
client.setNickname(editable.innerText)
}
}
element.find('.action-nickname').on('click', editableEnable)
element.find('.action-dismiss').remove()
$(editable).on('blur', editableUpdate)
.on('keydown', e => {
// Enter or Esc
if (e.keyCode == 13 || e.keyCode == 27) {
editableUpdate()
return false
}
// Do not propagate the event, so it does not interfere with our
// keyboard shortcuts
e.stopPropagation()
})
} else {
element.find('.action-nickname').remove()
element.find('.action-dismiss').on('click', () => {
client.kickPeer(params.id)
})
}
// Don't close the menu on permission change
element.find('.dropdown-menu > label').on('click', e => { e.stopPropagation() })
element.find('.action-role-publisher input').on('change', e => {
client[e.target.checked ? 'addRole' : 'removeRole'](params.id, Roles.PUBLISHER)
})
element.find('.action-role-moderator input').on('change', e => {
client[e.target.checked ? 'addRole' : 'removeRole'](params.id, Roles.MODERATOR)
})
element.find('.interpreting select')
.on('change', e => {
const language = $(e.target).val()
client.setLanguage(params.id, language)
dropdown.hide()
})
.on('click', e => {
// Prevents from closing the dropdown menu on click
e.stopPropagation()
})
return element.get(0)
}
/**
* Window onresize event handler (updates room layout)
*/
function resize() {
if (publishersContainer) {
updateLayout()
}
$(container).parent()[window.screen.width <= 768 ? 'addClass' : 'removeClass']('mobile')
}
/**
* Update the room "matrix" layout
*/
function updateLayout() {
let publishers = $(publishersContainer).find('.meet-video')
let numOfVideos = publishers.length
if (sessionData && sessionData.counterElement) {
sessionData.counterElement.innerHTML = Object.keys(peers).length
}
if (!numOfVideos) {
subscribersContainer.style.minHeight = 'auto'
return
}
// Note: offsetHeight/offsetWidth return rounded values, but for proper matrix
// calculations we need more precision, therefore we use getBoundingClientRect()
let allHeight = container.offsetHeight
let scrollHeight = subscribersContainer.scrollHeight
let bcr = publishersContainer.getBoundingClientRect()
let containerWidth = bcr.width
let containerHeight = bcr.height
let limit = Math.ceil(allHeight * 0.25) // max subscribers list height
// Fix subscribers list height
if (subscribersContainer.offsetHeight <= scrollHeight) {
limit = Math.min(scrollHeight, limit)
subscribersContainer.style.minHeight = limit + 'px'
containerHeight = allHeight - limit
} else {
subscribersContainer.style.minHeight = 'auto'
}
let css, rows, cols, height, padding = 0
// Make the first screen sharing tile big
let screenVideo = publishers.filter('.screen').find('video').get(0)
if (screenVideo) {
const element = screenVideo.parentNode
const peerId = element.id.replace(/^screen-/, '')
const peer = peers[peerId]
// We know the shared screen video dimensions, we can calculate
// width/height of the tile in the matrix
if (peer && peer.screenWidth) {
let screenWidth = peer.screenWidth
let screenHeight = containerHeight
// TODO: When the shared window is minimized the width/height is set to 1 (or 2)
// - at least on my system. We might need to handle this case nicer. Right now
// it create a 1-2px line on the left of the matrix - not a big issue.
// TODO: Make the 0.666 factor bigger for wide screen and small number of participants?
let maxWidth = Math.ceil(containerWidth * 0.666)
if (screenWidth > maxWidth) {
screenWidth = maxWidth
}
// Set the tile position and size
$(element).css({
width: screenWidth + 'px',
height: screenHeight + 'px',
position: 'absolute',
top: 0,
left: 0
})
padding = screenWidth + 'px'
// Now the estate for the rest of participants is what's left on the right side
containerWidth -= screenWidth
publishers = publishers.not(element)
numOfVideos -= 1
}
}
// Compensate the shared screen estate with a padding
$(publishersContainer).css('padding-left', padding)
const factor = containerWidth / containerHeight
if (factor >= 16/9) {
if (numOfVideos <= 3) {
rows = 1
} else if (numOfVideos <= 8) {
rows = 2
} else if (numOfVideos <= 15) {
rows = 3
} else if (numOfVideos <= 20) {
rows = 4
} else {
rows = 5
}
cols = Math.ceil(numOfVideos / rows)
} else {
if (numOfVideos == 1) {
cols = 1
} else if (numOfVideos <= 4) {
cols = 2
} else if (numOfVideos <= 9) {
cols = 3
} else if (numOfVideos <= 16) {
cols = 4
} else if (numOfVideos <= 25) {
cols = 5
} else {
cols = 6
}
rows = Math.ceil(numOfVideos / cols)
if (rows < cols && containerWidth < containerHeight) {
cols = rows
rows = Math.ceil(numOfVideos / cols)
}
}
// console.log('factor=' + factor, 'num=' + numOfVideos, 'cols = '+cols, 'rows=' + rows)
// Update all tiles (except the main shared screen) in the matrix
publishers.css({
width: (containerWidth / cols) + 'px',
// Height must be in pixels to make object-fit:cover working
height: (containerHeight / rows) + 'px'
})
}
/**
* Create an svg element (string) for a FontAwesome icon
*
* @todo Find if there's a "official" way to do this
*/
function svgIcon(name, type, className) {
// Note: the library will contain definitions for all icons registered elswhere
const icon = library.definitions[type || 'fas'][name]
let attrs = {
'class': 'svg-inline--fa',
'aria-hidden': true,
focusable: false,
role: 'img',
xmlns: 'http://www.w3.org/2000/svg',
viewBox: `0 0 ${icon[0]} ${icon[1]}`
}
if (className) {
attrs['class'] += ' ' + className
}
return $(`<svg><path fill="currentColor" d="${icon[4]}"></path></svg>`)
.attr(attrs)
.get(0).outerHTML
}
}
export { Room }

File Metadata

Mime Type
text/plain
Expires
Fri, Apr 24, 10:56 AM (1 w, 3 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18870729
Default Alt Text
room.js (38 KB)

Event Timeline