Page MenuHomePhorge

No OneTemporary

Size
148 KB
Referenced Files
None
Subscribers
None
diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js
index cc37797a..869fe788 100644
--- a/src/resources/js/meet/app.js
+++ b/src/resources/js/meet/app.js
@@ -1,1674 +1,1678 @@
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 chatCount = 0
let volumeElement
let publishersContainer
let subscribersContainer
let scrollStop
+ let $t
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
+ * translate - Translation function
*/
function joinRoom(data) {
// Create a container for subscribers and publishers
publishersContainer = $('<div id="meet-publishers">').appendTo(container).get(0)
subscribersContainer = $('<div id="meet-subscribers">').appendTo(container).get(0)
resize();
volumeMeterStop()
data.params = {
nickname: data.nickname, // user nickname
// avatar: undefined // avatar image
}
+ $t = data.translate
+
// Make sure all supported callbacks exist, so we don't have to check
// their existence everywhere anymore
let events = ['Success', 'Error', 'Destroy', 'Dismiss', 'JoinRequest', 'ConnectionChange',
'SessionDataUpdate', 'MediaSetup']
events.map(event => 'on' + event).forEach(event => {
if (!data[event]) {
data[event] = () => {}
}
})
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 => {
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(() => {
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)
}
})
.catch(error => {
console.error('There was an error connecting to the session: ', error.message);
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(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
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.
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
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>'
+ + '<button type="button" class="btn btn-link link-setup hidden" title="' + $t('meet.media-setup') + '">' + svgIcon('cog') + '</button>'
+ + '<button type="button" class="btn btn-link link-audio hidden" title="' + $t('meet.menu-audio-mute') + '">' + svgIcon('volume-mute') + '</button>'
+ + '<button type="button" class="btn btn-link link-fullscreen closed hidden" title="' + $t('meet.menu-fullscreen') + '">' + svgIcon('expand') + '</button>'
+ + '<button type="button" class="btn btn-link link-fullscreen open hidden" title="' + $t('meet.menu-fullscreen') + '">' + svgIcon('compress') + '</button>'
+ '</div>'
+ '<div class="status">'
+ '<span class="bg-warning status-audio hidden">' + svgIcon('microphone-slash') + '</span>'
+ '<span class="bg-warning status-video hidden">' + svgIcon('video-slash') + '</span>'
+ '</div>'
+ '</div>'
)
// Append the nickname widget
wrapper.find('.controls').before(nicknameWidget(params))
if (content) {
wrapper.prepend(content)
}
if (isScreen) {
wrapper.addClass('screen')
}
if (params.isSelf) {
wrapper.find('.link-setup').removeClass('hidden').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').toggleClass('hidden')
})
}
// 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>`)
+ languages.push(`<option value="${code}">${$t(sessionData.languages[code])}</option>`)
})
// Create the element
let element = $(
'<div class="dropdown">'
+ '<a href="#" class="meet-nickname btn" aria-haspopup="true" aria-expanded="false" role="button">'
+ '<span class="content"></span>'
+ '<span class="icon">'
+ svgIcon('user', null, 'user')
+ svgIcon('crown', null, 'moderator hidden')
+ svgIcon('headphones', null, 'interpreter hidden')
+ '</span>'
+ '</a>'
+ '<div class="dropdown-menu">'
+ '<a class="dropdown-item action-nickname" href="#">Nickname</a>'
+ '<a class="dropdown-item action-dismiss" href="#">Dismiss</a>'
+ '<div class="dropdown-divider permissions"></div>'
+ '<div class="permissions">'
- + '<h6 class="dropdown-header">Permissions</h6>'
+ + '<h6 class="dropdown-header">' + $t('meet.perm') + '</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>'
+ + ' <span class="custom-control-label">' + $t('meet.perm-av') + '</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>'
+ + ' <span class="custom-control-label">' + $t('meet.perm-mod') + '</span>'
+ '</label>'
+ '</div>'
+ '<div class="dropdown-divider interpreting"></div>'
+ '<div class="interpreting">'
- + '<h6 class="dropdown-header">Language interpreter</h6>'
+ + '<h6 class="dropdown-header">' + $t('meet.lang-int') + '</h6>'
+ '<div class="ml-4 mr-4"><select class="custom-select">'
- + '<option value="">- none -</option>'
+ + '<option value="">- ' + $t('form.none') + ' -</option>'
+ languages.join('')
+ '</select></div>'
+ '</div>'
+ '</div>'
+ '</div>'
)
let nickname = element.find('.meet-nickname')
.addClass('btn btn-outline-' + (params.isSelf ? 'primary' : 'secondary'))
- .attr({title: 'Options', 'data-toggle': 'dropdown'})
+ .attr({title: $t('meet.menu-options'), 'data-toggle': 'dropdown'})
.dropdown({boundary: container.parentNode})
if (params.isSelf) {
// Add events for nickname change
let editable = element.find('.content')[0]
let editableEnable = () => {
editable.contentEditable = true
editable.focus()
}
let editableUpdate = () => {
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 => {
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() })
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() {
if (publishersContainer) {
updateLayout()
}
$(container).parent()[window.screen.width <= 768 ? 'addClass' : 'removeClass']('mobile')
}
/**
* Update the room "matrix" layout
*/
function updateLayout() {
let publishers = $(publishersContainer).find('.meet-video')
let numOfVideos = publishers.length
if (sessionData && sessionData.counterElement) {
sessionData.counterElement.innerHTML = Object.keys(connections).length + 1
}
if (!numOfVideos) {
subscribersContainer.style.minHeight = 'auto'
return
}
// Note: offsetHeight/offsetWidth return rounded values, but for proper matrix
// calculations we need more precision, therefore we use getBoundingClientRect()
let allHeight = container.offsetHeight
let scrollHeight = subscribersContainer.scrollHeight
let bcr = publishersContainer.getBoundingClientRect()
let containerWidth = bcr.width
let containerHeight = bcr.height
let limit = Math.ceil(allHeight * 0.25) // max subscribers list height
// Fix subscribers list height
if (subscribersContainer.offsetHeight <= scrollHeight) {
limit = Math.min(scrollHeight, limit)
subscribersContainer.style.minHeight = limit + 'px'
containerHeight = allHeight - limit
} else {
subscribersContainer.style.minHeight = 'auto'
}
let css, rows, cols, height, padding = 0
// Make the first screen sharing tile big
let screenVideo = publishers.filter('.screen').find('video').get(0)
if (screenVideo) {
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/lang/de/ui.php b/src/resources/lang/de/ui.php
index 5dfa216c..c012bf82 100644
--- a/src/resources/lang/de/ui.php
+++ b/src/resources/lang/de/ui.php
@@ -1,30 +1,30 @@
<?php
return [
- 'buttons' => [
+ 'button' => [
'cancel' => "Stornieren",
'save' => "Speichern",
],
- 'menu' => [
- 'cockpit' => "Cockpit",
- 'login' => "Einloggen",
- 'logout' => "Ausloggen",
- 'signup' => "Signup",
- 'toggle' => "Navigation umschalten",
- ],
-
'lang' => [
'en' => "Englisch",
'de' => "Deutsch",
'fr' => "Französisch",
],
'login' => [
'forgot_password' => "Passwort vergessen?",
'sign_in' => "Anmelden",
'webmail' => "Webmail",
],
+ 'menu' => [
+ 'cockpit' => "Cockpit",
+ 'login' => "Einloggen",
+ 'logout' => "Ausloggen",
+ 'signup' => "Signup",
+ 'toggle' => "Navigation umschalten",
+ ],
+
];
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
index eac13386..4c83ea23 100644
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -1,41 +1,178 @@
<?php
/**
* This file will be converted to a Vue-i18n compatible JSON format on build time
*
* Note: The Laravel localization features do not work here. Vue-i18n rules are different
*/
return [
- 'buttons' => [
+ 'button' => [
+ 'accept' => "Accept",
+ 'back' => "Back",
'cancel' => "Cancel",
- 'save' => "Save"
+ 'close' => "Close",
+ 'continue' => "Continue",
+ 'deny' => "Deny",
+ 'save' => "Save",
+ 'submit' => "Submit",
],
- 'menu' => [
- 'cockpit' => "Cockpit",
- 'login' => "Login",
- 'logout' => "Logout",
- 'signup' => "Signup",
- 'toggle' => "Toggle navigation"
+ 'dashboard' => [
+ 'beta' => "beta",
+ ],
+
+ 'distlist' => [
+ 'list-title' => "Distribution list | Distribution lists",
+ 'create' => "Create list",
+ 'delete' => "Delete list",
+ 'email' => "Email",
+ 'list-empty' => "There are no distribution lists in this account.",
+ 'new' => "New distribution list",
+ 'recipients' => "Recipients",
+ ],
+
+ 'form' => [
+ 'code' => "Confirmation Code",
+ 'email' => "Email Address",
+ 'none' => "none",
+ 'password' => "Password",
+ 'password-confirm' => "Confirm Password",
+ 'status' => "Status",
],
'lang' => [
'en' => "English",
'de' => "German",
- 'fr' => "French"
+ 'fr' => "French",
+ 'it' => "Italian",
],
'login' => [
'2fa' => "Second factor code",
'2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.",
- 'email' => "Email address",
'forgot_password' => "Forgot password?",
'header' => "Please sign in",
- 'password' => "Password",
'sign_in' => "Sign in",
'webmail' => "Webmail"
],
+ 'meet' => [
+ 'title' => "Voice & Video Conferencing",
+ 'welcome' => "Welcome to our beta program for Voice & Video Conferencing.",
+ 'url' => "You have a room of your own at the URL below. This room is only open when you yourself are in attendance. Use this URL to invite people to join you.",
+ 'notice' => "This is a work in progress and more features will be added over time. Current features include:",
+ 'sharing' => "Screen Sharing",
+ 'sharing-text' => "Share your screen for presentations or show-and-tell.",
+ 'security' => "Room Security",
+ 'security-text' => "Increase the room security by setting a password that attendees will need to know"
+ . " before they can enter, or lock the door so attendees will have to knock, and a moderator can accept or deny those requests.",
+ 'qa' => "Raise Hand (Q&A)",
+ 'qa-text' => "Silent audience members can raise their hand to facilitate a Question & Answer session with the panel members.",
+ 'moderation' => "Moderator Delegation",
+ 'moderation-text' => "Delegate moderator authority for the session, so that a speaker is not needlessly"
+ . " interrupted with attendees knocking and other moderator duties.",
+ 'eject' => "Eject Attendees",
+ 'eject-text' => "Eject attendees from the session in order to force them to reconnect, or address policy"
+ . " violations. Click the user icon for effective dismissal.",
+ 'silent' => "Silent Audience Members",
+ 'silent-text' => "For a webinar-style session, configure the room to force all new attendees to be silent audience members.",
+ 'interpreters' => "Language Specific Audio Channels",
+ 'interpreters-text' => "Designate a participant to interpret the original audio to a target language, for sessions"
+ . " with multi-lingual attendees. The interpreter is expected to be able to relay the original audio, and override it.",
+ 'beta-notice' => "Keep in mind that this is still in beta and might come with some issues."
+ . " Should you encounter any on your way, let us know by contacting support.",
+
+ // Room options dialog
+ 'options' => "Room options",
+ 'password' => "Password",
+ 'password-none' => "none",
+ 'password-clear' => "Clear password",
+ 'password-set' => "Set password",
+ 'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.",
+ 'lock' => "Locked room",
+ 'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.",
+ 'nomedia' => "Subscribers only",
+ 'nomedia-text' => "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.",
+
+ // Room menu
+ 'partcnt' => "Number of participants",
+ 'menu-audio-mute' => "Mute audio",
+ 'menu-audio-unmute' => "Unmute audio",
+ 'menu-video-mute' => "Mute video",
+ 'menu-video-unmute' => "Unmute video",
+ 'menu-screen' => "Share screen",
+ 'menu-hand-lower' => "Lower hand",
+ 'menu-hand-raise' => "Raise hand",
+ 'menu-channel' => "Interpreted language channel",
+ 'menu-chat' => "Chat",
+ 'menu-fullscreen' => "Full screen",
+ 'menu-fullscreen-exit' => "Exit full screen",
+ 'menu-leave' => "Leave session",
+
+ // Room setup screen
+ 'setup-title' => "Set up your session",
+ 'mic' => "Microphone",
+ 'cam' => "Camera",
+ 'nick' => "Nickname",
+ 'nick-placeholder' => "Your name",
+ 'join' => "JOIN",
+ 'joinnow' => "JOIN NOW",
+ 'imaowner' => "I'm the owner",
+
+ // Room
+ 'qa' => "Q & A",
+ 'leave-title' => "Room closed",
+ 'leave-body' => "The session has been closed by the room owner.",
+ 'media-title' => "Media setup",
+ 'join-request' => "Join request",
+ 'join-requested' => "{user} requested to join.",
+
+ // Status messages
+ 'status-init' => "Checking the room...",
+ 'status-323' => "The room is closed. Please, wait for the owner to start the session.",
+ 'status-324' => "The room is closed. It will be open for others after you join.",
+ 'status-325' => "The room is ready. Please, provide a valid password.",
+ 'status-326' => "The room is locked. Please, enter your name and try again.",
+ 'status-327' => "Waiting for permission to join the room.",
+ 'status-404' => "The room does not exist.",
+ 'status-429' => "Too many requests. Please, wait.",
+ 'status-500' => "Failed to connect to the room. Server error.",
+
+ // Other menus
+ 'media-setup' => "Media setup",
+ 'perm' => "Permissions",
+ 'perm-av' => "Audio &amp; Video publishing",
+ 'perm-mod' => "Moderation",
+ 'lang-int' => "Language interpreter",
+ 'menu-options' => "Options",
+ ],
+
+ 'menu' => [
+ 'cockpit' => "Cockpit",
+ 'login' => "Login",
+ 'logout' => "Logout",
+ 'signup' => "Signup",
+ 'toggle' => "Toggle navigation",
+ ],
+
+ 'msg' => [
+ 'loading' => "Loading...",
+ 'notfound' => "Resource not found.",
+ ],
+
+ 'nav' => [
+ 'step' => "Step {i}/{n}",
+ ],
+
+ 'password' => [
+ 'reset' => "Password Reset",
+ 'reset-step1' => "Enter your email address to reset your password.",
+ 'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.",
+ 'reset-step2' => "We sent out a confirmation code to your external email address."
+ . " Enter the code we sent you, or click the link in the message.",
+ ],
+
];
diff --git a/src/resources/views/layouts/app.blade.php b/src/resources/views/layouts/app.blade.php
index f8415a1e..fb7581f2 100644
--- a/src/resources/views/layouts/app.blade.php
+++ b/src/resources/views/layouts/app.blade.php
@@ -1,23 +1,23 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no, maximum-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
- <title>{{ config('app.name') }} -- @yield('title')</title>
+ <title>{{ config('app.name') }}</title>
{{-- TODO: PWA disabled for now: @laravelPWA --}}
<link rel="icon" type="image/x-icon" href="@theme_asset(images/favicon.ico)">
<link href="@theme_asset(app.css)" rel="stylesheet">
</head>
<body>
<div class="outer-container">
@yield('content')
</div>
<script>window.config = {!! json_encode($env) !!}</script>
<script src="{{ asset('js/' . $env['jsapp']) }}" defer></script>
</body>
</html>
diff --git a/src/resources/views/root.blade.php b/src/resources/views/root.blade.php
index 70d4ca12..a3034171 100644
--- a/src/resources/views/root.blade.php
+++ b/src/resources/views/root.blade.php
@@ -1,10 +1,9 @@
@extends('layouts.app')
-@section('title', "Home")
@section('content')
<div id="app">
<menu-component mode="header"></menu-component>
<app-component></app-component>
<div class="filler"></div>
<menu-component mode="footer" footer="{{ config('app.company.footer') }}"></menu-component>
</div>
@endsection
diff --git a/src/resources/vue/Distlist/Info.vue b/src/resources/vue/Distlist/Info.vue
index 8722a7dd..636981af 100644
--- a/src/resources/vue/Distlist/Info.vue
+++ b/src/resources/vue/Distlist/Info.vue
@@ -1,105 +1,105 @@
<template>
<div class="container">
<status-component v-if="list_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card" id="distlist-info">
<div class="card-body">
<div class="card-title" v-if="list_id !== 'new'">
- Distribution list
+ {{ $tc('distlist.list-title', 1) }}
<button class="btn btn-outline-danger button-delete float-right" @click="deleteList()" tag="button">
- <svg-icon icon="trash-alt"></svg-icon> Delete list
+ <svg-icon icon="trash-alt"></svg-icon> {{ $t('distlist.delete') }}
</button>
</div>
- <div class="card-title" v-if="list_id === 'new'">New distribution list</div>
+ <div class="card-title" v-if="list_id === 'new'">{{ $t('distlist.new') }}</div>
<div class="card-text">
<form @submit.prevent="submit">
<div v-if="list_id !== 'new'" class="form-group row plaintext">
- <label for="status" class="col-sm-4 col-form-label">Status</label>
+ <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.distlistStatusClass(list) + ' form-control-plaintext'" id="status">{{ $root.distlistStatusText(list) }}</span>
</div>
</div>
<div class="form-group row">
- <label for="email" class="col-sm-4 col-form-label">Email</label>
+ <label for="email" class="col-sm-4 col-form-label">{{ $t('form.email') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="email" :disabled="list_id !== 'new'" required v-model="list.email">
</div>
</div>
<div class="form-group row">
- <label for="members-input" class="col-sm-4 col-form-label">Recipients</label>
+ <label for="members-input" class="col-sm-4 col-form-label">{{ $t('distlist.recipients') }}</label>
<div class="col-sm-8">
<list-input id="members" :list="list.members"></list-input>
</div>
</div>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('button.submit') }}</button>
</form>
</div>
</div>
</div>
</div>
</template>
<script>
import ListInput from '../Widgets/ListInput'
import StatusComponent from '../Widgets/Status'
export default {
components: {
ListInput,
StatusComponent
},
data() {
return {
list_id: null,
list: { members: [] },
status: {}
}
},
created() {
this.list_id = this.$route.params.list
if (this.list_id != 'new') {
this.$root.startLoading()
axios.get('/api/v4/groups/' + this.list_id)
.then(response => {
this.$root.stopLoading()
this.list = response.data
this.status = response.data.statusInfo
})
.catch(this.$root.errorHandler)
}
},
methods: {
deleteList() {
axios.delete('/api/v4/groups/' + this.list_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'distlists' })
}
})
},
statusUpdate(list) {
this.list = Object.assign({}, this.list, list)
},
submit() {
this.$root.clearFormValidation($('#list-info form'))
let method = 'post'
let location = '/api/v4/groups'
if (this.list_id !== 'new') {
method = 'put'
location += '/' + this.list_id
}
axios[method](location, this.list)
.then(response => {
this.$toast.success(response.data.message)
this.$router.push({ name: 'distlists' })
})
}
}
}
</script>
diff --git a/src/resources/vue/Distlist/List.vue b/src/resources/vue/Distlist/List.vue
index eb41315d..9a188ca1 100644
--- a/src/resources/vue/Distlist/List.vue
+++ b/src/resources/vue/Distlist/List.vue
@@ -1,56 +1,56 @@
<template>
<div class="container">
<div class="card" id="distlist-list">
<div class="card-body">
<div class="card-title">
- Distribution lists
+ {{ $tc('distlist.list-title', 2) }}
<router-link class="btn btn-success float-right create-list" :to="{ path: 'distlist/new' }" tag="button">
- <svg-icon icon="users"></svg-icon> Create list
+ <svg-icon icon="users"></svg-icon> {{ $t('distlist.create') }}
</router-link>
</div>
<div class="card-text">
<table class="table table-sm table-hover">
<thead class="thead-light">
<tr>
- <th scope="col">Email</th>
+ <th scope="col">{{ $t('distlist.email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="list in lists" :key="list.id" @click="$root.clickRecord">
<td>
<svg-icon icon="users" :class="$root.distlistStatusClass(list)" :title="$root.distlistStatusText(list)"></svg-icon>
<router-link :to="{ path: 'distlist/' + list.id }">{{ list.email }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
- <td>There are no distribution lists in this account.</td>
+ <td>{{ $t('distlist.list-empty') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
lists: []
}
},
created() {
this.$root.startLoading()
axios.get('/api/v4/groups')
.then(response => {
this.$root.stopLoading()
this.lists = response.data
})
.catch(this.$root.errorHandler)
}
}
</script>
diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue
index 1cc0550e..dc6a5e8b 100644
--- a/src/resources/vue/Login.vue
+++ b/src/resources/vue/Login.vue
@@ -1,81 +1,81 @@
<template>
<div class="container d-flex flex-column align-items-center justify-content-center">
<div id="logon-form" class="card col-sm-8 col-lg-6">
<div class="card-body">
<h1 class="card-title text-center mb-3">{{ $t('login.header') }}</h1>
<div class="card-text">
<form class="form-signin" @submit.prevent="submitLogin">
<div class="form-group">
- <label for="inputEmail" class="sr-only">{{ $t('login.email') }}</label>
+ <label for="inputEmail" class="sr-only">{{ $t('form.email') }}</label>
<div class="input-group">
<span class="input-group-prepend">
<span class="input-group-text"><svg-icon icon="user"></svg-icon></span>
</span>
- <input type="email" id="inputEmail" class="form-control" :placeholder="$t('login.email')" required autofocus v-model="email">
+ <input type="email" id="inputEmail" class="form-control" :placeholder="$t('form.email')" required autofocus v-model="email">
</div>
</div>
<div class="form-group">
- <label for="inputPassword" class="sr-only">{{ $t('login.password') }}</label>
+ <label for="inputPassword" class="sr-only">{{ $t('form.password') }}</label>
<div class="input-group">
<span class="input-group-prepend">
<span class="input-group-text"><svg-icon icon="lock"></svg-icon></span>
</span>
- <input type="password" id="inputPassword" class="form-control" :placeholder="$t('login.password')" required v-model="password">
+ <input type="password" id="inputPassword" class="form-control" :placeholder="$t('form.password')" required v-model="password">
</div>
</div>
<div class="form-group pt-3" v-if="!$root.isAdmin">
<label for="secondfactor" class="sr-only">{{ $t('login.2fa') }}</label>
<div class="input-group">
<span class="input-group-prepend">
<span class="input-group-text"><svg-icon icon="key"></svg-icon></span>
</span>
<input type="text" id="secondfactor" class="form-control rounded-right" :placeholder="$t('login.2fa')" v-model="secondFactor">
</div>
<small class="form-text text-muted">{{ $t('login.2fa_desc') }}</small>
</div>
<div class="text-center">
<button class="btn btn-primary" type="submit">
<svg-icon icon="sign-in-alt"></svg-icon> {{ $t('login.sign_in') }}
</button>
</div>
</form>
</div>
</div>
</div>
<div id="logon-form-footer" class="mt-1">
<router-link v-if="!$root.isAdmin && $root.hasRoute('password-reset')" :to="{ name: 'password-reset' }" id="forgot-password">{{ $t('login.forgot_password') }}</router-link>
<a v-if="webmailURL && !$root.isAdmin" :href="webmailURL" id="webmail">{{ $t('login.webmail') }}</a>
</div>
</div>
</template>
<script>
export default {
props: {
dashboard: { type: Boolean, default: true }
},
data() {
return {
email: '',
password: '',
secondFactor: '',
webmailURL: window.config['app.webmail_url']
}
},
methods: {
submitLogin() {
this.$root.clearFormValidation($('form.form-signin'))
axios.post('/api/auth/login', {
email: this.email,
password: this.password,
secondfactor: this.secondFactor
}).then(response => {
// login user and redirect to dashboard
this.$root.loginUser(response.data, this.dashboard)
this.$emit('success')
})
}
}
}
</script>
diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue
index 4c173c0d..d6bc9de6 100644
--- a/src/resources/vue/Meet/Room.vue
+++ b/src/resources/vue/Meet/Room.vue
@@ -1,758 +1,759 @@
<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-counter" :title="$t('meet.partcnt')"><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' + (audioActive ? '' : ' on')" @click="switchSound" :disabled="!isPublisher()" :title="audioActive ? 'Mute audio' : 'Unmute audio'">
+ <button :class="'btn link-audio' + (audioActive ? '' : ' on')" @click="switchSound" :disabled="!isPublisher()" :title="$t('meet.menu-audio-' + (audioActive ? 'mute' : 'unmute'))">
<svg-icon :icon="audioActive ? 'microphone' : 'microphone-slash'"></svg-icon>
</button>
- <button :class="'btn link-video' + (videoActive ? '' : ' on')" @click="switchVideo" :disabled="!isPublisher()" :title="videoActive ? 'Mute video' : 'Unmute video'">
+ <button :class="'btn link-video' + (videoActive ? '' : ' on')" @click="switchVideo" :disabled="!isPublisher()" :title="$t('meet.menu-video-' + (videoActive ? 'mute' : 'unmute'))">
<svg-icon :icon="videoActive ? 'video' : 'video-slash'"></svg-icon>
</button>
- <button :class="'btn link-screen' + (screenShareActive ? ' on' : '')" @click="switchScreen" :disabled="!canShareScreen || !isPublisher()" title="Share screen">
+ <button :class="'btn link-screen' + (screenShareActive ? ' on' : '')" @click="switchScreen" :disabled="!canShareScreen || !isPublisher()" :title="$t('meet.menu-screen')">
<svg-icon icon="desktop"></svg-icon>
</button>
- <button :class="'btn link-hand' + (handRaised ? ' on' : '')" v-if="!isPublisher()" @click="switchHand" :title="handRaised ? 'Lower hand' : 'Raise hand'">
+ <button :class="'btn link-hand' + (handRaised ? ' on' : '')" v-if="!isPublisher()" @click="switchHand" :title="$t('meet.menu-hand-' + (handRaised ? 'lower' : 'raise'))">
<svg-icon icon="hand-paper"></svg-icon>
</button>
<span id="channel-select" :style="'display:' + (channels.length ? '' : 'none')" class="dropdown">
<button :class="'btn link-channel' + (session.channel ? ' on' : '')" data-toggle="dropdown"
- title="Interpreted language channel" aria-haspopup="true" aria-expanded="false"
+ :title="$t('meet.menu-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 :class="'dropdown-item' + (!session.channel ? ' active' : '')" href="#" data-code="" @click="switchChannel">- {{ $t('form.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>
+ >{{ $t('lang.' + code) }}</a>
</div>
</span>
- <button :class="'btn link-chat' + (chatActive ? ' on' : '')" @click="switchChat" title="Chat">
+ <button :class="'btn link-chat' + (chatActive ? ' on' : '')" @click="switchChat" :title="$t('meet.menu-chat')">
<svg-icon icon="comment"></svg-icon>
</button>
- <button class="btn link-fullscreen closed hidden" @click="switchFullscreen" title="Full screen">
+ <button class="btn link-fullscreen closed hidden" @click="switchFullscreen" :title="$t('meet.menu-fullscreen')">
<svg-icon icon="expand"></svg-icon>
</button>
- <button class="btn link-fullscreen open hidden" @click="switchFullscreen" title="Exit full screen">
+ <button class="btn link-fullscreen open hidden" @click="switchFullscreen" :title="$t('meet.menu-fullscreen-exit')">
<svg-icon icon="compress"></svg-icon>
</button>
- <button class="btn link-options" v-if="isRoomOwner()" @click="roomOptions" title="Room options">
+ <button class="btn link-options" v-if="isRoomOwner()" @click="roomOptions" :title="$t('meet.options')">
<svg-icon icon="cog"></svg-icon>
</button>
- <button class="btn link-logout" @click="logout" title="Leave session">
+ <button class="btn link-logout" @click="logout" :title="$t('meet.menu-leave')">
<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-title">{{ $t('meet.setup-title') }}</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>
+ <span class="input-group-text" :title="$t('meet.mic')"><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 value="">{{ $t('form.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>
+ <span class="input-group-text" :title="$t('meet.cam')"><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 value="">{{ $t('form.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>
+ <span class="input-group-text" :title="$t('meet.nick')"><svg-icon icon="user"></svg-icon></span>
</label>
- <input class="form-control" type="text" id="setup-nickname" v-model="nickname" placeholder="Your name">
+ <input class="form-control" type="text" id="setup-nickname" v-model="nickname" :placeholder="$t('meet.nick-placeholder')">
</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>
+ <span class="input-group-text" :title="$t('form.password')"><svg-icon icon="key"></svg-icon></span>
</label>
- <input type="password" class="form-control" id="setup-password" v-model="password" placeholder="Password">
+ <input type="password" class="form-control" id="setup-password" v-model="password" :placeholder="$t('form.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>
+ <span v-if="isRoomReady()">{{ $t('meet.joinnow') }}</span>
+ <span v-else-if="roomState == 323">{{ $t('meet.imaowner') }}</span>
+ <span v-else>{{ $t('meet.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 class="head" :title="$t('meet.qa')"><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">
+ <h5 class="modal-title">{{ $t('meet.leave-title') }}</h5>
+ <button type="button" class="close" data-dismiss="modal" :aria-label="$t('button.close')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
- <p>The session has been closed by the room owner.</p>
+ <p>{{ $t('meet.leave-body') }}</p>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-danger modal-action" data-dismiss="modal">Close</button>
+ <button type="button" class="btn btn-danger modal-action" data-dismiss="modal">{{ $t('button.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">
+ <h5 class="modal-title">{{ $t('meet.media-title') }}</h5>
+ <button type="button" class="close" data-dismiss="modal" :aria-label="$t('button.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-mic" class="input-group-prepend mb-0">
- <span class="input-group-text" title="Microphone"><svg-icon icon="microphone"></svg-icon></span>
+ <span class="input-group-text" :title="$t('meet.mic')"><svg-icon icon="microphone"></svg-icon></span>
</label>
<select class="custom-select" id="setup-mic" v-model="microphone" @change="setupMicrophoneChange">
- <option value="">None</option>
+ <option value="">{{ $t('form.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-cam" class="input-group-prepend mb-0">
- <span class="input-group-text" title="Camera"><svg-icon icon="video"></svg-icon></span>
+ <span class="input-group-text" :title="$t('meet.cam')"><svg-icon icon="video"></svg-icon></span>
</label>
<select class="custom-select" id="setup-cam" v-model="camera" @change="setupCameraChange">
- <option value="">None</option>
+ <option value="">{{ $t('form.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>
+ <button type="button" class="btn btn-secondary modal-action" data-dismiss="modal">{{ $t('button.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'
+ en: 'lang.en',
+ de: 'lang.de',
+ fr: 'lang.fr',
+ it: 'lang.it'
},
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.'
+ 'init': 'meet.status-init',
+ 323: 'meet.status-323',
+ 324: 'meet.status-324',
+ 325: 'meet.status-325',
+ 326: 'meet.status-326',
+ 327: 'meet.status-327',
+ 404: 'meet.status-404',
+ 429: 'meet.status-429',
+ 500: 'meet.status-500'
},
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>`
+ + `<button type="button" class="btn btn-sm btn-success accept">${this.$t('button.accept')}</button>`
+ + `<button type="button" class="btn btn-sm btn-danger deny ml-2">${this.$t('button.deny')}</button>`
)
this.$toast.message({
className: 'join-request',
icon: 'user',
timeout: 0,
- title: 'Join request',
+ title: this.$t('meet.join-request'),
// titleClassName: '',
body: body.html(),
onShow: element => {
const id = data.requestId
- $(element).find('p').text((data.nickname || '') + ' requested to join.')
+ $(element).find('p').text(this.$t('meet.join-requested', { user: data.nickname || '' }))
// add id attribute, so we can identify it
$(element).attr('id', 'i' + id)
// add action to the buttons
.find('button.accept,button.deny').on('click', e => {
const action = $(e.target).is('.accept') ? 'accept' : 'deny'
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.translate = (label, args) => this.$t(label, args)
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() {
this.audioActive = this.meet.switchAudio()
},
switchVideo() {
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 2b6b055f..a3ba2631 100644
--- a/src/resources/vue/Meet/RoomOptions.vue
+++ b/src/resources/vue/Meet/RoomOptions.vue
@@ -1,120 +1,117 @@
<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">
+ <h5 class="modal-title">{{ $t('meet.options') }}</h5>
+ <button type="button" class="close" data-dismiss="modal" :aria-label="$t('button.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 class="input-group-text label">{{ $t('meet.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>
+ <span v-else id="password-input-text" class="input-group-text text-muted">{{ $t('meet.password-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>
+ <button type="button" @click="passwordSave" id="password-save-btn" class="btn btn-outline-primary activable rounded-right">{{ $t('button.save') }}</button>
+ <button type="button" v-if="config.password" id="password-clear-btn" @click="passwordClear" class="btn btn-outline-danger rounded">{{ $t('meet.password-clear') }}</button>
+ <button type="button" v-else @click="passwordSet" id="password-set-btn" class="btn btn-outline-primary rounded">{{ $t('meet.password-set') }}</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.
+ {{ $t('meet.password-text') }}
</small>
</form>
<hr>
<form id="room-options-lock">
<div id="room-lock">
- <label for="room-lock-input">Locked room:</label>
+ <label for="room-lock-input">{{ $t('meet.lock') }}:</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.
+ {{ $t('meet.lock-text') }}
</small>
</form>
<hr>
<form id="room-options-nomedia">
<div id="room-nomedia">
- <label for="room-nomedia-input">Subscribers only:</label>
+ <label for="room-nomedia-input">{{ $t('meet.nomedia') }}:</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.
+ {{ $t('meet.nomedia-text') }}
</small>
</form>
</div>
<div class="modal-footer">
- <button type="button" class="btn btn-secondary modal-action" data-dismiss="modal">Close</button>
+ <button type="button" class="btn btn-secondary modal-action" data-dismiss="modal">{{ $t('button.close') }}</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
config: { type: Object, default: () => null },
room: { type: String, default: () => null }
},
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/resources/vue/PasswordReset.vue b/src/resources/vue/PasswordReset.vue
index 5c43211d..30437d64 100644
--- a/src/resources/vue/PasswordReset.vue
+++ b/src/resources/vue/PasswordReset.vue
@@ -1,157 +1,156 @@
<template>
<div class="container">
<div class="card" id="step1">
<div class="card-body">
- <h4 class="card-title">Password Reset - Step 1/3</h4>
+ <h4 class="card-title">{{ $t('password.reset') }} - {{ $t('nav.step', { i: 1, n: 3 }) }}</h4>
<p class="card-text">
- Enter your email address to reset your password.
- <span v-if="fromEmail">You may need to check your spam folder or unblock {{ fromEmail }}.</span>
+ {{ $t('password.reset-step1') }}
+ <span v-if="fromEmail">{{ $t('password.reset-step1-hint', { email: fromEmail }) }}</span>
</p>
<form @submit.prevent="submitStep1" data-validation-prefix="reset_">
<div class="form-group">
- <label for="reset_email" class="sr-only">Email Address</label>
- <input type="text" class="form-control" id="reset_email" placeholder="Email Address" required v-model="email">
+ <label for="reset_email" class="sr-only">{{ $t('form.email') }}</label>
+ <input type="text" class="form-control" id="reset_email" :placeholder="$t('form.email')" required v-model="email">
</div>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Continue</button>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('button.continue') }}</button>
</form>
</div>
</div>
<div class="card d-none" id="step2">
<div class="card-body">
- <h4 class="card-title">Password Reset - Step 2/3</h4>
+ <h4 class="card-title">{{ $t('password.reset') }} - {{ $t('nav.step', { i: 2, n: 3 }) }}</h4>
<p class="card-text">
- We sent out a confirmation code to your external email address.
- Enter the code we sent you, or click the link in the message.
+ {{ $t('password.reset-step2') }}
</p>
<form @submit.prevent="submitStep2" data-validation-prefix="reset_">
<div class="form-group">
- <label for="reset_short_code" class="sr-only">Confirmation Code</label>
- <input type="text" class="form-control" id="reset_short_code" placeholder="Confirmation Code" required v-model="short_code">
+ <label for="reset_short_code" class="sr-only">{{ $t('form.code') }}</label>
+ <input type="text" class="form-control" id="reset_short_code" :placeholder="$t('form.code')" required v-model="short_code">
</div>
- <button class="btn btn-secondary" type="button" @click="stepBack">Back</button>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Continue</button>
+ <button class="btn btn-secondary" type="button" @click="stepBack">{{ $t('button.back') }}</button>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('button.continue') }}</button>
<input type="hidden" id="reset_code" v-model="code" />
</form>
</div>
</div>
<div class="card d-none" id="step3">
<div class="card-body">
- <h4 class="card-title">Password Reset - Step 3/3</h4>
+ <h4 class="card-title">{{ $t('password.reset') }} - {{ $t('nav.step', { i: 3, n: 3 }) }}</h4>
<p class="card-text">
</p>
<form @submit.prevent="submitStep3" data-validation-prefix="reset_">
<div class="form-group">
- <label for="reset_password" class="sr-only">Password</label>
- <input type="password" class="form-control" id="reset_password" placeholder="Password" required v-model="password">
+ <label for="reset_password" class="sr-only">{{ $t('form.password') }}</label>
+ <input type="password" class="form-control" id="reset_password" :placeholder="$t('form.password')" required v-model="password">
</div>
<div class="form-group">
- <label for="reset_confirm" class="sr-only">Confirm Password</label>
- <input type="password" class="form-control" id="reset_confirm" placeholder="Confirm Password" required v-model="password_confirmation">
+ <label for="reset_confirm" class="sr-only">{{ $t('form.password-confirm') }}</label>
+ <input type="password" class="form-control" id="reset_confirm" :placeholder="$t('form.password-confirm')" required v-model="password_confirmation">
</div>
- <button class="btn btn-secondary" type="button" @click="stepBack">Back</button>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
+ <button class="btn btn-secondary" type="button" @click="stepBack">{{ $t('button.back') }}</button>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('button.submit') }}</button>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
email: '',
code: '',
short_code: '',
password: '',
password_confirmation: '',
fromEmail: window.config['mail.from.address']
}
},
created() {
// Verification code provided, auto-submit Step 2
if (this.$route.params.code) {
if (/^([A-Z0-9]+)-([a-zA-Z0-9]+)$/.test(this.$route.params.code)) {
this.short_code = RegExp.$1
this.code = RegExp.$2
this.submitStep2(true)
}
else {
this.$root.errorPage(404)
}
}
},
mounted() {
// Focus the first input (autofocus does not work when using the menu/router)
this.displayForm(1, true)
},
methods: {
// Submits data to the API, validates and gets verification code
submitStep1() {
this.$root.clearFormValidation($('#step1 form'))
axios.post('/api/auth/password-reset/init', {
email: this.email
}).then(response => {
this.displayForm(2, true)
this.code = response.data.code
})
},
// Submits the code to the API for verification
submitStep2(bylink) {
if (bylink === true) {
this.displayForm(2, false)
}
this.$root.clearFormValidation($('#step2 form'))
axios.post('/api/auth/password-reset/verify', {
code: this.code,
short_code: this.short_code
}).then(response => {
this.displayForm(3, true)
}).catch(error => {
if (bylink === true) {
// FIXME: display step 1, user can do nothing about it anyway
// Maybe we should display 404 error page?
this.displayForm(1, true)
}
})
},
// Submits the data to the API to reset the password
submitStep3() {
this.$root.clearFormValidation($('#step3 form'))
axios.post('/api/auth/password-reset', {
code: this.code,
short_code: this.short_code,
password: this.password,
password_confirmation: this.password_confirmation
}).then(response => {
// auto-login and goto dashboard
this.$root.loginUser(response.data)
})
},
// Moves the user a step back in registration form
stepBack(e) {
var card = $(e.target).closest('.card')
card.prev().removeClass('d-none').find('input').first().focus()
card.addClass('d-none').find('form')[0].reset()
},
displayForm(step, focus) {
[1, 2, 3].filter(value => value != step).forEach(value => {
$('#step' + value).addClass('d-none')
})
$('#step' + step).removeClass('d-none')
if (focus) {
$('#step' + step).find('input').first().focus()
}
}
}
}
</script>
diff --git a/src/resources/vue/Rooms.vue b/src/resources/vue/Rooms.vue
index c4f3ab6a..42f0aacb 100644
--- a/src/resources/vue/Rooms.vue
+++ b/src/resources/vue/Rooms.vue
@@ -1,104 +1,64 @@
<template>
<div class="container" dusk="rooms-component">
<div id="meet-rooms" class="card">
<div class="card-body">
- <div class="card-title">Voice &amp; Video Conferencing <small><sup class="badge badge-primary">beta</sup></small></div>
+ <div class="card-title">{{ $t('meet.title') }} <small><sup class="badge badge-primary">{{ $t('dashboard.beta') }}</sup></small></div>
<div class="card-text">
- <p>
- Welcome to our beta program for Voice &amp; Video Conferencing.
- </p>
- <p>
- You have a room of your own at the URL below. This room is only open when you yourself are in
- attendance. Use this URL to invite people to join you.
- </p>
- <p>
- <router-link v-if="href" :to="roomRoute">{{ href }}</router-link>
- </p>
- <p>
- This is a work in progress and more features will be added over time. Current features include:
- </p>
+ <p>{{ $t('meet.welcome') }}</p>
+ <p>{{ $t('meet.url') }}</p>
+ <p><router-link v-if="href" :to="roomRoute">{{ href }}</router-link></p>
+ <p>{{ $t('meet.notice') }}</p>
<dl>
- <dt>Screen Sharing</dt>
- <dd>
- Share your screen for presentations or show-and-tell.
- </dd>
-
- <dt>Room Security</dt>
- <dd>
- Increase the room security by setting a password that attendees will need to know
- before they can enter, or lock the door so attendees will have to knock, and a moderator
- can accept or deny those requests.
- </dd>
-
- <dt>Raise Hand (Q&amp;A)</dt>
- <dd>
- Silent audience members can raise their hand to facilitate a Question &amp; Answer session
- with the panel members.
- </dd>
-
- <dt>Moderator Delegation</dt>
- <dd>
- Delegate moderator authority for the session, so that a speaker is not needlessly
- interrupted with attendees knocking and other moderator duties.
- </dd>
-
- <dt>Eject Attendees</dt>
- <dd>
- Eject attendees from the session in order to force them to reconnect, or address policy
- violations. Click the user icon for effective dismissal.
- </dd>
-
- <dt>Silent Audience Members</dt>
- <dd>
- For a webinar-style session, configure the room to force all new attendees to be silent
- audience members.
- </dd>
-
- <dt>Language Specific Audio Channels</dt>
- <dd>
- Designate a participant to interpret the original audio to a target language, for sessions
- with multi-lingual attendees. The interpreter is expected to be able to relay the original
- audio, and override it.
- </dd>
+ <dt>{{ $t('meet.sharing') }}</dt>
+ <dd>{{ $t('meet.sharing-text') }}</dd>
+ <dt>{{ $t('meet.security') }}</dt>
+ <dd>{{ $t('meet.security-text') }}</dd>
+ <dt>{{ $t('meet.qa') }}</dt>
+ <dd>{{ $t('meet.qa-text') }}</dd>
+ <dt>{{ $t('meet.moderation') }}</dt>
+ <dd>{{ $t('meet.moderation-text') }}</dd>
+ <dt>{{ $t('meet.eject') }}</dt>
+ <dd>{{ $t('meet.eject-text') }}</dd>
+ <dt>{{ $t('meet.silent') }}</dt>
+ <dd>{{ $t('meet.silent-text') }}</dd>
+ <dt>{{ $t('meet.interpreters') }}</dt>
+ <dd>{{ $t('meet.interpreters-text') }}</dd>
</dl>
- <p>
- Keep in mind that this is still in beta and might come with some issues.
- Should you encounter any on your way, let us know by contacting support.
- </p>
+ <p>{{ $t('meet.beta-notice') }}</p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
rooms: [],
href: '',
roomRoute: ''
}
},
mounted() {
if (!this.$root.hasSKU('meet')) {
this.$root.errorPage(403)
return
}
this.$root.startLoading()
axios.get('/api/v4/openvidu/rooms')
.then(response => {
this.$root.stopLoading()
this.rooms = response.data.list
if (response.data.count) {
this.roomRoute = '/meet/' + encodeURI(this.rooms[0].name)
this.href = window.config['app.url'] + this.roomRoute
}
})
.catch(this.$root.errorHandler)
}
}
</script>
diff --git a/src/resources/vue/Widgets/StatusMessage.vue b/src/resources/vue/Widgets/StatusMessage.vue
index d706ba5e..293ad5ab 100644
--- a/src/resources/vue/Widgets/StatusMessage.vue
+++ b/src/resources/vue/Widgets/StatusMessage.vue
@@ -1,49 +1,49 @@
<template>
<div v-if="statusLabel()" :class="statusClass()">
<div v-if="status == 'init'" class="app-loader small">
<div class="spinner-border" role="status"></div>
</div>
<span v-if="status == 'init'">{{ statusLabel() }}</span>
<svg-icon v-if="status != 'init' && statusLabel()" :icon="Number(status) >= 400 ? 'exclamation-circle' : 'info-circle'"></svg-icon>
- <span v-if="status != 'init' && statusLabel()">{{ statusLabel() }}</span>
+ <span v-if="status != 'init' && statusLabel()">{{ $t(statusLabel()) }}</span>
</div>
</template>
<script>
const defaultLabels = {
- init: 'Loading...',
- 404: 'Resource not found.'
+ init: 'msg.loading',
+ 404: 'msg.notfound'
}
export default {
props: {
status: { type: [String, Number], default: 'init' },
statusLabels: { type: Object, default: defaultLabels }
},
methods: {
statusClass() {
let className = 'status-message'
if (this.status === 'init') {
className += ' loading'
} else if (Number(this.status) >= 400) {
className += ' text-danger'
}
return className
},
statusLabel() {
if (this.status in this.statusLabels) {
return this.statusLabels[this.status]
}
if (this.status in defaultLabels) {
return defaultLabels[this.status]
}
return ''
}
}
}
</script>

File Metadata

Mime Type
text/x-diff
Expires
Sun, Feb 1, 5:37 PM (1 d, 20 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
426760
Default Alt Text
(148 KB)

Event Timeline