Page MenuHomePhorge

No OneTemporary

Size
166 KB
Referenced Files
None
Subscribers
None
diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js
index bbd21144..163fb2e6 100644
--- a/src/resources/js/meet/app.js
+++ b/src/resources/js/meet/app.js
@@ -1,1666 +1,1671 @@
import anchorme from 'anchorme'
import { library } from '@fortawesome/fontawesome-svg-core'
import { OpenVidu } from 'openvidu-browser'
class Roles {
static get SUBSCRIBER() { return 1 << 0; }
static get PUBLISHER() { return 1 << 1; }
static get MODERATOR() { return 1 << 2; }
static get SCREEN() { return 1 << 3; }
static get OWNER() { return 1 << 4; }
}
function Meet(container)
{
let OV // OpenVidu object to initialize a session
let session // Session object where the user will connect
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 audioSource = '' // Currently selected microphone
let videoSource = '' // Currently selected camera
let sessionData // Room session metadata
let screenOV // OpenVidu object to initialize a screen sharing session
let screenSession // Session object where the user will connect for screen sharing
let screenPublisher // Publisher object which the user will publish the screen sharing
let publisherDefaults = {
publishAudio: true, // Whether to start publishing with your audio unmuted or not
publishVideo: true, // Whether to start publishing with your video enabled or not
resolution: '640x480', // The resolution of your video
frameRate: 30, // The frame rate of your video
mirror: true // Whether to mirror your local video or not
}
let cameras = [] // List of user video devices
let microphones = [] // List of user audio devices
let connections = {} // Connected users in the session
let containerWidth
let containerHeight
let chatCount = 0
let volumeElement
let publishersContainer
let subscribersContainer
let scrollStop
OV = ovInit()
// 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.setupStart = setupStart
this.setupStop = setupStop
this.setupSetAudioDevice = setupSetAudioDevice
this.setupSetVideoDevice = setupSetVideoDevice
this.switchAudio = switchAudio
this.switchChannel = switchChannel
this.switchScreen = switchScreen
this.switchVideo = switchVideo
this.updateSession = updateSession
/**
* Initialize OpenVidu instance
*/
function ovInit()
{
let ov = new OpenVidu()
// If there's anything to do, do it here.
//ov.setAdvancedConfiguration(config)
// Disable all logging except errors
// ov.enableProdMode()
return ov
}
/**
* Join the room session
*
* @param data Session metadata and event handlers:
* token - OpenVidu token for the main connection,
* shareToken - OpenVidu token for screen-sharing connection,
* 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,
+ * 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,
* onDismiss - Callback for Dismiss action,
* onJoinRequest - Callback for join request,
* onConnectionChange - Callback for participant changes, e.g. role update,
* onSessionDataUpdate - Callback for current user connection update,
* onMediaSetup - Called when user clicks the Media setup button
*/
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();
volumeMeterStop()
data.params = {
nickname: data.nickname, // user nickname
// avatar: undefined // avatar image
}
// TODO: Make sure all supported callbacks exist, so we don't have to check
// their existence everywhere anymore
sessionData = data
// Init a session
session = OV.initSession()
// Handle connection creation events
session.on('connectionCreated', event => {
// Ignore the current user connection
if (event.connection.role) {
return
}
// 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.
let metadata = connectionData(event.connection)
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])
}
metadata.element = participantCreate(metadata)
connections[connId] = metadata
// Send the current user status to the connecting user
// otherwise e.g. nickname might be not up to date
signalUserUpdate(event.connection)
})
session.on('connectionDestroyed', event => {
let connectionId = event.connection.connectionId
let conn = connections[connectionId]
if (conn) {
// Remove elements related to the participant
connectionHandDown(connectionId)
$(conn.element).remove()
delete connections[connectionId]
}
resize()
})
// On every new Stream received...
session.on('streamCreated', event => {
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, metadata.element, props);
Object.assign(metadata, {
audioActive: event.stream.audioActive,
videoActive: event.stream.videoActive,
videoDimensions: event.stream.videoDimensions
})
subscriber.on('videoElementCreated', event => {
$(event.element).prop({
tabindex: -1
})
resize()
})
// Update the wrapper controls/status
participantUpdate(metadata.element, metadata)
})
// 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
metadata.audioActive = audioActive
metadata.videoActive = videoActive
}
if (metadata) {
metadata[event.changedProperty] = event.newValue
if (event.changedProperty == 'videoDimensions') {
resize()
} else {
participantUpdate(metadata.element, metadata)
}
}
})
// Handle session disconnection events
session.on('sessionDisconnected', event => {
if (data.onDestroy) {
data.onDestroy(event)
}
session = null
resize()
})
// Handle signals from all participants
session.on('signal', signalEventHandler)
// Connect with the token
session.connect(data.token, data.params)
.then(() => {
if (data.onSuccess) {
data.onSuccess()
}
let params = {
connectionId: session.connection.connectionId,
role: data.role,
audioActive,
videoActive
}
params = Object.assign({}, data.params, params)
publisher.on('videoElementCreated', event => {
$(event.element).prop({
muted: true, // Mute local video to avoid feedback
disablePictureInPicture: true, // this does not work in Firefox
tabindex: -1
})
resize()
})
let wrapper = participantCreate(params)
if (data.role & Roles.PUBLISHER) {
publisher.createVideoElement(wrapper, 'PREPEND')
session.publish(publisher)
}
sessionData.element = wrapper
// Create Q&A queue from the existing connections with rised hand.
// 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);
if (data.onError) {
data.onError(error)
}
})
// Prepare the chat
setupChat()
}
/**
* Leave the room (disconnect)
*/
function leaveRoom() {
if (publisher) {
volumeMeterStop()
// Release any media
let mediaStream = publisher.stream.getMediaStream()
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop())
}
publisher = null
}
if (session) {
session.disconnect();
session = null
}
if (screenSession) {
screenSession.disconnect();
screenSession = null
}
}
/**
* 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) {
// 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)
publisher.once('accessDenied', error => {
props.onError(error)
})
publisher.once('accessAllowed', async () => {
let mediaStream = publisher.stream.getMediaStream()
let videoStream = mediaStream.getVideoTracks()[0]
let audioStream = mediaStream.getAudioTracks()[0]
audioActive = !!audioStream
videoActive = !!videoStream
volumeElement = props.volumeElement
publisher.addVideoElement(props.videoElement)
volumeMeterStart()
const devices = await OV.getDevices()
devices.forEach(device => {
// device's props: deviceId, kind, label
if (device.kind == 'videoinput') {
cameras.push(device)
if (videoStream && videoStream.label == device.label) {
videoSource = device.deviceId
}
} else if (device.kind == 'audioinput') {
microphones.push(device)
if (audioStream && audioStream.label == device.label) {
audioSource = device.deviceId
}
}
})
props.onSuccess({
microphones,
cameras,
audioSource,
videoSource,
audioActive,
videoActive
})
})
}
/**
* Stop the setup "process", cleanup after it.
*/
function setupStop() {
volumeMeterStop()
}
/**
* Change the publisher audio device
*
* @param deviceId Device identifier string
*/
async function setupSetAudioDevice(deviceId) {
if (!deviceId) {
publisher.publishAudio(false)
volumeMeterStop()
audioActive = false
} else if (deviceId == audioSource) {
publisher.publishAudio(true)
volumeMeterStart()
audioActive = true
} else {
const mediaStream = publisher.stream.mediaStream
const properties = Object.assign({}, publisherDefaults, {
publishAudio: true,
publishVideo: videoActive,
audioSource: deviceId,
videoSource: videoSource
})
volumeMeterStop()
// 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) => {
await replaceTrack(newMediaStream.getAudioTracks()[0])
volumeMeterStart()
audioActive = true
audioSource = deviceId
})
}
return audioActive
}
/**
* Change the publisher video device
*
* @param deviceId Device identifier string
*/
async function setupSetVideoDevice(deviceId) {
if (!deviceId) {
publisher.publishVideo(false)
videoActive = false
} else if (deviceId == videoSource) {
publisher.publishVideo(true)
videoActive = true
} else {
const mediaStream = publisher.stream.mediaStream
const properties = Object.assign({}, publisherDefaults, {
publishAudio: audioActive,
publishVideo: true,
audioSource: audioSource,
videoSource: deviceId
})
volumeMeterStop()
// 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) => {
await replaceTrack(newMediaStream.getVideoTracks()[0])
volumeMeterStart()
videoActive = true
videoSource = deviceId
})
}
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);
}
}
// Fix a bug in Chrome where you would start hearing yourself after audio device change
// https://github.com/OpenVidu/openvidu/issues/449
publisher.videoReference.muted = true
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
*/
function setupChat() {
// 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) {
signalChat(textarea.val())
textarea.val('')
}
return false
}
})
// Add an element for the count of unread messages on the chat button
button.append('<span class="badge badge-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
})
}
/**
* Signal events handler
*/
function signalEventHandler(signal) {
let conn, data
let connId = signal.from ? signal.from.connectionId : null
switch (signal.type) {
case 'signal:userChanged':
// TODO: Use 'signal:connectionUpdate' for nickname updates?
if (conn = connections[connId]) {
data = JSON.parse(signal.data)
conn.nickname = data.nickname
participantUpdate(conn.element, conn)
nicknameUpdate(data.nickname, connId)
}
break
case 'signal:chat':
data = JSON.parse(signal.data)
data.id = connId
pushChatMessage(data)
break
case 'signal:joinRequest':
// accept requests from the server only
if (!connId && sessionData.onJoinRequest) {
sessionData.onJoinRequest(JSON.parse(signal.data))
}
break
case 'signal:connectionUpdate':
// accept requests from the server only
if (!connId) {
data = JSON.parse(signal.data)
connectionUpdate(data)
}
break
}
}
/**
* Send the chat message to other participants
*
* @param message Message string
*/
function signalChat(message) {
let data = {
nickname: sessionData.params.nickname,
message
}
session.signal({
data: JSON.stringify(data),
type: 'chat'
})
}
/**
* 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 = 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/, '<br>')
// Display the message
let isSelf = data.id == session.connectionId
let chat = $(sessionData.chatElement).find('.chat')
let box = chat.find('.message').last()
message = $('<div>').html(message)
message.find('a').attr('rel', 'noreferrer')
if (box.length && box.data('id') == data.id) {
// 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.id)
.append($('<div class="nickname">').text(data.nickname || ''))
.append(message)
.appendTo(chat)
if (isSelf) {
box.addClass('self')
}
}
// Count unread messages
if (!$(sessionData.chatElement).is('.open')) {
if (!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
}
}
/**
* Send the user properties update signal to other participants
*
* @param connection Optional connection to which the signal will be sent
* If not specified the signal is sent to all participants
*/
function signalUserUpdate(connection) {
let data = {
nickname: sessionData.params.nickname
}
session.signal({
data: JSON.stringify(data),
type: 'userChanged',
to: connection ? [connection] : undefined
})
// The same nickname for screen sharing session
if (screenSession) {
screenSession.signal({
data: JSON.stringify(data),
type: 'userChanged',
to: connection ? [connection] : undefined
})
}
}
/**
* 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
*/
function switchAudio() {
// TODO: If user has no devices or denied access to them in the setup,
// the button will just not work. Find a way to make it working
// after user unlocks his devices. For now he has to refresh
// the page and join the room again.
if (microphones.length) {
try {
publisher.publishAudio(!audioActive)
audioActive = !audioActive
} catch (e) {
console.error(e)
}
}
return audioActive
}
/**
* Mute/Unmute video for current session publisher
*/
function switchVideo() {
// TODO: If user has no devices or denied access to them in the setup,
// the button will just not work. Find a way to make it working
// after user unlocks his devices. For now he has to refresh
// the page and join the room again.
if (cameras.length) {
try {
publisher.publishVideo(!videoActive)
videoActive = !videoActive
} catch (e) {
console.error(e)
}
}
return videoActive
}
/**
* Switch on/off screen sharing
*/
function switchScreen(callback) {
if (screenPublisher) {
// Note: This is what the original openvidu-call app does.
// It is probably better for performance reasons to close the connection,
// than to use unpublish() and keep the connection open.
screenSession.disconnect()
screenSession = null
screenPublisher = null
if (callback) {
// Note: Disconnecting invalidates the token, we have to inform the vue component
// to update UI state (and be prepared to request a new token).
callback(false)
}
return
}
screenConnect(callback)
}
/**
* Detect if screen sharing is supported by the browser
*/
function isScreenSharingSupported() {
return !!OV.checkScreenSharingCapabilities();
}
/**
* Update participant connection state
*/
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) {
connectionHandUp(conn)
} else {
connectionHandDown(data.connectionId)
}
}
}
// It's me
if (session.connection.connectionId == data.connectionId) {
const rolePublisher = data.role && data.role & Roles.PUBLISHER
const roleModerator = data.role && data.role & Roles.MODERATOR
const isPublisher = sessionData.role & Roles.PUBLISHER
const isModerator = sessionData.role & Roles.MODERATOR
// 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)
}
handUpdate(sessionData)
// merge the changed data into internal session metadata object
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) {
// Update all participants, to enable/disable the popup menu
refresh = (!isModerator && roleModerator) || (isModerator && !roleModerator)
}
// promoted to a publisher
if ('role' in data && !isPublisher && rolePublisher) {
publisher.createVideoElement(sessionData.element, 'PREPEND')
session.publish(publisher).then(() => {
sessionData.audioActive = publisher.stream.audioActive
sessionData.videoActive = publisher.stream.videoActive
if (sessionData.onSessionDataUpdate) {
sessionData.onSessionDataUpdate(sessionData)
}
})
// Open the media setup dialog
// Note: If user didn't give permission to media before joining the room
// he will not be able to use them now. Changing permissions requires
// a page refresh.
// Note: In Firefox I'm always being asked again for media permissions.
// It does not happen in Chrome. In Chrome the cam/mic will be just re-used.
// I.e. streaming starts automatically.
// It might make sense to not start streaming automatically in any cirmustances,
// display the dialog and wait until user closes it, but this would be
// a bigger refactoring.
if (sessionData.onMediaSetup) {
sessionData.onMediaSetup()
}
}
} else if (conn) {
handUpdate(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 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)
}
}
/**
* Handler for Hand-Up "signal"
*/
function connectionHandUp(connection) {
connection.isSelf = session.connection.connectionId == connection.connectionId
let element = $(nicknameWidget(connection))
participantUpdate(element, connection)
element.attr('id', 'qa' + connection.connectionId)
.appendTo($(sessionData.queueElement).show())
setTimeout(() => element.addClass('widdle'), 50)
}
/**
* Handler for Hand-Down "signal"
*/
function connectionHandDown(connectionId) {
let list = $(sessionData.queueElement)
list.find('#qa' + connectionId).remove();
if (!list.find('.meet-nickname').length) {
list.hide();
}
}
/**
* Update participant nickname in the UI
*
* @param nickname Nickname
* @param connectionId Connection identifier of the user
*/
function nicknameUpdate(nickname, connectionId) {
if (connectionId) {
$(sessionData.chatElement).find('.chat').find('.message').each(function() {
let elem = $(this)
if (elem.data('id') == connectionId) {
elem.find('.nickname').text(nickname || '')
}
})
$(sessionData.queueElement).find('#qa' + connectionId + ' .content').text(nickname || '')
}
}
/**
* Create a participant element in the matrix. Depending on the connection role
* 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 content Optional content to prepend to the element
*
* @return The element
*/
function participantCreate(params, content) {
let element
params.isSelf = params.isSelf || session.connection.connectionId == params.connectionId
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="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>'
+ '</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) {
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 => {
let video = wrapper.find('video')[0]
video.muted = !video.muted
wrapper.find('.link-audio')[video.muted ? 'addClass' : 'removeClass']('text-danger')
})
}
participantUpdate(wrapper, params, true)
// 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.closed').toggleClass('hidden')
wrapper.find('.link-fullscreen.open').toggleClass('hidden')
wrapper.toggleClass('fullscreen')
})
}
// Remove the subscriber element, if exists
$('#subscriber-' + params.connectionId).remove()
let prio = params.isSelf || (isScreen && !$(publishersContainer).children('.screen').length)
return wrapper[prio ? 'prependTo' : 'appendTo'](publishersContainer)
.attr('id', 'publisher-' + params.connectionId)
.get(0)
}
/**
* Update the publisher/subscriber element controls
*
* @param wrapper The wrapper element
* @param params Connection metadata/params
*/
function participantUpdate(wrapper, params, noupdate) {
const element = $(wrapper)
const isModerator = sessionData.role & Roles.MODERATOR
const isSelf = session.connection.connectionId == params.connectionId
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
if (!noupdate && !roleScreen) {
const isPublisher = element.is('.meet-video')
// 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)
}
}
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
}
element.find('.status-video')[params.videoActive ? 'addClass' : 'removeClass']('hidden')
if (video) {
video.muted = muted
}
if ('nickname' in params) {
element.find('.meet-nickname > .content').text(params.nickname)
}
if (isSelf) {
element.addClass('self')
}
if (isModerator) {
element.addClass('moderated')
}
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 && !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 : '')
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 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, true)
return wrapper[params.isSelf ? 'prependTo' : 'appendTo'](subscribersContainer)
.attr('id', 'subscriber-' + params.connectionId)
.get(0)
}
/**
* Create a tag-like nickname widget
*
* @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">'
+ '<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">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 &amp; 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 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>'
)
let nickname = element.find('.meet-nickname')
.addClass('btn btn-outline-' + (params.isSelf ? 'primary' : 'secondary'))
.attr({title: 'Options', 'data-toggle': 'dropdown'})
.dropdown({boundary: container})
if (params.isSelf) {
// Add events for nickname change
let editable = element.find('.content')[0]
let editableEnable = () => {
editable.contentEditable = true
editable.focus()
}
let editableUpdate = () => {
editable.contentEditable = false
sessionData.params.nickname = editable.innerText
signalUserUpdate()
nicknameUpdate(editable.innerText, session.connection.connectionId)
}
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', e => {
if (sessionData.onDismiss) {
sessionData.onDismiss(params.connectionId)
}
})
}
let connectionRole = () => {
if (params.isSelf) {
return sessionData.role
}
if (params.connectionId in connections) {
return connections[params.connectionId].role
}
return 0
}
// 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 = connectionRole()
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 = connectionRole()
if (enabled) {
role |= Roles.MODERATOR
} else if (role & Roles.MODERATOR) {
role ^= Roles.MODERATOR
}
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)
}
/**
* Window onresize event handler (updates room layout)
*/
function resize() {
containerWidth = publishersContainer.offsetWidth
containerHeight = publishersContainer.offsetHeight
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(connections).length + 1
+ }
+
if (!numOfVideos) {
return
}
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) {
let element = screenVideo.parentNode
let connId = element.id.replace(/^publisher-/, '')
let connection = connections[connId]
// We know the shared screen video dimensions, we can calculate
// width/height of the tile in the matrix
if (connection && connection.videoDimensions) {
let screenWidth = connection.videoDimensions.width
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'
})
}
/**
* Initialize screen sharing session/publisher
*/
function screenConnect(callback) {
if (!sessionData.shareToken) {
return false
}
let gotSession = !!screenSession
if (!screenOV) {
screenOV = ovInit()
}
// Init screen sharing session
if (!gotSession) {
screenSession = screenOV.initSession();
}
let successFunc = function() {
screenSession.publish(screenPublisher)
screenSession.on('sessionDisconnected', event => {
callback(false)
screenSession = null
screenPublisher = null
})
if (callback) {
callback(true)
}
}
let errorFunc = function() {
screenPublisher = null
if (callback) {
callback(false, true)
}
}
// Init the publisher
let params = {
videoSource: 'screen',
publishAudio: false
}
screenPublisher = screenOV.initPublisher(null, params)
screenPublisher.once('accessAllowed', (event) => {
if (gotSession) {
successFunc()
} else {
screenSession.connect(sessionData.shareToken, sessionData.params)
.then(() => {
successFunc()
})
.catch(error => {
console.error('There was an error connecting to the session:', error.code, error.message);
errorFunc()
})
}
})
screenPublisher.once('accessDenied', () => {
console.info('ScreenShare: Access Denied')
errorFunc()
})
}
/**
* 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
}
/**
* A way to update some session data, after you joined the room
*
* @param data Same input as for joinRoom()
*/
function updateSession(data) {
sessionData.shareToken = data.shareToken
}
/**
* A handler for volume level change events
*/
function volumeChangeHandler(event) {
let value = 100 + Math.min(0, Math.max(-100, event.value.newValue))
let color = 'lime'
const bar = volumeElement.firstChild
if (value >= 70) {
color = '#ff3300'
} else if (value >= 50) {
color = '#ff9933'
}
bar.style.height = value + '%'
bar.style.background = color
}
/**
* Start the volume meter
*/
function volumeMeterStart() {
if (publisher && volumeElement) {
publisher.on('streamAudioVolumeChange', volumeChangeHandler)
}
}
/**
* Stop the volume meter
*/
function volumeMeterStop() {
if (publisher && volumeElement) {
publisher.off('streamAudioVolumeChange')
volumeElement.firstChild.style.height = 0
}
}
function connectionData(connection) {
// Note: we're sending a json from two sources (server-side when
// creating a token/connection, and client-side when joining the session)
// 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
let data = JSON.parse(connection.data.replace('}%/%{', ','))
data.connectionId = connection.connectionId
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
index ef782b9f..8db0331c 100644
--- a/src/resources/themes/meet.scss
+++ b/src/resources/themes/meet.scss
@@ -1,451 +1,493 @@
.meet-nickname {
padding: 0;
line-height: 2em;
border-radius: 1em;
max-width: 100%;
white-space: nowrap;
display: inline-flex;
.icon {
display: inline-block;
min-width: 2em;
}
.content {
order: 1;
height: 2em;
outline: none;
overflow: hidden;
text-overflow: ellipsis;
&:not(:empty) {
margin-left: 0.5em;
padding-right: 0.5em;
& + .icon {
margin-right: -0.75em;
}
}
}
.self & {
.content {
&:focus {
min-width: 0.5em;
}
}
}
& + .dropdown-menu {
.permissions > label {
margin: 0;
padding-left: 3.75rem;
}
}
}
.meet-video {
position: relative;
background: $menu-bg-color;
// Use flexbox for centering .watermark
display: flex;
align-items: center;
justify-content: center;
video {
// To make object-fit:cover working we have to set the height in pixels
// on the wrapper element. This is what javascript method will do.
object-fit: cover;
width: 100%;
height: 100%;
background: #000;
& + .watermark {
display: none;
}
}
&.screen video {
// For shared screen videos we use the original aspect ratio
object-fit: scale-down;
background: none;
}
&.fullscreen {
video {
// We don't want the video to be cut in fullscreen
// This will preserve the aspect ratio of the video stream
object-fit: contain;
}
}
.watermark {
color: darken($menu-bg-color, 20%);
width: 50%;
height: 50%;
}
.controls {
position: absolute;
bottom: 0;
right: 0;
margin: 0.5em;
padding: 0 0.05em;
line-height: 2em;
border-radius: 1em;
background: rgba(#000, 0.7);
button {
line-height: 2;
border-radius: 50%;
padding: 0;
width: 2em;
}
}
.status {
position: absolute;
bottom: 0;
left: 0;
margin: 0.5em;
line-height: 2em;
span {
display: inline-block;
color: #fff;
border-radius: 50%;
width: 2em;
text-align: center;
margin-right: 0.25em;
}
}
.dropdown {
position: absolute !important;
top: 0;
left: 0;
right: 0;
}
.meet-nickname {
margin: 0.5em;
max-width: calc(100% - 1em);
border: 0;
&:not(:hover) {
background-color: rgba(#fff, 0.8);
}
}
&:not(.moderated):not(.self) .meet-nickname {
.icon {
display: none;
}
}
}
#meet-component {
flex-grow: 1;
display: flex;
flex-direction: column;
& + .filler {
display: none;
}
}
#app.meet {
height: 100%;
#meet-component {
overflow: hidden;
}
nav.navbar {
display: none;
}
}
#meet-setup {
max-width: 720px;
}
#meet-auth {
margin-top: 2rem;
margin-bottom: 2rem;
flex: 1;
}
+#meet-counter {
+ position: absolute;
+ left: 0;
+ padding: 1.1em 0.6em;
+ color: #808c99;
+
+ svg {
+ font-size: 1.4em;
+ vertical-align: text-bottom;
+ }
+}
+
#meet-session-toolbar {
display: flex;
justify-content: center;
}
#meet-session-menu {
background: #f6f5f3;
border-radius: 0 2em 2em 0;
margin: 0.25em 0;
+ padding-right: 0.5em;
button {
font-size: 1.3em;
- padding: 0 0.25em;
- margin: 0.5em;
+ margin: 0.4em;
position: relative;
+ color: #4f5963;
+ width: 1.75em;
+ height: 1.75em;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ &.on:not(:disabled) {
+ background: #4f5963;
+ color: white;
+ }
.badge {
font-size: 0.5em;
position: absolute;
right: -0.5em;
+ top: -0.5em;
&:empty {
display: none;
}
}
+
+ svg {
+ width: 1em;
+ }
}
}
#meet-session-logo {
background: #e9e7e2;
border-radius: 2em 0 0 2em;
margin: 0.25em 0;
display: flex;
flex-direction: column;
justify-content: center;
img {
height: 1.25em;
- padding: 0 1.5em;
+ padding: 0 1em 0 1.5em;
}
}
#meet-session-layout {
flex: 1;
overflow: hidden;
}
#meet-publishers {
height: 100%;
position: relative;
}
#meet-subscribers {
padding: 0.15em;
overflow-y: auto;
.meet-subscriber {
margin: 0.15em;
max-width: calc(25% - 0.4em);
}
// Language interpreters will be displayed as subscribers, but will have still
// the video element that we will hide
video {
display: none;
}
}
#meet-session {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
& > div {
display: flex;
flex-wrap: wrap;
width: 100%;
&:empty {
display: none;
}
}
#meet-publishers:empty {
& + #meet-subscribers {
justify-content: center;
align-content: center;
flex: 1;
}
}
#meet-publishers:not(:empty) {
& + #meet-subscribers {
max-height: 30%;
}
}
}
#meet-chat {
width: 0;
display: none;
flex-direction: column;
+ background: $body-bg;
+ padding-top: 0.25em;
&.open {
width: 30%;
display: flex !important;
.mobile & {
width: 100%;
z-index: 1;
- background: $body-bg;
}
}
.chat {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
}
.message {
margin: 0 0.5em 0.5em 0.5em;
padding: 0.25em 0.5em;
border-radius: 1em;
- background: $menu-bg-color;
+ background: #fff;
overflow-wrap: break-word;
&.self {
background: lighten($main-color, 30%);
}
&:last-child {
margin-bottom: 0;
}
}
.nickname {
font-size: 80%;
color: $secondary;
text-align: right;
}
// TODO: mobile mode
}
#meet-queue {
display: none;
width: 150px;
.head {
text-align: center;
font-size: 1.75em;
background: $menu-bg-color;
}
.dropdown {
margin: 0.2em;
display: flex;
position: relative;
transition: top 10s ease;
top: 15em;
.meet-nickname {
width: 100%;
}
&.widdle {
top: 0;
animation-name: wiggle;
animation-duration: 1s;
animation-timing-function: ease-in-out;
animation-iteration-count: 8;
}
}
}
@keyframes wiggle {
0% { transform: rotate(0deg); }
25% { transform: rotate(10deg); }
50% { transform: rotate(0deg); }
75% { transform: rotate(-10deg); }
100% { transform: rotate(0deg); }
}
.media-setup-form {
.input-group svg {
width: 1em;
}
}
.media-setup-preview {
display: flex;
position: relative;
video {
width: 100%;
background: #000;
}
.volume {
height: 50%;
position: absolute;
bottom: 1em;
right: 2em;
width: 0.5em;
background: rgba(0, 0, 0, 0.5);
.bar {
width: 100%;
position: absolute;
bottom: 0;
}
#media-setup-dialog & {
right: 1em;
}
}
}
.toast.join-request {
.toast-header {
color: #eee;
}
.toast-body {
display: flex;
}
.picture {
margin-right: 1em;
img {
width: 64px;
height: 64px;
border: 1px solid #555;
border-radius: 50%;
object-fit: cover;
}
}
.content {
flex: 1;
}
}
@include media-breakpoint-down(sm) {
#meet-session-logo {
display: none;
}
#meet-session-menu {
background: transparent;
}
#app.meet {
#footer-menu {
display: block !important;
height: 2em;
padding: 0;
.navbar-brand {
padding: 0;
margin: 0;
}
img {
width: auto !important;
height: 1em;
}
}
}
}
+
+@include media-breakpoint-down(xs) {
+ #meet-setup {
+ .card-title {
+ text-align: center;
+ }
+ }
+
+ .media-setup-preview {
+ // Fix video element size in Safari/iOS
+ display: block;
+ }
+}
diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue
index 3e3f5d58..4c173c0d 100644
--- a/src/resources/vue/Meet/Room.vue
+++ b/src/resources/vue/Meet/Room.vue
@@ -1,752 +1,758 @@
<template>
<div id="meet-component">
<div id="meet-session-toolbar" class="hidden">
+ <span id="meet-counter" title="Number of participants"><svg-icon icon="users"></svg-icon> <span></span></span>
<span id="meet-session-logo" v-html="$root.logo()"></span>
<div id="meet-session-menu">
- <button class="btn link-audio" :class="{ 'text-primary' : audioActive }" @click="switchSound" :disabled="!isPublisher()" :title="audioActive ? 'Mute audio' : 'Unmute audio'">
+ <button :class="'btn link-audio' + (audioActive ? '' : ' on')" @click="switchSound" :disabled="!isPublisher()" :title="audioActive ? 'Mute audio' : 'Unmute audio'">
<svg-icon :icon="audioActive ? 'microphone' : 'microphone-slash'"></svg-icon>
</button>
- <button class="btn link-video" :class="{ 'text-primary' : videoActive }" @click="switchVideo" :disabled="!isPublisher()" :title="videoActive ? 'Mute video' : 'Unmute video'">
+ <button :class="'btn link-video' + (videoActive ? '' : ' on')" @click="switchVideo" :disabled="!isPublisher()" :title="videoActive ? 'Mute video' : 'Unmute video'">
<svg-icon :icon="videoActive ? 'video' : 'video-slash'"></svg-icon>
</button>
- <button class="btn link-screen" :class="{ 'text-danger' : screenShareActive }" @click="switchScreen" :disabled="!canShareScreen || !isPublisher()" title="Share screen">
+ <button :class="'btn link-screen' + (screenShareActive ? ' on' : '')" @click="switchScreen" :disabled="!canShareScreen || !isPublisher()" title="Share screen">
<svg-icon icon="desktop"></svg-icon>
</button>
- <button class="btn link-hand" :class="{ 'text-primary' : handRaised }" v-if="!isPublisher()" @click="switchHand" :title="handRaised ? 'Lower hand' : 'Raise hand'">
+ <button :class="'btn link-hand' + (handRaised ? ' on' : '')" v-if="!isPublisher()" @click="switchHand" :title="handRaised ? 'Lower hand' : 'Raise hand'">
<svg-icon icon="hand-paper"></svg-icon>
</button>
<span id="channel-select" :style="'display:' + (channels.length ? '' : 'none')" class="dropdown">
- <button class="btn link-channel" title="Interpreted language channel" aria-haspopup="true" aria-expanded="false" data-toggle="dropdown">
+ <button :class="'btn link-channel' + (session.channel ? ' on' : '')" data-toggle="dropdown"
+ title="Interpreted language channel" aria-haspopup="true" aria-expanded="false"
+ >
<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 link-chat" @click="switchChat" title="Chat">
+ <button :class="'btn link-chat' + (chatActive ? ' on' : '')" @click="switchChat" title="Chat">
<svg-icon icon="comment"></svg-icon>
</button>
<button class="btn link-fullscreen closed hidden" @click="switchFullscreen" title="Full screen">
<svg-icon icon="expand"></svg-icon>
</button>
<button class="btn link-fullscreen open hidden" @click="switchFullscreen" title="Exit full screen">
<svg-icon icon="compress"></svg-icon>
</button>
<button class="btn link-options" v-if="isRoomOwner()" @click="roomOptions" title="Room options">
<svg-icon icon="cog"></svg-icon>
</button>
<button class="btn link-logout" @click="logout" title="Leave session">
<svg-icon icon="power-off"></svg-icon>
</button>
</div>
</div>
<div id="meet-setup" class="card container mt-2 mt-md-5 mb-5">
<div class="card-body">
<div class="card-title">Set up your session</div>
<div class="card-text">
<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>
<div class="col-sm-6 align-self-center">
<div class="input-group">
<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>
<div class="input-group mt-2">
<label for="setup-nickname" class="input-group-prepend mb-0">
<span class="input-group-text" title="Nickname"><svg-icon icon="user"></svg-icon></span>
</label>
<input class="form-control" type="text" id="setup-nickname" v-model="nickname" placeholder="Your name">
</div>
<div class="input-group mt-2" v-if="session.config && session.config.requires_password">
<label for="setup-password" class="input-group-prepend mb-0">
<span class="input-group-text" title="Password"><svg-icon icon="key"></svg-icon></span>
</label>
<input type="password" class="form-control" id="setup-password" v-model="password" placeholder="Password">
</div>
<div class="mt-3">
<button type="submit" id="join-button"
:class="'btn w-100 btn-' + (isRoomReady() ? 'success' : 'primary')"
>
<span v-if="isRoomReady()">JOIN NOW</span>
<span v-else-if="roomState == 323">I'm the owner</span>
<span v-else>JOIN</span>
</button>
</div>
</div>
<div class="mt-4 col-sm-12">
<status-message :status="roomState" :status-labels="roomStateLabels"></status-message>
</div>
</form>
</div>
</div>
</div>
<div id="meet-session-layout" class="d-flex hidden">
<div id="meet-queue">
<div class="head" title="Q &amp; A"><svg-icon icon="microphone-alt"></svg-icon></div>
</div>
<div id="meet-session"></div>
<div id="meet-chat">
<div class="chat"></div>
<div class="chat-input m-2">
<textarea class="form-control" rows="1"></textarea>
</div>
</div>
</div>
<logon-form id="meet-auth" class="hidden" :dashboard="false" @success="authSuccess"></logon-form>
<div id="leave-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">Room closed</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>The session has been closed by the room owner.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger modal-action" data-dismiss="modal">Close</button>
</div>
</div>
</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">&times;</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">
+ <label for="setup-mic" 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">
+ <select class="custom-select" id="setup-mic" 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">
+ <label for="setup-cam" 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">
+ <select class="custom-select" id="setup-cam" 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>
<room-options v-if="session.config" :config="session.config" :room="room" @config-update="configUpdate"></room-options>
</div>
</template>
<script>
import { Meet, Roles } from '../../js/meet/app.js'
import StatusMessage from '../Widgets/StatusMessage'
import LogonForm from '../Login'
import RoomOptions from './RoomOptions'
// Register additional icons
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faComment,
faCog,
faCompress,
faCrown,
faDesktop,
faExpand,
faHandPaper,
faHeadphones,
faMicrophone,
faMicrophoneSlash,
faMicrophoneAlt,
faPowerOff,
faUser,
+ faUsers,
faVideo,
faVideoSlash,
faVolumeMute
} from '@fortawesome/free-solid-svg-icons'
// Register only these icons we need
library.add(
faComment,
faCog,
faCompress,
faCrown,
faDesktop,
faExpand,
faHandPaper,
faHeadphones,
faMicrophone,
faMicrophoneSlash,
faMicrophoneAlt,
faPowerOff,
faUser,
+ faUsers,
faVideo,
faVideoSlash,
faVolumeMute
)
let roomRequest
const authHeader = 'X-Meet-Auth-Token'
export default {
components: {
LogonForm,
RoomOptions,
StatusMessage
},
data() {
return {
setup: {
cameras: [],
microphones: [],
},
canShareScreen: false,
camera: '',
channels: [],
languages: {
en: 'English',
de: 'German',
fr: 'French',
it: 'Italian'
},
meet: null,
microphone: '',
nickname: '',
password: '',
room: null,
roomState: 'init',
roomStateLabels: {
init: 'Checking the room...',
323: 'The room is closed. Please, wait for the owner to start the session.',
324: 'The room is closed. It will be open for others after you join.',
325: 'The room is ready. Please, provide a valid password.',
326: 'The room is locked. Please, enter your name and try again.',
327: 'Waiting for permission to join the room.',
404: 'The room does not exist.',
429: 'Too many requests. Please, wait.',
500: 'Failed to connect to the room. Server error.'
},
session: {},
audioActive: false,
videoActive: false,
+ chatActive: false,
handRaised: false,
screenShareActive: false
}
},
mounted() {
this.room = this.$route.params.room
// Initialize OpenVidu and do some basic checks
this.meet = new Meet($('#meet-session')[0]);
this.canShareScreen = this.meet.isScreenSharingSupported()
// Check the room and init the session
this.initSession()
// Setup the room UI
this.setupSession()
},
beforeDestroy() {
clearTimeout(roomRequest)
$('#app').removeClass('meet')
if (this.meet) {
this.meet.leaveRoom()
}
delete axios.defaults.headers.common[authHeader]
$(document.body).off('keydown.meet')
},
methods: {
authSuccess() {
// The user authentication succeeded, we still don't know it's really the room owner
this.initSession()
$('#meet-setup').removeClass('hidden')
$('#meet-auth').addClass('hidden')
},
configUpdate(config) {
this.session.config = Object.assign({}, this.session.config, config)
},
dismissParticipant(id) {
axios.post('/api/v4/openvidu/rooms/' + this.room + '/connections/' + id + '/dismiss')
},
initSession(init) {
const button = $('#join-button').prop('disabled', true)
this.post = {
password: this.password,
nickname: this.nickname,
screenShare: this.canShareScreen ? 1 : 0,
init: init ? 1 : 0,
picture: init ? this.makePicture() : '',
requestId: this.requestId(),
canPublish: !!this.camera || !!this.microphone
}
$('#setup-password,#setup-nickname').removeClass('is-invalid')
axios.post('/api/v4/openvidu/rooms/' + this.room, this.post, { ignoreErrors: true })
.then(response => {
button.prop('disabled', false)
// We already have token, the response is redundant
if (this.roomState == 'ready' && this.session.token) {
return
}
this.roomState = 'ready'
this.session = response.data
if (init) {
this.joinSession()
}
if (this.session.authToken) {
axios.defaults.headers.common[authHeader] = this.session.authToken
}
})
.catch(error => {
if (!error.response) {
console.error(error)
return
}
const data = error.response.data || {}
if (data.code) {
this.roomState = data.code
} else {
this.roomState = error.response.status
}
button.prop('disabled', this.roomState == 'init' || this.roomState == 327 || this.roomState >= 400)
if (data.config) {
this.session.config = data.config
}
switch (this.roomState) {
case 323:
// Waiting for the owner to open the room...
// Update room state every 10 seconds
roomRequest = setTimeout(() => { this.initSession() }, 10000)
break;
case 324:
// Room is ready for the owner, but the 'init' was not requested yet
clearTimeout(roomRequest)
break;
case 325:
// Missing/invalid password
if (init) {
$('#setup-password').addClass('is-invalid').focus()
}
break;
case 326:
// Locked room prerequisites error
if (init && !$('#setup-nickname').val()) {
$('#setup-nickname').addClass('is-invalid').focus()
}
break;
case 327:
// Waiting for the owner's approval to join
// Update room state every 10 seconds
roomRequest = setTimeout(() => { this.initSession(true) }, 10000)
break;
case 429:
// Rate limited, wait and try again
const waitTime = error.response.headers['retry-after'] || 10
roomRequest = setTimeout(() => { this.initSession(init) }, waitTime * 1000)
break;
default:
if (this.roomState >= 400 && this.roomState != 404) {
this.roomState = 500
}
}
})
if (document.fullscreenEnabled) {
$('#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
},
isRoomOwner() {
return !!this.session.role && (this.session.role & Roles.OWNER) > 0
},
isRoomReady() {
return ['ready', 322, 324, 325, 326, 327].includes(this.roomState)
},
// An event received by the room owner when a participant is asking for a permission to join the room
joinRequest(data) {
// The toast for this user request already exists, ignore
// It's not really needed as we do this on server-side already
if ($('#i' + data.requestId).length) {
return
}
// FIXME: Should the message close button act as the Deny button? Do we need the Deny button?
let body = $(
`<div>`
+ `<div class="picture"><img src="${data.picture}"></div>`
+ `<div class="content">`
+ `<p class="mb-2"></p>`
+ `<div class="text-right">`
+ `<button type="button" class="btn btn-sm btn-success accept">Accept</button>`
+ `<button type="button" class="btn btn-sm btn-danger deny ml-2">Deny</button>`
)
this.$toast.message({
className: 'join-request',
icon: 'user',
timeout: 0,
title: 'Join request',
// titleClassName: '',
body: body.html(),
onShow: element => {
const id = data.requestId
$(element).find('p').text((data.nickname || '') + ' requested to join.')
// 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'
axios.post('/api/v4/openvidu/rooms/' + this.room + '/request/' + id + '/' + action)
.then(response => {
$('#i' + id).remove()
})
})
}
})
},
// Entering the room
joinSession() {
// The form can be submitted not only via the submit button,
// make sure the submit is allowed
if ($('#meet-setup [type=submit]').prop('disabled')) {
return;
}
if (this.roomState == 323) {
$('#meet-setup').addClass('hidden')
$('#meet-auth').removeClass('hidden')
return
}
if (this.roomState != 'ready' && !this.session.token) {
this.initSession(true)
return
}
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]
+ this.session.counterElement = $('#meet-counter span')[0]
this.session.onSuccess = () => {
$('#app').addClass('meet')
$('#meet-setup').addClass('hidden')
$('#meet-session-toolbar,#meet-session-layout').removeClass('hidden')
}
this.session.onError = () => {
this.roomState = 500
}
this.session.onDestroy = event => {
// TODO: Display different message for each reason: forceDisconnectByUser,
// forceDisconnectByServer, sessionClosedByServer?
if (event.reason != 'disconnect' && event.reason != 'networkDisconnect' && !this.isRoomOwner()) {
$('#leave-dialog').on('hide.bs.modal', () => {
// FIXME: Where exactly the user should land? Currently he'll land
// on dashboard (if he's logged in) or login form (if he's not).
this.$router.push({ name: 'dashboard' })
}).modal()
}
}
this.session.onDismiss = connId => { this.dismissParticipant(connId) }
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)
this.keyboardShortcuts()
},
keyboardShortcuts() {
$(document.body).on('keydown.meet', e => {
if ($(e.target).is('select,input,textarea')) {
return
}
// Self-Mute with 'm' key
if (e.key == 'm' || e.key == 'M') {
if ($('#meet-session-menu').find('.link-audio:not(:disabled)').length) {
this.switchSound()
}
}
})
},
logout() {
const logout = () => {
this.meet.leaveRoom()
this.meet = null
this.$router.push({ name: 'dashboard' })
}
if (this.isRoomOwner()) {
axios.post('/api/v4/openvidu/rooms/' + this.room + '/close').then(logout)
} else {
logout()
}
},
makePicture() {
const video = $("#meet-setup video")[0];
// Skip if video is not "playing"
if (!video.videoWidth || !this.camera) {
return ''
}
// we're going to crop a square from the video and resize it
const maxSize = 64
// Calculate sizing
let sh = Math.floor(video.videoHeight / 1.5)
let sw = sh
let sx = (video.videoWidth - sw) / 2
let sy = (video.videoHeight - sh) / 2
let dh = Math.min(sh, maxSize)
let dw = sh < maxSize ? sw : Math.floor(sw * dh/sh)
const canvas = $("<canvas>")[0];
canvas.width = dw;
canvas.height = dh;
// draw the image on the canvas (square cropped and resized)
canvas.getContext('2d').drawImage(video, sx, sy, sw, sh, 0, 0, dw, dh);
// convert it to a usable data URL (png format)
return canvas.toDataURL();
},
requestId() {
const key = 'kolab-meet-uid'
if (!this.reqId) {
this.reqId = localStorage.getItem(key)
}
if (!this.reqId) {
// We store the identifier in the browser to make sure that it is the same after
// page refresh for the avg user. This will not prevent hackers from sending
// the new identifier on every request.
// If we're afraid of a room owner being spammed with join requests we might invent
// a way to silently ignore all join requests after the owner pressed some button
// stating "all attendees already joined, lock the room for good!".
// This will create max. 24-char numeric string
this.reqId = (String(Date.now()) + String(Math.random()).substring(2)).substring(0, 24)
localStorage.setItem(key, this.reqId)
}
return this.reqId
},
roomOptions() {
$('#room-options-dialog').modal()
},
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.setupStart({
videoElement: $('#meet-setup video')[0],
volumeElement: $('#meet-setup .volume')[0],
onSuccess: setup => {
this.setup = setup
this.microphone = setup.audioSource
this.camera = setup.videoSource
this.audioActive = setup.audioActive
this.videoActive = setup.videoActive
},
onError: error => {
this.audioActive = false
this.videoActive = false
}
})
},
setupCameraChange() {
this.meet.setupSetVideoDevice(this.camera).then(enabled => {
this.videoActive = enabled
})
},
setupMicrophoneChange() {
this.meet.setupSetAudioDevice(this.microphone).then(enabled => {
this.audioActive = 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')
-
+
chat.toggleClass('open')
if (!enabled) {
chat.find('textarea').focus()
}
+ this.chatActive = !enabled
+
// Trigger resize, so participant matrix can update its layout
window.dispatchEvent(new Event('resize'));
},
switchFullscreen() {
const element = this.$el
$(element).off('fullscreenchange').on('fullscreenchange', (e) => {
let enabled = document.fullscreenElement == element
let buttons = $('#meet-session-menu').find('.link-fullscreen')
buttons.first()[enabled ? 'addClass' : 'removeClass']('hidden')
buttons.last()[!enabled ? 'addClass' : 'removeClass']('hidden')
})
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
element.requestFullscreen()
}
},
switchHand() {
this.updateSelf({ hand: !this.handRaised })
},
switchSound() {
- const enabled = this.meet.switchAudio()
- this.audioActive = enabled
+ this.audioActive = this.meet.switchAudio()
},
switchVideo() {
- const enabled = this.meet.switchVideo()
- this.videoActive = enabled
+ this.videoActive = this.meet.switchVideo()
},
switchScreen() {
const switchScreenAction = () => {
this.meet.switchScreen((enabled, error) => {
this.screenShareActive = enabled
if (!enabled && !error) {
// Closing a screen sharing connection invalidates the token
delete this.session.shareToken
}
})
}
if (this.session.shareToken || this.screenShareActive) {
switchScreenAction()
} else {
axios.post('/api/v4/openvidu/rooms/' + this.room + '/connections')
.then(response => {
this.session.shareToken = response.data.token
this.meet.updateSession(this.session)
switchScreenAction()
})
}
},
updateParticipant(connId, params) {
if (this.isModerator()) {
axios.put('/api/v4/openvidu/rooms/' + this.room + '/connections/' + connId, params)
}
},
updateSelf(params, onSuccess) {
axios.put('/api/v4/openvidu/rooms/' + this.room + '/connections/' + this.session.connectionId, params)
.then(response => {
if (onSuccess) {
onSuccess(response)
}
})
},
updateSession(data) {
this.session = data
this.channels = data.channels || []
const isPublisher = this.isPublisher()
this.videoActive = isPublisher ? data.videoActive : false
this.audioActive = isPublisher ? data.audioActive : false
-
this.handRaised = data.hand
}
}
}
</script>
diff --git a/src/resources/vue/Meet/RoomOptions.vue b/src/resources/vue/Meet/RoomOptions.vue
index 950bd752..9d435f82 100644
--- a/src/resources/vue/Meet/RoomOptions.vue
+++ b/src/resources/vue/Meet/RoomOptions.vue
@@ -1,124 +1,124 @@
<template>
<div v-if="config">
<div id="room-options-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">Room options</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<form id="room-options-password">
<div id="password-input" class="input-group input-group-activable">
<span class="input-group-text label">Password:</span>
<span v-if="config.password" id="password-input-text" class="input-group-text">{{ config.password }}</span>
<span v-else id="password-input-text" class="input-group-text text-muted">none</span>
<input type="text" :value="config.password" name="password" class="form-control rounded-left activable">
<div class="input-group-append">
<button type="button" @click="passwordSave" id="password-save-btn" class="btn btn-outline-primary activable rounded-right">Save</button>
<button type="button" v-if="config.password" id="password-clear-btn" @click="passwordClear" class="btn btn-outline-danger rounded">Clear password</button>
<button type="button" v-else @click="passwordSet" id="password-set-btn" class="btn btn-outline-primary rounded">Set password</button>
</div>
</div>
<small class="form-text text-muted">
You can add a password to your meeting. Participants will have to provide
the password before they are allowed to join the meeting.
</small>
</form>
<hr>
<form id="room-options-lock">
<div id="room-lock">
<label for="room-lock-input">Locked room:</label>
<input type="checkbox" id="room-lock-input" name="lock" value="1" :checked="config.locked" @click="lockSave">
</div>
<small class="form-text text-muted">
When the room is locked participants have to be approved by a moderator
before they could join the meeting.
</small>
</form>
<hr>
<form id="room-options-nomedia">
<div id="room-nomedia">
- <label for="room-lock-input">Subscribers only:</label>
+ <label for="room-nomedia-input">Subscribers only:</label>
<input type="checkbox" id="room-nomedia-input" name="lock" value="1" :checked="config.nomedia" @click="nomediaSave">
</div>
<small class="form-text text-muted">
Forces all participants to join as subscribers (with camera and microphone turned off).
Moderators will be able to promote them to publishers throughout the session.
</small>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-action" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
config: { type: Object, default: () => null },
room: { type: String, default: () => null }
},
data() {
return {
}
},
mounted() {
$('#room-options-dialog').on('show.bs.modal', e => {
$(e.target).find('.input-group-activable.active').removeClass('active')
})
},
methods: {
configSave(name, value, callback) {
const post = {}
post[name] = value
axios.post('/api/v4/openvidu/rooms/' + this.room + '/config', post)
.then(response => {
this.config[name] = value
if (callback) {
callback(response.data)
}
this.$emit('config-update', this.config)
this.$toast.success(response.data.message)
})
},
lockSave(e) {
this.configSave('locked', $(e.target).prop('checked') ? 1 : 0)
},
nomediaSave(e) {
this.configSave('nomedia', $(e.target).prop('checked') ? 1 : 0)
},
passwordClear() {
this.configSave('password', '')
},
passwordSave() {
this.configSave('password', $('#password-input input').val(), () => {
$('#password-input').removeClass('active')
})
},
passwordSet() {
$('#password-input').addClass('active').find('input')
.off('keydown.pass')
.on('keydown.pass', e => {
if (e.which == 13) {
// On ENTER save the password
this.passwordSave()
e.preventDefault()
} else if (e.which == 27) {
// On ESC escape from the input, but not the dialog
$('#password-input').removeClass('active')
e.stopPropagation()
}
})
.focus()
}
}
}
</script>
diff --git a/src/tests/Browser/Meet/RoomControlsTest.php b/src/tests/Browser/Meet/RoomControlsTest.php
index 7222629b..b3b31416 100644
--- a/src/tests/Browser/Meet/RoomControlsTest.php
+++ b/src/tests/Browser/Meet/RoomControlsTest.php
@@ -1,377 +1,377 @@
<?php
namespace Tests\Browser\Meet;
use App\OpenVidu\Room;
use Tests\Browser;
use Tests\Browser\Pages\Meet\Room as RoomPage;
use Tests\TestCaseDusk;
class RoomControlsTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->setupTestRoom();
}
public function tearDown(): void
{
$this->resetTestRoom();
parent::tearDown();
}
/**
* Test fullscreen buttons
*
* @group openvidu
*/
public function testFullscreen(): void
{
// TODO: This test does not work in headless mode
$this->markTestIncomplete();
/*
$this->browse(function (Browser $browser) {
// Join the room as an owner (authenticate)
$browser->visit(new RoomPage('john'))
->click('@setup-button')
->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@setup-form')
->assertVisible('@login-form')
->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->assertMissing('@login-form')
->waitUntilMissing('@setup-status-message.loading')
->click('@setup-button')
->waitFor('@session')
// Test fullscreen for the whole room
->click('@menu button.link-fullscreen.closed')
->assertVisible('@toolbar')
->assertVisible('@session')
->assertMissing('nav')
->assertMissing('@menu button.link-fullscreen.closed')
->click('@menu button.link-fullscreen.open')
->assertVisible('nav')
// Test fullscreen for the participant video
->click('@session button.link-fullscreen.closed')
->assertVisible('@session')
->assertMissing('@toolbar')
->assertMissing('nav')
->assertMissing('@session button.link-fullscreen.closed')
->click('@session button.link-fullscreen.open')
->assertVisible('nav')
->assertVisible('@toolbar');
});
*/
}
/**
* Test nickname and muting audio/video
*
* @group openvidu
*/
public function testNicknameAndMuting(): void
{
$this->browse(function (Browser $owner, Browser $guest) {
// Join the room as an owner (authenticate)
$owner->visit(new RoomPage('john'))
->click('@setup-button')
->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'john')
->keys('@setup-nickname-input', '{enter}') // Test form submit with Enter key
->waitFor('@session');
// In another browser act as a guest
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertMissing('@setup-status-message')
->assertSeeIn('@setup-button', "JOIN")
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
//->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Assert current UI state
$owner->assertToolbar([
- 'audio' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
- 'video' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
+ 'audio' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
+ 'video' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
- 'fullscreen' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
- 'options' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
- 'logout' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
+ 'fullscreen' => RoomPage::BUTTON_ENABLED,
+ 'options' => RoomPage::BUTTON_ENABLED,
+ 'logout' => RoomPage::BUTTON_ENABLED,
])
->whenAvailable('div.meet-video.self', function (Browser $browser) {
$browser->waitFor('video')
->assertAudioMuted('video', true)
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertMissing('.controls button.link-audio')
->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->assertVisible('.meet-nickname')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2);
// Assert current UI state
$guest->assertToolbar([
- 'audio' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
- 'video' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
+ 'audio' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
+ 'video' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
- 'fullscreen' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
- 'logout' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
+ 'fullscreen' => RoomPage::BUTTON_ENABLED,
+ 'logout' => RoomPage::BUTTON_ENABLED,
])
->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
->whenAvailable('div.meet-video.self', function (Browser $browser) {
$browser->waitFor('video')
->assertVisible('.controls button.link-fullscreen')
->assertMissing('.controls button.link-audio')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2);
// Test nickname change propagation
$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');
// Test muting audio
$owner->click('@menu button.link-audio')
- ->assertToolbarButtonState('audio', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
+ ->assertToolbarButtonState('audio', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
->assertVisible('div.meet-video.self .status .status-audio');
// FIXME: It looks that we can't just check the <video> element state
// We might consider using OpenVidu API to make sure
$guest->waitFor('div.meet-video:not(.self) .status .status-audio');
// Test unmuting audio
$owner->click('@menu button.link-audio')
- ->assertToolbarButtonState('audio', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
+ ->assertToolbarButtonState('audio', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
->assertMissing('div.meet-video.self .status .status-audio');
$guest->waitUntilMissing('div.meet-video:not(.self) .status .status-audio');
// Test muting audio with a keyboard shortcut (key 'm')
$owner->driver->getKeyboard()->sendKeys('m');
- $owner->assertToolbarButtonState('audio', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
+ $owner->assertToolbarButtonState('audio', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
->assertVisible('div.meet-video.self .status .status-audio');
$guest->waitFor('div.meet-video:not(.self) .status .status-audio')
->assertAudioMuted('div.meet-video:not(.self) video', true);
// Test unmuting audio with a keyboard shortcut (key 'm')
$owner->driver->getKeyboard()->sendKeys('m');
- $owner->assertToolbarButtonState('audio', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
+ $owner->assertToolbarButtonState('audio', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
->assertMissing('div.meet-video.self .status .status-audio');
$guest->waitUntilMissing('div.meet-video:not(.self) .status .status-audio')
->assertAudioMuted('div.meet-video:not(.self) video', false);
// Test muting video
$owner->click('@menu button.link-video')
- ->assertToolbarButtonState('video', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
+ ->assertToolbarButtonState('video', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
->assertVisible('div.meet-video.self .status .status-video');
// FIXME: It looks that we can't just check the <video> element state
// We might consider using OpenVidu API to make sure
$guest->waitFor('div.meet-video:not(.self) .status .status-video');
// Test unmuting video
$owner->click('@menu button.link-video')
- ->assertToolbarButtonState('video', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
+ ->assertToolbarButtonState('video', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
->assertMissing('div.meet-video.self .status .status-video');
$guest->waitUntilMissing('div.meet-video:not(.self) .status .status-video');
// Test muting other user
$guest->with('div.meet-video:not(.self)', function (Browser $browser) {
$browser->click('.controls button.link-audio')
->assertAudioMuted('video', true)
->assertVisible('.controls button.link-audio.text-danger')
->click('.controls button.link-audio')
->assertAudioMuted('video', false)
->assertVisible('.controls button.link-audio:not(.text-danger)');
});
});
}
/**
* Test text chat
*
* @group openvidu
*/
public function testChat(): void
{
$this->browse(function (Browser $owner, Browser $guest) {
// Join the room as an owner
$owner->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'john')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// In another browser act as a guest
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertMissing('@setup-status-message')
->assertSeeIn('@setup-button', "JOIN")
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
// ->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Test chat elements
$owner->click('@menu button.link-chat')
->assertToolbarButtonState('chat', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
->assertVisible('@chat')
->assertVisible('@session')
->assertFocused('@chat-input')
->assertElementsCount('@chat-list .message', 0)
->keys('@chat-input', 'test1', '{enter}')
->assertValue('@chat-input', '')
->waitFor('@chat-list .message')
->assertElementsCount('@chat-list .message', 1)
->assertSeeIn('@chat-list .message .nickname', 'john')
->assertSeeIn('@chat-list .message div:last-child', 'test1');
$guest->waitFor('@menu button.link-chat .badge')
->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')
->assertVisible('@chat')
->assertVisible('@session')
->assertElementsCount('@chat-list .message', 1)
->assertSeeIn('@chat-list .message .nickname', 'john')
->assertSeeIn('@chat-list .message div:last-child', 'test1');
// Test the number of (hidden) incoming messages
$guest->click('@menu button.link-chat')
->assertMissing('@chat');
$owner->keys('@chat-input', 'test2', '{enter}', 'test3', '{enter}')
->assertElementsCount('@chat-list .message', 1)
->assertSeeIn('@chat-list .message .nickname', 'john')
->assertElementsCount('@chat-list .message div', 4)
->assertSeeIn('@chat-list .message div:last-child', 'test3');
$guest->waitFor('@menu button.link-chat .badge')
->assertSeeIn('@menu button.link-chat .badge', '2')
->click('@menu button.link-chat')
->assertElementsCount('@chat-list .message', 1)
->assertSeeIn('@chat-list .message .nickname', 'john')
->assertSeeIn('@chat-list .message div:last-child', 'test3')
->keys('@chat-input', 'guest1', '{enter}')
->assertElementsCount('@chat-list .message', 2)
->assertMissing('@chat-list .message:last-child .nickname')
->assertSeeIn('@chat-list .message:last-child div:last-child', 'guest1');
$owner->assertElementsCount('@chat-list .message', 2)
->assertMissing('@chat-list .message:last-child .nickname')
->assertSeeIn('@chat-list .message:last-child div:last-child', 'guest1');
// Test nickname change is propagated to chat messages
$guest->setNickname('div.meet-video.self', 'guest')
->keys('@chat-input', 'guest2', '{enter}')
->assertElementsCount('@chat-list .message', 2)
->assertSeeIn('@chat-list .message:last-child .nickname', 'guest')
->assertSeeIn('@chat-list .message:last-child div:last-child', 'guest2');
$owner->assertElementsCount('@chat-list .message', 2)
->assertSeeIn('@chat-list .message:last-child .nickname', 'guest')
->assertSeeIn('@chat-list .message:last-child div:last-child', 'guest2');
// TODO: Test text chat features, e.g. link handling
});
}
/**
* Test screen sharing
*
* @group openvidu
*/
public function testShareScreen(): void
{
$this->browse(function (Browser $owner, Browser $guest) {
// Join the room as an owner
$owner->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'john')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// In another browser act as a guest
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Test screen sharing
$owner->assertToolbarButtonState('screen', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
->assertElementsCount('@session div.meet-video', 1)
->click('@menu button.link-screen')
->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@subscribers .meet-subscriber', 1)
->assertToolbarButtonState('screen', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED);
$guest
->whenAvailable('div.meet-video.screen', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@subscribers .meet-subscriber', 1);
});
}
}
diff --git a/src/tests/Browser/Meet/RoomSetupTest.php b/src/tests/Browser/Meet/RoomSetupTest.php
index 5acc6848..b83f50fb 100644
--- a/src/tests/Browser/Meet/RoomSetupTest.php
+++ b/src/tests/Browser/Meet/RoomSetupTest.php
@@ -1,569 +1,574 @@
<?php
namespace Tests\Browser\Meet;
use App\OpenVidu\Room;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Menu;
use Tests\Browser\Pages\Meet\Room as RoomPage;
use Tests\TestCaseDusk;
class RoomSetupTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->setupTestRoom();
}
public function tearDown(): void
{
$this->resetTestRoom();
parent::tearDown();
}
/**
* Test non-existing room
*
* @group openvidu
*/
public function testRoomNonExistingRoom(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new RoomPage('unknown'))
->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']);
});
if ($browser->isDesktop()) {
$browser->within(new Menu('footer'), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'tos', 'login']);
});
} else {
$browser->assertMissing('#footer-menu .navbar-nav');
}
// FIXME: Maybe it would be better to just display the usual 404 Not Found error page?
$browser->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@login-form')
->assertVisible('@setup-form')
->assertSeeIn('@setup-status-message', "The room does not exist.")
->assertButtonDisabled('@setup-button');
});
}
/**
* Test the room setup page
*
* @group openvidu
*/
public function testRoomSetup(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new RoomPage('john'))
->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']);
});
if ($browser->isDesktop()) {
$browser->within(new Menu('footer'), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'tos', 'login']);
});
} else {
$browser->assertMissing('#footer-menu .navbar-nav');
}
// Note: I've found out that if I have another Chrome instance running
// that uses media, here the media devices will not be available
// TODO: Test enabling/disabling cam/mic in the setup widget
$browser->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@login-form')
->assertVisible('@setup-form')
->assertSeeIn('@setup-title', 'Set up your session')
->assertVisible('@setup-video')
->assertVisible('@setup-form .input-group:nth-child(1) svg')
->assertAttribute('@setup-form .input-group:nth-child(1) .input-group-text', 'title', 'Microphone')
->assertVisible('@setup-mic-select')
->assertVisible('@setup-form .input-group:nth-child(2) svg')
->assertAttribute('@setup-form .input-group:nth-child(2) .input-group-text', 'title', 'Camera')
->assertVisible('@setup-cam-select')
->assertVisible('@setup-form .input-group:nth-child(3) svg')
->assertAttribute('@setup-form .input-group:nth-child(3) .input-group-text', 'title', 'Nickname')
->assertValue('@setup-nickname-input', '')
->assertAttribute('@setup-nickname-input', 'placeholder', 'Your name')
->assertMissing('@setup-password-input')
->assertSeeIn(
'@setup-status-message',
"The room is closed. Please, wait for the owner to start the session."
)
->assertSeeIn('@setup-button', "I'm the owner");
});
}
/**
* Test two users in a room (joining/leaving and some basic functionality)
*
* @group openvidu
* @depends testRoomSetup
*/
public function testTwoUsersInARoom(): void
{
$this->browse(function (Browser $browser, Browser $guest) {
// In one browser window act as a guest
$guest->visit(new RoomPage('john'))
->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@login-form')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertSeeIn(
'@setup-status-message',
"The room is closed. Please, wait for the owner to start the session."
)
->assertSeeIn('@setup-button', "I'm the owner");
// In another window join the room as the owner (authenticate)
$browser->on(new RoomPage('john'))
->assertSeeIn('@setup-button', "I'm the owner")
->clickWhenEnabled('@setup-button')
->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@setup-form')
->assertVisible('@login-form')
->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout']);
});
if ($browser->isDesktop()) {
$browser->within(new Menu('footer'), function ($browser) {
$browser->assertMenuItems(['explore', 'blog', 'support', 'tos', 'dashboard', 'logout']);
});
}
$browser->assertMissing('@login-form')
->waitUntilMissing('@setup-status-message.loading')
->waitFor('@setup-status-message')
->assertSeeIn('@setup-status-message', "The room is closed. It will be open for others after you join.")
->assertSeeIn('@setup-button', "JOIN")
->type('@setup-nickname-input', 'john')
// Join the room (click the button twice, to make sure it does not
// produce redundant participants/subscribers in the room)
->clickWhenEnabled('@setup-button')
->pause(10)
->click('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->whenAvailable('div.meet-video.self', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertMissing('.controls button.link-audio')
->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
- ->assertMissing('#header-menu');
+ ->assertMissing('#header-menu')
+ ->assertSeeIn('@counter', 1);
if (!$browser->isPhone()) {
$browser->assertMissing('#footer-menu');
} else {
$browser->assertVisible('#footer-menu');
}
// After the owner "opened the room" guest should be able to join
$guest->waitUntilMissing('@setup-status-message', 10)
->assertSeeIn('@setup-button', "JOIN")
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
//->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->whenAvailable('div.meet-video.self', function (Browser $browser) {
$browser->waitFor('video')
->assertVisible('.meet-nickname')
->assertVisible('.controls button.link-fullscreen')
->assertMissing('.controls button.link-audio')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
- ->assertElementsCount('@session div.meet-video', 2);
+ ->assertElementsCount('@session div.meet-video', 2)
+ ->assertSeeIn('@counter', 2);
// Check guest's elements in the owner's window
$browser
->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->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');
})
->assertElementsCount('@session div.meet-video', 2);
// Test leaving the room
// Guest is leaving
$guest->click('@menu button.link-logout')
->waitForLocation('/login')
->assertVisible('#header-menu');
// Expect the participant removed from other users windows
- $browser->waitUntilMissing('@session div.meet-video:not(.self)');
+ $browser->waitUntilMissing('@session div.meet-video:not(.self)')
+ ->assertSeeIn('@counter', 1);
// Join the room as guest again
$guest->visit(new RoomPage('john'))
->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@login-form')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertMissing('@setup-status-message')
->assertSeeIn('@setup-button', "JOIN")
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
//->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Leave the room as the room owner
// TODO: Test leaving the room by closing the browser window,
// it should not destroy the session
$browser->click('@menu button.link-logout')
->waitForLocation('/dashboard');
// Expect other participants be informed about the end of the session
$guest->with(new Dialog('#leave-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Room closed')
->assertSeeIn('@body', "The session has been closed by the room owner.")
->assertMissing('@button-cancel')
->assertSeeIn('@button-action', 'Close')
->click('@button-action');
})
->assertMissing('#leave-dialog')
->waitForLocation('/login');
});
}
/**
* Test two subscribers-only users in a room
*
* @group openvidu
* @depends testTwoUsersInARoom
*/
public function testSubscribers(): void
{
$this->browse(function (Browser $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')
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->whenAvailable('@subscribers .meet-subscriber.self', function (Browser $browser) {
$browser->assertSeeIn('.meet-nickname', 'john');
})
->assertElementsCount('@session div.meet-video', 0)
->assertElementsCount('@session video', 0)
->assertElementsCount('@session .meet-subscriber', 1)
->assertToolbar([
- 'audio' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED,
- 'video' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED,
+ 'audio' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_DISABLED,
+ 'video' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_DISABLED,
'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED,
'hand' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
- 'fullscreen' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
- 'options' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
- 'logout' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
+ 'fullscreen' => RoomPage::BUTTON_ENABLED,
+ 'options' => RoomPage::BUTTON_ENABLED,
+ 'logout' => RoomPage::BUTTON_ENABLED,
]);
// After the owner "opened the room" guest should be able to join
// In one browser window act as a guest
$guest->visit(new RoomPage('john'))
->waitUntilMissing('@setup-status-message', 10)
->assertSeeIn('@setup-button', "JOIN")
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->whenAvailable('@subscribers .meet-subscriber.self', function (Browser $browser) {
$browser->assertVisible('.meet-nickname');
})
->whenAvailable('@subscribers .meet-subscriber:not(.self)', function (Browser $browser) {
$browser->assertSeeIn('.meet-nickname', 'john');
})
->assertElementsCount('@session div.meet-video', 0)
->assertElementsCount('@session video', 0)
->assertElementsCount('@session div.meet-subscriber', 2)
+ ->assertSeeIn('@counter', 2)
->assertToolbar([
- 'audio' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED,
- 'video' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED,
+ 'audio' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_DISABLED,
+ 'video' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_DISABLED,
'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED,
'hand' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
- 'fullscreen' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
- 'logout' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
+ 'fullscreen' => RoomPage::BUTTON_ENABLED,
+ 'logout' => RoomPage::BUTTON_ENABLED,
]);
// Check guest's elements in the owner's window
$browser
->whenAvailable('@subscribers .meet-subscriber:not(.self)', function (Browser $browser) {
$browser->assertVisible('.meet-nickname');
})
->assertElementsCount('@session div.meet-video', 0)
->assertElementsCount('@session video', 0)
- ->assertElementsCount('@session .meet-subscriber', 2);
+ ->assertElementsCount('@session .meet-subscriber', 2)
+ ->assertSeeIn('@counter', 2);
// Test leaving the room
// Guest is leaving
$guest->click('@menu button.link-logout')
->waitForLocation('/login');
// Expect the participant removed from other users windows
$browser->waitUntilMissing('@session .meet-subscriber:not(.self)');
});
}
/**
* Test demoting publisher to a subscriber
*
* @group openvidu
* @depends testSubscribers
*/
public function testDemoteToSubscriber(): void
{
$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
->with(new Dialog('#media-setup-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Media setup')
->click('@button-action');
})
->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')
->with(new Dialog('#media-setup-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Media setup')
->click('@button-action');
})
->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
{
$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/Pages/Meet/Room.php b/src/tests/Browser/Pages/Meet/Room.php
index 09fe5396..e2662977 100644
--- a/src/tests/Browser/Pages/Meet/Room.php
+++ b/src/tests/Browser/Pages/Meet/Room.php
@@ -1,223 +1,225 @@
<?php
namespace Tests\Browser\Pages\Meet;
use Laravel\Dusk\Page;
use PHPUnit\Framework\Assert;
class Room extends Page
{
public const BUTTON_ACTIVE = 1;
public const BUTTON_ENABLED = 2;
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;
/**
* Object constructor.
*
* @param string $name Room name
*/
public function __construct($name)
{
$this->roomName = $name;
}
/**
* Get the URL for the page.
*
* @return string
*/
public function url()
{
return '/meet/' . $this->roomName;
}
/**
* Assert that the browser is on the page.
*
* @param \Laravel\Dusk\Browser $browser The browser object
*
* @return void
*/
public function assert($browser)
{
$browser->waitForLocation($this->url())
->waitUntilMissing('.app-loader')
->waitUntilMissing('#meet-setup div.status-message.loading');
}
/**
* Get the element shortcuts for the page.
*
* @return array
*/
public function elements()
{
return [
'@app' => '#app',
'@setup-form' => '#meet-setup form',
'@setup-title' => '#meet-setup .card-title',
'@setup-mic-select' => '#setup-microphone',
'@setup-cam-select' => '#setup-camera',
'@setup-nickname-input' => '#setup-nickname',
'@setup-password-input' => '#setup-password',
'@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',
'@toolbar' => '#meet-session-toolbar',
'@menu' => '#meet-session-menu',
+ '@counter' => '#meet-counter',
+
'@session' => '#meet-session',
'@subscribers' => '#meet-subscribers',
'@queue' => '#meet-queue',
'@chat' => '#meet-chat',
'@chat-input' => '#meet-chat textarea',
'@chat-list' => '#meet-chat .chat',
'@login-form' => '#meet-auth',
'@login-email-input' => '#inputEmail',
'@login-password-input' => '#inputPassword',
'@login-second-factor-input' => '#secondfactor',
'@login-button' => '#meet-auth button',
];
}
/**
* Assert menu state.
*
* @param \Tests\Browser $browser The browser object
* @param array $menu Menu items/state
*/
public function assertToolbar($browser, array $menu): void
{
$browser->assertElementsCount('@menu button', count($menu));
foreach ($menu as $item => $state) {
$this->assertToolbarButtonState($browser, $item, $state);
}
}
/**
* Assert menu button state.
*
* @param \Tests\Browser $browser The browser object
* @param string $button Button name
* @param int $state Expected button state (sum of BUTTON_* consts)
*/
public function assertToolbarButtonState($browser, $button, $state): void
{
$class = '';
if ($state & self::BUTTON_ACTIVE) {
- $class .= ':not(.text-danger)';
+ $class .= '.on';
}
if ($state & self::BUTTON_INACTIVE) {
- $class .= '.text-danger';
+ $class .= ':not(.on)';
}
if ($state & self::BUTTON_DISABLED) {
$class .= '[disabled]';
}
if ($state & self::BUTTON_ENABLED) {
$class .= ':not([disabled])';
}
$browser->assertVisible('@menu button.link-' . $button . $class);
}
/**
* Assert the <video> element's 'muted' property state
*
* @param \Tests\Browser $browser The browser object
* @param string $selector Video element selector
* @param bool $state Expected state
*/
public function assertAudioMuted($browser, $selector, $state): void
{
$selector = addslashes($browser->resolver->format($selector));
$result = $browser->script(
"var video = document.querySelector('$selector'); return video.muted"
);
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
*
* @param \Tests\Browser $browser The browser object
* @param string $selector Participant element selector
* @param string $nickname Nickname
*/
public function setNickname($browser, $selector, $nickname): void
{
$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 }))"
);
}
/**
* Submit logon form.
*
* @param \Tests\Browser $browser The browser object
* @param string $username User name
* @param string $password User password
* @param array $config Client-site config
*/
public function submitLogon($browser, $username, $password, $config = []): void
{
$browser->type('@login-email-input', $username)
->type('@login-password-input', $password);
if ($username == 'ned@kolab.org') {
$code = \App\Auth\SecondFactor::code('ned@kolab.org');
$browser->type('@login-second-factor-input', $code);
}
if (!empty($config)) {
$browser->script(
sprintf('Object.assign(window.config, %s)', \json_encode($config))
);
}
$browser->click('@login-button');
}
}

File Metadata

Mime Type
text/x-diff
Expires
Thu, Feb 5, 8:08 AM (4 h, 5 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
427851
Default Alt Text
(166 KB)

Event Timeline