diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js
index b7d35af3..060ac648 100644
--- a/src/resources/js/meet/app.js
+++ b/src/resources/js/meet/app.js
@@ -1,1216 +1,1238 @@
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 setupProps
let subscribersContainer
OV = new OpenVidu()
screenOV = new OpenVidu()
// If there's anything to do, do it here.
//OV.setAdvancedConfiguration(config)
// Disable all logging except errors
// OV.enableProdMode()
// 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.setup = setup
this.setupSetAudioDevice = setupSetAudioDevice
this.setupSetVideoDevice = setupSetVideoDevice
this.switchAudio = switchAudio
this.switchScreen = switchScreen
this.switchVideo = switchVideo
this.updateSession = updateSession
/**
* Join the room session
*
* @param data Session metadata and event handlers (token, shareToken, nickname, role,
* chatElement, menuElement, onDestroy, onJoinRequest, onDismiss, onConnectionChange,
* onSessionDataUpdate)
*/
function joinRoom(data) {
resize();
volumeMeterStop()
data.params = {
nickname: data.nickname, // user nickname
// avatar: undefined // avatar image
}
// Create a container for subscribers
if (!subscribersContainer) {
subscribersContainer = $('
').appendTo(container).get(0)
}
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.
// Note: For a user with a subscriber role 'streamCreated' event
// is not being dispatched at all
let metadata = connectionData(event.connection)
- let connectionId = event.connection.connectionId
- metadata.connectionId = connectionId
metadata.element = participantCreate(metadata)
- connections[connectionId] = metadata
+ connections[metadata.connectionId] = metadata
resize()
// 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 conn = connections[event.connection.connectionId]
if (conn) {
$(conn.element).remove()
delete connections[event.connection.connectionId]
}
resize()
})
// On every new Stream received...
session.on('streamCreated', event => {
- let connection = event.stream.connection
- let connectionId = connection.connectionId
- let metadata = connectionData(connection)
- let wrapper = connections[connectionId].element
+ 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, wrapper, props);
+ let subscriber = session.subscribe(event.stream, metadata.element, props);
subscriber.on('videoElementCreated', event => {
$(event.element).prop({
tabindex: -1
})
resize()
})
-/*
- subscriber.on('videoElementDestroyed', event => {
- })
-*/
+
+ metadata.audioActive = event.stream.audioActive
+ metadata.videoActive = event.stream.videoActive
+
// Update the wrapper controls/status
- participantUpdate(wrapper, event.stream)
+ participantUpdate(metadata.element, metadata)
})
-/*
- session.on('streamDestroyed', event => {
+
+ // Stream properties changed e.g. audio/video muted/unmuted
+ session.on('streamPropertyChanged', event => {
+ let connectionId = event.target.connection.connectionId
+ let metadata = connections[connectionId]
+
+ if (session.connection.connectionId == connectionId) {
+ metadata = sessionData
+ }
+
+ if (metadata) {
+ metadata[event.changedProperty] = event.newValue
+ participantUpdate(metadata.element, metadata)
+ }
})
-*/
+
// Handle session disconnection events
session.on('sessionDisconnected', event => {
if (data.onDestroy) {
data.onDestroy(event)
}
resize()
})
// Handle signals from all participants
session.on('signal', signalEventHandler)
// Connect with the token
session.connect(data.token, data.params)
.then(() => {
- let params = { self: true, role: data.role, audioActive, videoActive }
+ 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)
}
resize()
sessionData.element = wrapper
})
.catch(error => {
console.error('There was an error connecting to the session: ', error.message);
})
// Prepare the chat
setupChat()
}
/**
* Leave the room (disconnect)
*/
function leaveRoom() {
if (publisher) {
volumeMeterStop()
// FIXME: We have to unpublish streams only if there's no session yet
if (!session && audioActive) {
publisher.publishAudio(false)
}
if (!session && videoActive) {
publisher.publishVideo(false)
}
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 setup(props) {
setupProps = props
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
})
})
}
/**
* 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 oldTrack = mediaStream.getAudioTracks()[0]
let properties = Object.assign({}, publisherDefaults, {
publishAudio: true,
publishVideo: videoActive,
audioSource: deviceId,
videoSource: videoSource
})
volumeMeterStop()
// Note: We're not using publisher.replaceTrack() as it wasn't working for me
// Stop and remove the old track
if (oldTrack) {
oldTrack.stop()
mediaStream.removeTrack(oldTrack)
}
// TODO: Handle errors
await OV.getUserMedia(properties)
.then(async (newMediaStream) => {
publisher.stream.mediaStream = newMediaStream
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 oldTrack = mediaStream.getAudioTracks()[0]
let properties = Object.assign({}, publisherDefaults, {
publishAudio: audioActive,
publishVideo: true,
audioSource: audioSource,
videoSource: deviceId
})
volumeMeterStop()
// Stop and remove the old track
if (oldTrack) {
oldTrack.stop()
mediaStream.removeTrack(oldTrack)
}
// TODO: Handle errors
await OV.getUserMedia(properties)
.then(async (newMediaStream) => {
publisher.stream.mediaStream = newMediaStream
volumeMeterStart()
videoActive = true
videoSource = deviceId
})
}
return videoActive
}
/**
* 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 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('')
.on('click', () => {
button.find('.badge').text('')
chatCount = 0
})
}
/**
* 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
- // TODO: Use 'streamPropertyChanged' event to handle audio/video mute/unmute state changes
+ // TODO: Use 'signal:connectionUpdate' for nickname updates?
if (conn = connections[connId]) {
data = JSON.parse(signal.data)
- participantUpdate(conn.element, 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 = $('').text(data.message).text() // make the message secure
// Format the message, convert emails and urls to links
message = anchorme({
input: message,
options: {
attributes: {
target: "_blank"
},
// any link above 20 characters will be truncated
// to 20 characters and ellipses at the end
truncate: 20,
// characters will be taken out of the middle
middleTruncation: true
}
// TODO: anchorme is extensible, we could support
// github/phabricator's markup e.g. backticks for code samples
})
message = message.replace(/\r?\n/, ' ')
// Display the message
let isSelf = data.id == session.connectionId
let chat = $(sessionData.chatElement).find('.chat')
let box = chat.find('.message').last()
message = $('
').html(message)
message.find('a').attr('rel', 'noreferrer')
if (box.length && box.data('id') == data.id) {
// A message from the same user as the last message, no new box needed
message.appendTo(box)
} else {
box = $('
').data('id', data.id)
.append($('
').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 : '')
}
/**
* 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 = {
- audioActive,
- videoActive,
nickname: sessionData.params.nickname
}
- // Note: StreamPropertyChangedEvent might be more standard way
- // to propagate the audio/video state change to other users.
- // It looks there's no other way to propagate nickname changes.
session.signal({
data: JSON.stringify(data),
type: 'userChanged',
to: connection ? [connection] : undefined
})
// The same nickname for screen sharing session
if (screenSession) {
- data.audioActive = false
- data.videoActive = true
screenSession.signal({
data: JSON.stringify(data),
type: 'userChanged',
to: connection ? [connection] : undefined
})
}
}
/**
* 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
- participantUpdate(sessionData.element, { audioActive })
- signalUserUpdate()
} 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
- participantUpdate(sessionData.element, { videoActive })
- signalUserUpdate()
} catch (e) {
console.error(e)
}
}
return videoActive
}
/**
* Switch on/off screen sharing
*/
function switchScreen(callback) {
if (screenPublisher) {
screenSession.disconnect()
screenSession = null
screenPublisher = null
if (callback) {
// Note: Disconnecting invalidates the token. The callback should request
// a new token for the next screen sharing session.
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]
// It's me
if (session.connection.connectionId == data.connectionId) {
const rolePublisher = data.role && data.role & Roles.PUBLISHER
const isPublisher = sessionData.role & Roles.PUBLISHER
// Inform the vue component, so it can update some UI controls
let update = () => {
if (sessionData.onSessionDataUpdate) {
sessionData.onSessionDataUpdate(data)
}
}
// demoted to a subscriber
if ('role' in data && isPublisher && !rolePublisher) {
session.unpublish(publisher)
// 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)
}
// merge the changed data into internal session metadata object
Object.keys(data).forEach(key => { sessionData[key] = data[key] })
- // TODO: do "it's me" detection based on connection id instead
- sessionData.self = true
-
// update the participant element
sessionData.element = participantUpdate(sessionData.element, sessionData)
// promoted to a publisher
if ('role' in data && !isPublisher && rolePublisher) {
publisher.createVideoElement(sessionData.element, 'PREPEND')
session.publish(publisher).then(() => {
data.audioActive = publisher.stream.audioActive
data.videoActive = publisher.stream.videoActive
update()
})
// TODO: Here the user is asked for media permissions again
// should we rather start the stream without asking the user?
// Or maybe we want to display the media setup/preview form?
// Need to find a way to do this.
} else {
// Inform the vue component, so it can update some UI controls
update()
}
} else if (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 nickname in chat
*
* @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 || '')
}
})
}
}
/**
* 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
*
* @return The element
*/
function participantCreate(params) {
if (params.role & Roles.PUBLISHER || params.role & Roles.SCREEN) {
return publisherCreate(params)
}
return subscriberCreate(params)
}
/**
* Create a