Changeset View
Changeset View
Standalone View
Standalone View
src/resources/js/meet/app.js
Show All 11 Lines | |||||
function Meet(container) | function Meet(container) | ||||
{ | { | ||||
let OV // OpenVidu object to initialize a session | let OV // OpenVidu object to initialize a session | ||||
let session // Session object where the user will connect | let session // Session object where the user will connect | ||||
let publisher // Publisher object which the user will publish | let publisher // Publisher object which the user will publish | ||||
let audioActive = false // True if the audio track of the publisher is active | 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 videoActive = false // True if the video track of the publisher is active | ||||
let numOfVideos = 0 // Keeps track of the number of videos that are being shown | |||||
let audioSource = '' // Currently selected microphone | let audioSource = '' // Currently selected microphone | ||||
let videoSource = '' // Currently selected camera | let videoSource = '' // Currently selected camera | ||||
let sessionData // Room session metadata | let sessionData // Room session metadata | ||||
let screenOV // OpenVidu object to initialize a screen sharing session | let screenOV // OpenVidu object to initialize a screen sharing session | ||||
let screenSession // Session object where the user will connect for screen sharing | 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 screenPublisher // Publisher object which the user will publish the screen sharing | ||||
let publisherDefaults = { | let publisherDefaults = { | ||||
publishAudio: true, // Whether to start publishing with your audio unmuted or not | publishAudio: true, // Whether to start publishing with your audio unmuted or not | ||||
publishVideo: true, // Whether to start publishing with your video enabled or not | publishVideo: true, // Whether to start publishing with your video enabled or not | ||||
resolution: '640x480', // The resolution of your video | resolution: '640x480', // The resolution of your video | ||||
frameRate: 30, // The frame rate of your video | frameRate: 30, // The frame rate of your video | ||||
mirror: true // Whether to mirror your local video or not | mirror: true // Whether to mirror your local video or not | ||||
} | } | ||||
let cameras = [] // List of user video devices | let cameras = [] // List of user video devices | ||||
let microphones = [] // List of user audio devices | let microphones = [] // List of user audio devices | ||||
let connections = {} // Connected users in the session | let connections = {} // Connected users in the session | ||||
let containerWidth | let containerWidth | ||||
let containerHeight | let containerHeight | ||||
let chatCount = 0 | let chatCount = 0 | ||||
let volumeElement | let volumeElement | ||||
let setupProps | |||||
let subscribersContainer | let subscribersContainer | ||||
OV = new OpenVidu() | OV = new OpenVidu() | ||||
screenOV = new OpenVidu() | screenOV = new OpenVidu() | ||||
// If there's anything to do, do it here. | // If there's anything to do, do it here. | ||||
//OV.setAdvancedConfiguration(config) | //OV.setAdvancedConfiguration(config) | ||||
// Disable all logging except errors | // Disable all logging except errors | ||||
// OV.enableProdMode() | // OV.enableProdMode() | ||||
// Disconnect participant when browser's window close | // Disconnect participant when browser's window close | ||||
window.addEventListener('beforeunload', () => { | window.addEventListener('beforeunload', () => { | ||||
leaveRoom() | leaveRoom() | ||||
}) | }) | ||||
window.addEventListener('resize', resize) | window.addEventListener('resize', resize) | ||||
// Public methods | // Public methods | ||||
this.isScreenSharingSupported = isScreenSharingSupported | this.isScreenSharingSupported = isScreenSharingSupported | ||||
this.joinRoom = joinRoom | this.joinRoom = joinRoom | ||||
this.leaveRoom = leaveRoom | this.leaveRoom = leaveRoom | ||||
this.setup = setup | this.setupStart = setupStart | ||||
this.setupStop = setupStop | |||||
this.setupSetAudioDevice = setupSetAudioDevice | this.setupSetAudioDevice = setupSetAudioDevice | ||||
this.setupSetVideoDevice = setupSetVideoDevice | this.setupSetVideoDevice = setupSetVideoDevice | ||||
this.switchAudio = switchAudio | this.switchAudio = switchAudio | ||||
this.switchScreen = switchScreen | this.switchScreen = switchScreen | ||||
this.switchVideo = switchVideo | this.switchVideo = switchVideo | ||||
this.updateSession = updateSession | this.updateSession = updateSession | ||||
/** | /** | ||||
* Join the room session | * Join the room session | ||||
* | * | ||||
* @param data Session metadata and event handlers (session, token, shareToken, nickname, role, | * @param data Session metadata and event handlers (token, shareToken, nickname, role, connections, | ||||
* chatElement, menuElement, onDestroy, onJoinRequest) | * chatElement, menuElement, onDestroy, onJoinRequest, onDismiss, onConnectionChange, | ||||
* onSessionDataUpdate, onMediaSetup) | |||||
*/ | */ | ||||
function joinRoom(data) { | function joinRoom(data) { | ||||
resize(); | resize(); | ||||
volumeMeterStop() | volumeMeterStop() | ||||
data.params = { | data.params = { | ||||
nickname: data.nickname, // user nickname | nickname: data.nickname, // user nickname | ||||
// avatar: undefined // avatar image | // avatar: undefined // avatar image | ||||
Show All 14 Lines | function joinRoom(data) { | ||||
// Ignore the current user connection | // Ignore the current user connection | ||||
if (event.connection.role) { | if (event.connection.role) { | ||||
return | return | ||||
} | } | ||||
// This is the first event executed when a user joins in. | // This is the first event executed when a user joins in. | ||||
// We'll create the video wrapper here, which can be re-used | // We'll create the video wrapper here, which can be re-used | ||||
// in 'streamCreated' event handler. | // 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 metadata = connectionData(event.connection) | ||||
let connectionId = event.connection.connectionId | const connId = metadata.connectionId | ||||
metadata.connId = connectionId | |||||
let element = participantCreate(metadata) | // 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]) | |||||
delete sessionData.connections[connId] | |||||
} | |||||
connections[connectionId] = { element } | metadata.element = participantCreate(metadata) | ||||
resize() | connections[connId] = metadata | ||||
// Send the current user status to the connecting user | // Send the current user status to the connecting user | ||||
// otherwise e.g. nickname might be not up to date | // otherwise e.g. nickname might be not up to date | ||||
signalUserUpdate(event.connection) | signalUserUpdate(event.connection) | ||||
}) | }) | ||||
session.on('connectionDestroyed', event => { | session.on('connectionDestroyed', event => { | ||||
let conn = connections[event.connection.connectionId] | let conn = connections[event.connection.connectionId] | ||||
if (conn) { | if (conn) { | ||||
if ($(conn.element).is('.meet-video')) { | |||||
numOfVideos-- | |||||
} | |||||
$(conn.element).remove() | $(conn.element).remove() | ||||
delete connections[event.connection.connectionId] | delete connections[event.connection.connectionId] | ||||
} | } | ||||
resize() | resize() | ||||
}) | }) | ||||
// On every new Stream received... | // On every new Stream received... | ||||
session.on('streamCreated', event => { | session.on('streamCreated', event => { | ||||
let connection = event.stream.connection | let connectionId = event.stream.connection.connectionId | ||||
let connectionId = connection.connectionId | let metadata = connections[connectionId] | ||||
let metadata = connectionData(connection) | |||||
let wrapper = connections[connectionId].element | |||||
let props = { | let props = { | ||||
// Prepend the video element so it is always before the watermark element | // Prepend the video element so it is always before the watermark element | ||||
insertMode: 'PREPEND' | insertMode: 'PREPEND' | ||||
} | } | ||||
// Subscribe to the Stream to receive it | // 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 => { | subscriber.on('videoElementCreated', event => { | ||||
$(event.element).prop({ | $(event.element).prop({ | ||||
tabindex: -1 | tabindex: -1 | ||||
}) | }) | ||||
resize() | resize() | ||||
}) | }) | ||||
/* | |||||
subscriber.on('videoElementDestroyed', event => { | metadata.audioActive = event.stream.audioActive | ||||
}) | metadata.videoActive = event.stream.videoActive | ||||
*/ | |||||
// Update the wrapper controls/status | // Update the wrapper controls/status | ||||
participantUpdate(wrapper, event.stream) | participantUpdate(metadata.element, metadata) | ||||
}) | }) | ||||
/* | |||||
session.on('streamDestroyed', event => { | // 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 | |||||
} | |||||
if (metadata) { | |||||
metadata[event.changedProperty] = event.newValue | |||||
participantUpdate(metadata.element, metadata) | |||||
} | |||||
}) | }) | ||||
*/ | |||||
// Handle session disconnection events | // Handle session disconnection events | ||||
session.on('sessionDisconnected', event => { | session.on('sessionDisconnected', event => { | ||||
if (data.onDestroy) { | if (data.onDestroy) { | ||||
data.onDestroy(event) | data.onDestroy(event) | ||||
} | } | ||||
resize() | resize() | ||||
}) | }) | ||||
// Handle signals from all participants | // Handle signals from all participants | ||||
session.on('signal', signalEventHandler) | session.on('signal', signalEventHandler) | ||||
// Connect with the token | // Connect with the token | ||||
session.connect(data.token, data.params) | session.connect(data.token, data.params) | ||||
.then(() => { | .then(() => { | ||||
let wrapper | let params = { | ||||
let params = { self: true, role: data.role, audioActive, videoActive } | connectionId: session.connection.connectionId, | ||||
role: data.role, | |||||
audioActive, | |||||
videoActive | |||||
} | |||||
params = Object.assign({}, data.params, params) | params = Object.assign({}, data.params, params) | ||||
publisher.on('videoElementCreated', event => { | publisher.on('videoElementCreated', event => { | ||||
$(event.element).prop({ | $(event.element).prop({ | ||||
muted: true, // Mute local video to avoid feedback | muted: true, // Mute local video to avoid feedback | ||||
disablePictureInPicture: true, // this does not work in Firefox | disablePictureInPicture: true, // this does not work in Firefox | ||||
tabindex: -1 | tabindex: -1 | ||||
}) | }) | ||||
resize() | resize() | ||||
}) | }) | ||||
wrapper = participantCreate(params) | let wrapper = participantCreate(params) | ||||
if (data.role & Roles.PUBLISHER) { | if (data.role & Roles.PUBLISHER) { | ||||
publisher.createVideoElement(wrapper, 'PREPEND') | publisher.createVideoElement(wrapper, 'PREPEND') | ||||
session.publish(publisher) | session.publish(publisher) | ||||
} | } | ||||
resize() | sessionData.element = wrapper | ||||
sessionData.wrapper = wrapper | |||||
}) | }) | ||||
.catch(error => { | .catch(error => { | ||||
console.error('There was an error connecting to the session: ', error.message); | console.error('There was an error connecting to the session: ', error.message); | ||||
}) | }) | ||||
// Prepare the chat | // Prepare the chat | ||||
setupChat() | setupChat() | ||||
} | } | ||||
Show All 28 Lines | function Meet(container) | ||||
} | } | ||||
/** | /** | ||||
* Sets the audio and video devices for the session. | * Sets the audio and video devices for the session. | ||||
* This will ask user for permission to access media devices. | * This will ask user for permission to access media devices. | ||||
* | * | ||||
* @param props Setup properties (videoElement, volumeElement, onSuccess, onError) | * @param props Setup properties (videoElement, volumeElement, onSuccess, onError) | ||||
*/ | */ | ||||
function setup(props) { | function setupStart(props) { | ||||
setupProps = 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 = OV.initPublisher(undefined, publisherDefaults) | ||||
publisher.once('accessDenied', error => { | publisher.once('accessDenied', error => { | ||||
props.onError(error) | props.onError(error) | ||||
}) | }) | ||||
publisher.once('accessAllowed', async () => { | publisher.once('accessAllowed', async () => { | ||||
Show All 33 Lines | function setupStart(props) { | ||||
videoSource, | videoSource, | ||||
audioActive, | audioActive, | ||||
videoActive | videoActive | ||||
}) | }) | ||||
}) | }) | ||||
} | } | ||||
/** | /** | ||||
* Stop the setup "process", cleanup after it. | |||||
*/ | |||||
function setupStop() { | |||||
volumeMeterStop() | |||||
} | |||||
/** | |||||
* Change the publisher audio device | * Change the publisher audio device | ||||
* | * | ||||
* @param deviceId Device identifier string | * @param deviceId Device identifier string | ||||
*/ | */ | ||||
async function setupSetAudioDevice(deviceId) { | async function setupSetAudioDevice(deviceId) { | ||||
if (!deviceId) { | if (!deviceId) { | ||||
publisher.publishAudio(false) | publisher.publishAudio(false) | ||||
volumeMeterStop() | volumeMeterStop() | ||||
audioActive = false | audioActive = false | ||||
} else if (deviceId == audioSource) { | } else if (deviceId == audioSource) { | ||||
publisher.publishAudio(true) | publisher.publishAudio(true) | ||||
volumeMeterStart() | volumeMeterStart() | ||||
audioActive = true | audioActive = true | ||||
} else { | } else { | ||||
const mediaStream = publisher.stream.mediaStream | const mediaStream = publisher.stream.mediaStream | ||||
const oldTrack = mediaStream.getAudioTracks()[0] | const properties = Object.assign({}, publisherDefaults, { | ||||
let properties = Object.assign({}, publisherDefaults, { | |||||
publishAudio: true, | publishAudio: true, | ||||
publishVideo: videoActive, | publishVideo: videoActive, | ||||
audioSource: deviceId, | audioSource: deviceId, | ||||
videoSource: videoSource | videoSource: videoSource | ||||
}) | }) | ||||
volumeMeterStop() | volumeMeterStop() | ||||
// Note: We're not using publisher.replaceTrack() as it wasn't working for me | // Stop and remove the old track, otherwise you get "Concurrent mic process limit." error | ||||
mediaStream.getAudioTracks().forEach(track => { | |||||
// Stop and remove the old track | track.stop() | ||||
if (oldTrack) { | mediaStream.removeTrack(track) | ||||
oldTrack.stop() | }) | ||||
mediaStream.removeTrack(oldTrack) | |||||
} | |||||
// TODO: Handle errors | // TODO: Handle errors | ||||
await OV.getUserMedia(properties) | await OV.getUserMedia(properties) | ||||
.then(async (newMediaStream) => { | .then(async (newMediaStream) => { | ||||
publisher.stream.mediaStream = newMediaStream | await replaceTrack(newMediaStream.getAudioTracks()[0]) | ||||
volumeMeterStart() | volumeMeterStart() | ||||
audioActive = true | audioActive = true | ||||
audioSource = deviceId | audioSource = deviceId | ||||
}) | }) | ||||
} | } | ||||
return audioActive | return audioActive | ||||
} | } | ||||
/** | /** | ||||
* Change the publisher video device | * Change the publisher video device | ||||
* | * | ||||
* @param deviceId Device identifier string | * @param deviceId Device identifier string | ||||
*/ | */ | ||||
async function setupSetVideoDevice(deviceId) { | async function setupSetVideoDevice(deviceId) { | ||||
if (!deviceId) { | if (!deviceId) { | ||||
publisher.publishVideo(false) | publisher.publishVideo(false) | ||||
videoActive = false | videoActive = false | ||||
} else if (deviceId == videoSource) { | } else if (deviceId == videoSource) { | ||||
publisher.publishVideo(true) | publisher.publishVideo(true) | ||||
videoActive = true | videoActive = true | ||||
} else { | } else { | ||||
const mediaStream = publisher.stream.mediaStream | const mediaStream = publisher.stream.mediaStream | ||||
const oldTrack = mediaStream.getAudioTracks()[0] | const properties = Object.assign({}, publisherDefaults, { | ||||
let properties = Object.assign({}, publisherDefaults, { | |||||
publishAudio: audioActive, | publishAudio: audioActive, | ||||
publishVideo: true, | publishVideo: true, | ||||
audioSource: audioSource, | audioSource: audioSource, | ||||
videoSource: deviceId | videoSource: deviceId | ||||
}) | }) | ||||
volumeMeterStop() | volumeMeterStop() | ||||
// Stop and remove the old track | // Stop and remove the old track, otherwise you get "Concurrent mic process limit." error | ||||
if (oldTrack) { | mediaStream.getVideoTracks().forEach(track => { | ||||
oldTrack.stop() | track.stop() | ||||
mediaStream.removeTrack(oldTrack) | mediaStream.removeTrack(track) | ||||
} | }) | ||||
// TODO: Handle errors | // TODO: Handle errors | ||||
await OV.getUserMedia(properties) | await OV.getUserMedia(properties) | ||||
.then(async (newMediaStream) => { | .then(async (newMediaStream) => { | ||||
publisher.stream.mediaStream = newMediaStream | await replaceTrack(newMediaStream.getVideoTracks()[0]) | ||||
volumeMeterStart() | volumeMeterStart() | ||||
videoActive = true | videoActive = true | ||||
videoSource = deviceId | videoSource = deviceId | ||||
}) | }) | ||||
} | } | ||||
return videoActive | 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); | |||||
} | |||||
} | |||||
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 | * Setup the chat UI | ||||
*/ | */ | ||||
function setupChat() { | function setupChat() { | ||||
// The UI elements are created in the vue template | // The UI elements are created in the vue template | ||||
// Here we add a logic for how they work | // Here we add a logic for how they work | ||||
const textarea = $(sessionData.chatElement).find('textarea') | const textarea = $(sessionData.chatElement).find('textarea') | ||||
const button = $(sessionData.menuElement).find('.link-chat') | const button = $(sessionData.menuElement).find('.link-chat') | ||||
Show All 21 Lines | function Meet(container) | ||||
* Signal events handler | * Signal events handler | ||||
*/ | */ | ||||
function signalEventHandler(signal) { | function signalEventHandler(signal) { | ||||
let conn, data | let conn, data | ||||
let connId = signal.from ? signal.from.connectionId : null | let connId = signal.from ? signal.from.connectionId : null | ||||
switch (signal.type) { | switch (signal.type) { | ||||
case 'signal:userChanged': | case 'signal:userChanged': | ||||
// TODO: Use 'signal:connectionUpdate' for nickname updates? | |||||
if (conn = connections[connId]) { | if (conn = connections[connId]) { | ||||
data = JSON.parse(signal.data) | data = JSON.parse(signal.data) | ||||
participantUpdate(conn.element, data) | conn.nickname = data.nickname | ||||
participantUpdate(conn.element, conn) | |||||
nicknameUpdate(data.nickname, connId) | nicknameUpdate(data.nickname, connId) | ||||
} | } | ||||
break | break | ||||
case 'signal:chat': | case 'signal:chat': | ||||
data = JSON.parse(signal.data) | data = JSON.parse(signal.data) | ||||
data.id = connId | data.id = connId | ||||
pushChatMessage(data) | pushChatMessage(data) | ||||
break | break | ||||
case 'signal:joinRequest': | case 'signal:joinRequest': | ||||
if (sessionData.onJoinRequest) { | // accept requests from the server only | ||||
if (!connId && sessionData.onJoinRequest) { | |||||
sessionData.onJoinRequest(JSON.parse(signal.data)) | sessionData.onJoinRequest(JSON.parse(signal.data)) | ||||
} | } | ||||
break; | 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 | * Send the chat message to other participants | ||||
* | * | ||||
* @param message Message string | * @param message Message string | ||||
*/ | */ | ||||
▲ Show 20 Lines • Show All 74 Lines • ▼ Show 20 Lines | function Meet(container) | ||||
/** | /** | ||||
* Send the user properties update signal to other participants | * Send the user properties update signal to other participants | ||||
* | * | ||||
* @param connection Optional connection to which the signal will be sent | * @param connection Optional connection to which the signal will be sent | ||||
* If not specified the signal is sent to all participants | * If not specified the signal is sent to all participants | ||||
*/ | */ | ||||
function signalUserUpdate(connection) { | function signalUserUpdate(connection) { | ||||
let data = { | let data = { | ||||
audioActive, | |||||
videoActive, | |||||
nickname: sessionData.params.nickname | 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({ | session.signal({ | ||||
data: JSON.stringify(data), | data: JSON.stringify(data), | ||||
type: 'userChanged', | type: 'userChanged', | ||||
to: connection ? [connection] : undefined | to: connection ? [connection] : undefined | ||||
}) | }) | ||||
// The same nickname for screen sharing session | // The same nickname for screen sharing session | ||||
if (screenSession) { | if (screenSession) { | ||||
data.audioActive = false | |||||
data.videoActive = true | |||||
screenSession.signal({ | screenSession.signal({ | ||||
data: JSON.stringify(data), | data: JSON.stringify(data), | ||||
type: 'userChanged', | type: 'userChanged', | ||||
to: connection ? [connection] : undefined | to: connection ? [connection] : undefined | ||||
}) | }) | ||||
} | } | ||||
} | } | ||||
/** | /** | ||||
* Mute/Unmute audio for current session publisher | * Mute/Unmute audio for current session publisher | ||||
*/ | */ | ||||
function switchAudio() { | function switchAudio() { | ||||
// TODO: If user has no devices or denied access to them in the setup, | // 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 | // the button will just not work. Find a way to make it working | ||||
// after user unlocks his devices. For now he has to refresh | // after user unlocks his devices. For now he has to refresh | ||||
// the page and join the room again. | // the page and join the room again. | ||||
if (microphones.length) { | if (microphones.length) { | ||||
try { | try { | ||||
publisher.publishAudio(!audioActive) | publisher.publishAudio(!audioActive) | ||||
audioActive = !audioActive | audioActive = !audioActive | ||||
participantUpdate(sessionData.wrapper, { audioActive }) | |||||
signalUserUpdate() | |||||
} catch (e) { | } catch (e) { | ||||
console.error(e) | console.error(e) | ||||
} | } | ||||
} | } | ||||
return audioActive | return audioActive | ||||
} | } | ||||
/** | /** | ||||
* Mute/Unmute video for current session publisher | * Mute/Unmute video for current session publisher | ||||
*/ | */ | ||||
function switchVideo() { | function switchVideo() { | ||||
// TODO: If user has no devices or denied access to them in the setup, | // 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 | // the button will just not work. Find a way to make it working | ||||
// after user unlocks his devices. For now he has to refresh | // after user unlocks his devices. For now he has to refresh | ||||
// the page and join the room again. | // the page and join the room again. | ||||
if (cameras.length) { | if (cameras.length) { | ||||
try { | try { | ||||
publisher.publishVideo(!videoActive) | publisher.publishVideo(!videoActive) | ||||
videoActive = !videoActive | videoActive = !videoActive | ||||
participantUpdate(sessionData.wrapper, { videoActive }) | |||||
signalUserUpdate() | |||||
} catch (e) { | } catch (e) { | ||||
console.error(e) | console.error(e) | ||||
} | } | ||||
} | } | ||||
return videoActive | return videoActive | ||||
} | } | ||||
Show All 21 Lines | function Meet(container) | ||||
/** | /** | ||||
* Detect if screen sharing is supported by the browser | * Detect if screen sharing is supported by the browser | ||||
*/ | */ | ||||
function isScreenSharingSupported() { | function isScreenSharingSupported() { | ||||
return !!OV.checkScreenSharingCapabilities(); | 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] }) | |||||
// 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 | * Update nickname in chat | ||||
* | * | ||||
* @param nickname Nickname | * @param nickname Nickname | ||||
* @param connectionId Connection identifier of the user | * @param connectionId Connection identifier of the user | ||||
*/ | */ | ||||
function nicknameUpdate(nickname, connectionId) { | function nicknameUpdate(nickname, connectionId) { | ||||
if (connectionId) { | if (connectionId) { | ||||
$(sessionData.chatElement).find('.chat').find('.message').each(function() { | $(sessionData.chatElement).find('.chat').find('.message').each(function() { | ||||
Show All 10 Lines | function Meet(container) | ||||
* parameter it will be a video element wrapper inside the matrix or a simple | * parameter it will be a video element wrapper inside the matrix or a simple | ||||
* tag-like element on the subscribers list. | * tag-like element on the subscribers list. | ||||
* | * | ||||
* @param params Connection metadata/params | * @param params Connection metadata/params | ||||
* | * | ||||
* @return The element | * @return The element | ||||
*/ | */ | ||||
function participantCreate(params) { | function participantCreate(params) { | ||||
let element | |||||
params.isSelf = params.isSelf || session.connection.connectionId == params.connectionId | |||||
if (params.role & Roles.PUBLISHER || params.role & Roles.SCREEN) { | if (params.role & Roles.PUBLISHER || params.role & Roles.SCREEN) { | ||||
return publisherCreate(params) | element = publisherCreate(params) | ||||
} else { | |||||
element = subscriberCreate(params) | |||||
} | } | ||||
return subscriberCreate(params) | setTimeout(resize, 50); | ||||
return element | |||||
} | } | ||||
/** | /** | ||||
* Create a <video> element wrapper with controls | * Create a <video> element wrapper with controls | ||||
* | * | ||||
* @param params Connection metadata/params | * @param params Connection metadata/params | ||||
*/ | */ | ||||
function publisherCreate(params) { | function publisherCreate(params) { | ||||
// Create the element | // Create the element | ||||
let wrapper = $( | let wrapper = $( | ||||
'<div class="meet-video">' | '<div class="meet-video">' | ||||
+ svgIcon('user', 'fas', 'watermark') | + svgIcon('user', 'fas', 'watermark') | ||||
+ '<div class="controls">' | + '<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-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 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>' | + '<button type="button" class="btn btn-link link-fullscreen open hidden" title="Full screen">' + svgIcon('compress') + '</button>' | ||||
+ '</div>' | + '</div>' | ||||
+ '<div class="status">' | + '<div class="status">' | ||||
+ '<span class="bg-danger status-audio hidden">' + svgIcon('microphone') + '</span>' | + '<span class="bg-danger status-audio hidden">' + svgIcon('microphone') + '</span>' | ||||
+ '<span class="bg-danger status-video hidden">' + svgIcon('video') + '</span>' | + '<span class="bg-danger status-video hidden">' + svgIcon('video') + '</span>' | ||||
+ '</div>' | + '</div>' | ||||
+ '</div>' | + '</div>' | ||||
) | ) | ||||
// Append the nickname widget | // Append the nickname widget | ||||
wrapper.find('.controls').before(nicknameWidget(params)) | wrapper.find('.controls').before(nicknameWidget(params)) | ||||
if (!params.self) { | if (params.isSelf) { | ||||
if (sessionData.onMediaSetup) { | |||||
wrapper.find('.link-setup').removeClass('hidden') | |||||
.click(() => sessionData.onMediaSetup()) | |||||
} | |||||
} else { | |||||
// Enable audio mute button | // Enable audio mute button | ||||
wrapper.find('.link-audio').removeClass('hidden') | wrapper.find('.link-audio').removeClass('hidden') | ||||
.on('click', e => { | .on('click', e => { | ||||
let video = wrapper.find('video')[0] | let video = wrapper.find('video')[0] | ||||
video.muted = !video.muted | video.muted = !video.muted | ||||
wrapper.find('.link-audio')[video.muted ? 'addClass' : 'removeClass']('text-danger') | wrapper.find('.link-audio')[video.muted ? 'addClass' : 'removeClass']('text-danger') | ||||
}) | }) | ||||
} | } | ||||
Show All 15 Lines | function publisherCreate(params) { | ||||
wrapper.on('fullscreenchange', () => { | wrapper.on('fullscreenchange', () => { | ||||
// const enabled = document.fullscreenElement | // const enabled = document.fullscreenElement | ||||
wrapper.find('.link-fullscreen.closed').toggleClass('hidden') | wrapper.find('.link-fullscreen.closed').toggleClass('hidden') | ||||
wrapper.find('.link-fullscreen.open').toggleClass('hidden') | wrapper.find('.link-fullscreen.open').toggleClass('hidden') | ||||
wrapper.toggleClass('fullscreen') | wrapper.toggleClass('fullscreen') | ||||
}) | }) | ||||
} | } | ||||
numOfVideos++ | |||||
// Remove the subscriber element, if exists | // Remove the subscriber element, if exists | ||||
$('#subscriber-' + params.connId).remove() | $('#subscriber-' + params.connectionId).remove() | ||||
return wrapper[params.self ? 'prependTo' : 'appendTo'](container).get(0) | return wrapper[params.isSelf ? 'prependTo' : 'appendTo'](container) | ||||
.attr('id', 'publisher-' + params.connectionId) | |||||
.get(0) | |||||
} | } | ||||
/** | /** | ||||
* Update the <video> wrapper controls | * Update the <video> wrapper controls | ||||
* | * | ||||
* @param wrapper The wrapper element | * @param wrapper The wrapper element | ||||
* @param params Connection metadata/params | * @param params Connection metadata/params | ||||
*/ | */ | ||||
function participantUpdate(wrapper, params) { | function participantUpdate(wrapper, params) { | ||||
const $element = $(wrapper) | const element = $(wrapper) | ||||
const isModerator = sessionData.role & Roles.MODERATOR | |||||
const isSelf = session.connection.connectionId == params.connectionId | |||||
// Handle publisher-to-subscriber and subscriber-to-publisher change | |||||
if ('role' in params && !(params.role & Roles.SCREEN)) { | |||||
const rolePublisher = params.role & Roles.PUBLISHER | |||||
const isPublisher = element.is('.meet-video') | |||||
if ((rolePublisher && !isPublisher) || (!rolePublisher && isPublisher)) { | |||||
element.remove() | |||||
return participantCreate(params) | |||||
} | |||||
element.find('.action-role-publisher input').prop('checked', params.role & Roles.PUBLISHER) | |||||
} | |||||
if ('audioActive' in params) { | if ('audioActive' in params) { | ||||
$element.find('.status-audio')[params.audioActive ? 'addClass' : 'removeClass']('hidden') | element.find('.status-audio')[params.audioActive ? 'addClass' : 'removeClass']('hidden') | ||||
} | } | ||||
if ('videoActive' in params) { | if ('videoActive' in params) { | ||||
$element.find('.status-video')[params.videoActive ? 'addClass' : 'removeClass']('hidden') | element.find('.status-video')[params.videoActive ? 'addClass' : 'removeClass']('hidden') | ||||
} | } | ||||
if ('nickname' in params) { | if ('nickname' in params) { | ||||
$element.find('.meet-nickname > .content').text(params.nickname) | element.find('.meet-nickname > .content').text(params.nickname) | ||||
} | } | ||||
if (params.self) { | if (isSelf) { | ||||
$element.addClass('self') | element.addClass('self') | ||||
} | } | ||||
if (sessionData.role & Roles.MODERATOR) { | if (isModerator) { | ||||
$element.addClass('moderated') | element.addClass('moderated') | ||||
} | } | ||||
element.find('.dropdown-menu')[isSelf || isModerator ? 'removeClass' : 'addClass']('hidden') | |||||
element.find('.permissions')[isModerator ? 'removeClass' : 'addClass']('hidden') | |||||
if ('role' in params && params.role & Roles.SCREEN) { | |||||
element.find('.permissions').addClass('hidden') | |||||
} | |||||
return wrapper | |||||
} | } | ||||
/** | /** | ||||
* Create a tag-like element for a subscriber participant | * Create a tag-like element for a subscriber participant | ||||
* | * | ||||
* @param params Connection metadata/params | * @param params Connection metadata/params | ||||
*/ | */ | ||||
function subscriberCreate(params) { | function subscriberCreate(params) { | ||||
// Create the element | // Create the element | ||||
let wrapper = $('<div class="meet-subscriber">').append(nicknameWidget(params)) | let wrapper = $('<div class="meet-subscriber">').append(nicknameWidget(params)) | ||||
participantUpdate(wrapper, params) | participantUpdate(wrapper, params) | ||||
return wrapper[params.self ? 'prependTo' : 'appendTo'](subscribersContainer) | return wrapper[params.isSelf ? 'prependTo' : 'appendTo'](subscribersContainer) | ||||
.attr('id', 'subscriber-' + params.connId) | .attr('id', 'subscriber-' + params.connectionId) | ||||
.get(0) | .get(0) | ||||
} | } | ||||
/** | /** | ||||
* Create a tag-like nickname widget | * Create a tag-like nickname widget | ||||
* | * | ||||
* @param object params Connection metadata/params | * @param object params Connection metadata/params | ||||
*/ | */ | ||||
function nicknameWidget(params) { | function nicknameWidget(params) { | ||||
// Create the element | // Create the element | ||||
let element = $( | let element = $( | ||||
'<div class="dropdown">' | '<div class="dropdown">' | ||||
+ '<a href="#" class="meet-nickname btn" title="Nickname" aria-haspopup="true" aria-expanded="false" role="button">' | + '<a href="#" class="meet-nickname btn" aria-haspopup="true" aria-expanded="false" role="button">' | ||||
+ '<span class="content"></span>' | + '<span class="content"></span>' | ||||
+ '<span class="icon">' + svgIcon('user') + '</span>' | + '<span class="icon">' + svgIcon('user') + '</span>' | ||||
+ '</a>' | + '</a>' | ||||
+ '<div class="dropdown-menu">' | + '<div class="dropdown-menu">' | ||||
+ '<a class="dropdown-item action-nickname" href="#">Nickname</a>' | |||||
+ '<a class="dropdown-item action-dismiss" href="#">Dismiss</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 & 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>' | + '</div>' | ||||
+ '</div>' | + '</div>' | ||||
) | ) | ||||
let nickname = element.find('.meet-nickname') | let nickname = element.find('.meet-nickname') | ||||
.addClass('btn btn-outline-' + (params.self ? 'primary' : 'secondary')) | .addClass('btn btn-outline-' + (params.isSelf ? 'primary' : 'secondary')) | ||||
.attr({title: 'Options', 'data-toggle': 'dropdown'}) | |||||
.dropdown({boundary: container}) | |||||
if (params.self) { | if (params.isSelf) { | ||||
// Add events for nickname change | // Add events for nickname change | ||||
let editable = element.find('.content')[0] | let editable = element.find('.content')[0] | ||||
let editableEnable = () => { | let editableEnable = () => { | ||||
editable.contentEditable = true | editable.contentEditable = true | ||||
editable.focus() | editable.focus() | ||||
} | } | ||||
let editableUpdate = () => { | let editableUpdate = () => { | ||||
editable.contentEditable = false | editable.contentEditable = false | ||||
sessionData.params.nickname = editable.innerText | sessionData.params.nickname = editable.innerText | ||||
signalUserUpdate() | signalUserUpdate() | ||||
nicknameUpdate(editable.innerText, session.connection.connectionId) | nicknameUpdate(editable.innerText, session.connection.connectionId) | ||||
} | } | ||||
nickname.on('click', editableEnable) | element.find('.action-nickname').on('click', editableEnable) | ||||
element.find('.action-dismiss').remove() | |||||
$(editable).on('blur', editableUpdate) | $(editable).on('blur', editableUpdate) | ||||
.on('keydown', e => { | .on('keydown', e => { | ||||
// Enter or Esc | // Enter or Esc | ||||
if (e.keyCode == 13 || e.keyCode == 27) { | if (e.keyCode == 13 || e.keyCode == 27) { | ||||
editableUpdate() | editableUpdate() | ||||
return false | return false | ||||
} | } | ||||
}) | }) | ||||
} else if (sessionData.role & Roles.MODERATOR) { | } else { | ||||
nickname.attr({title: 'Options', 'data-toggle': 'dropdown'}) | element.find('.action-nickname').remove() | ||||
.dropdown({boundary: container}) | |||||
element.find('.action-dismiss').on('click', e => { | element.find('.action-dismiss').on('click', e => { | ||||
if (sessionData.onDismiss) { | if (sessionData.onDismiss) { | ||||
sessionData.onDismiss(params.connId) | sessionData.onDismiss(params.connectionId) | ||||
} | |||||
}) | |||||
} | |||||
// 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 = params.role | |||||
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 = params.role | |||||
if (enabled) { | |||||
role |= Roles.MODERATOR | |||||
} else if (role & Roles.MODERATOR) { | |||||
role ^= Roles.MODERATOR | |||||
} | |||||
sessionData.onConnectionChange(params.connectionId, { role }) | |||||
}) | }) | ||||
} | } | ||||
return element.get(0) | return element.get(0) | ||||
} | } | ||||
/** | /** | ||||
* Window onresize event handler (updates room layout) | * Window onresize event handler (updates room layout) | ||||
Show All 9 Lines | function resize() { | ||||
updateLayout() | updateLayout() | ||||
$(container).parent()[window.screen.width <= 768 ? 'addClass' : 'removeClass']('mobile') | $(container).parent()[window.screen.width <= 768 ? 'addClass' : 'removeClass']('mobile') | ||||
} | } | ||||
/** | /** | ||||
* Update the room "matrix" layout | * Update the room "matrix" layout | ||||
*/ | */ | ||||
function updateLayout() { | function updateLayout() { | ||||
let numOfVideos = $(container).find('.meet-video').length | |||||
if (!numOfVideos) { | if (!numOfVideos) { | ||||
return | return | ||||
} | } | ||||
let css, rows, cols, height | let css, rows, cols, height | ||||
const factor = containerWidth / containerHeight | const factor = containerWidth / containerHeight | ||||
▲ Show 20 Lines • Show All 191 Lines • ▼ Show 20 Lines | |||||
} | } | ||||
function connectionData(connection) { | function connectionData(connection) { | ||||
// Note: we're sending a json from two sources (server-side when | // Note: we're sending a json from two sources (server-side when | ||||
// creating a token/connection, and client-side when joining the session) | // 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 | // 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 | // two strings, so it puts a "%/%" separator in between, we'll replace it with comma | ||||
// to get one parseable json object | // to get one parseable json object | ||||
return JSON.parse(connection.data.replace('}%/%{', ',')) | let data = JSON.parse(connection.data.replace('}%/%{', ',')) | ||||
data.connectionId = connection.connectionId | |||||
return data | |||||
} | } | ||||
} | } | ||||
export { Meet, Roles } | export { Meet, Roles } |