diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php
index c0e71349..9c410571 100644
--- a/src/app/Http/Controllers/API/V4/OpenViduController.php
+++ b/src/app/Http/Controllers/API/V4/OpenViduController.php
@@ -1,69 +1,110 @@
user();
+
+ $rooms = Room::where('user_id', $user->id)->orderBy('name')->get();
+
+ if (count($rooms) == 0) {
+ // Create a room for the user
+ list($name, $domain) = explode('@', $user->email);
+
+ // Room name is limited to 16 characters by the DB schema
+ if (strlen($name) > 16) {
+ $name = substr($name, 0, 16);
+ }
+
+ while (Room::where('name', $name)->first()) {
+ $name = \App\Utils::randStr(8);
+ }
+
+ $room = Room::create([
+ 'name' => $name,
+ 'user_id' => $user->id
+ ]);
+
+ $rooms = collect([$room]);
+ }
+
+ $result = [
+ 'list' => $rooms,
+ 'count' => count($rooms),
+ ];
+
+ return response()->json($result);
+ }
+
/**
- * Join or create the room. Each room has one owner, and the room isn't open until the owner
+ * Join the room session. Each room has one owner, and the room isn't open until the owner
* joins (and effectively creates the session).
*/
- public function joinOrCreate($id)
+ public function joinRoom($id)
{
$user = Auth::guard()->user();
$room = Room::where('name', $id)->first();
// This isn't a room, bye bye
if (!$room) {
return $this->errorResponse(404, \trans('meet.roomnotfound'));
}
// There's no existing session
if (!$room->hasSession()) {
// Only the room owner can create the session
if ($user->id != $room->user_id) {
return $this->errorResponse(423, \trans('meet.sessionnotfound'));
}
$session = $room->createSession();
if (empty($session)) {
return $this->errorResponse(500, \trans('meet.sessioncreateerror'));
}
}
// Create session token for the current user/connection
$response = $room->getSessionToken('PUBLISHER');
if (empty($response)) {
return $this->errorResponse(500, \trans('meet.sessionjoinerror'));
}
if (!empty(request()->input('screenShare'))) {
$add_token = $room->getSessionToken('PUBLISHER');
$response['shareToken'] = $add_token['token'];
}
return response()->json($response, 200);
}
/**
* Webhook as triggered from OpenVidu server
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\Response The response
*/
public function webhook(Request $request)
{
return response('Success', 200);
}
}
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
index ed41294b..7f6caacb 100644
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -1,331 +1,334 @@
/**
* First we will load all of this project's JavaScript dependencies which
* includes Vue and other libraries. It is a great starting point when
* building robust, powerful web applications using Vue and Laravel.
*/
require('./bootstrap')
import AppComponent from '../vue/App'
import MenuComponent from '../vue/Widgets/Menu'
import store from './store'
const loader = '
Loading
'
const app = new Vue({
el: '#app',
components: {
AppComponent,
MenuComponent,
},
store,
router: window.router,
data() {
return {
isLoading: true,
isAdmin: window.isAdmin
}
},
methods: {
// Clear (bootstrap) form validation state
clearFormValidation(form) {
$(form).find('.is-invalid').removeClass('is-invalid')
$(form).find('.invalid-feedback').remove()
},
+ hasRoute(name) {
+ return this.$router.resolve({ name: name }).resolved.matched.length > 0
+ },
isController(wallet_id) {
if (wallet_id && store.state.authInfo) {
let i
for (i = 0; i < store.state.authInfo.wallets.length; i++) {
if (wallet_id == store.state.authInfo.wallets[i].id) {
return true
}
}
for (i = 0; i < store.state.authInfo.accounts.length; i++) {
if (wallet_id == store.state.authInfo.accounts[i].id) {
return true
}
}
}
return false
},
// Set user state to "logged in"
loginUser(token, dashboard) {
store.commit('logoutUser') // destroy old state data
store.commit('loginUser')
localStorage.setItem('token', token)
axios.defaults.headers.common.Authorization = 'Bearer ' + token
if (dashboard !== false) {
this.$router.push(store.state.afterLogin || { name: 'dashboard' })
}
store.state.afterLogin = null
},
// Set user state to "not logged in"
logoutUser(redirect) {
store.commit('logoutUser')
localStorage.setItem('token', '')
delete axios.defaults.headers.common.Authorization
if (redirect !== false) {
this.$router.push({ name: 'login' })
}
},
// Display "loading" overlay inside of the specified element
addLoader(elem) {
$(elem).css({position: 'relative'}).append($(loader).addClass('small'))
},
// Remove loader element added in addLoader()
removeLoader(elem) {
$(elem).find('.app-loader').remove()
},
startLoading() {
this.isLoading = true
// Lock the UI with the 'loading...' element
let loading = $('#app > .app-loader').show()
if (!loading.length) {
$('#app').append($(loader))
}
},
// Hide "loading" overlay
stopLoading() {
$('#app > .app-loader').addClass('fadeOut')
this.isLoading = false
},
errorPage(code, msg) {
// Until https://github.com/vuejs/vue-router/issues/977 is implemented
// we can't really use router to display error page as it has two side
// effects: it changes the URL and adds the error page to browser history.
// For now we'll be replacing current view with error page "manually".
const map = {
400: "Bad request",
401: "Unauthorized",
403: "Access denied",
404: "Not found",
405: "Method not allowed",
500: "Internal server error"
}
if (!msg) msg = map[code] || "Unknown Error"
const error_page = `
${code}
${msg}
`
$('#error-page').remove()
$('#app').append(error_page)
},
errorHandler(error) {
this.stopLoading()
if (!error.response) {
// TODO: probably network connection error
} else if (error.response.status === 401) {
if (!store.state.afterLogin && this.$router.currentRoute.name != 'login') {
store.state.afterLogin = this.$router.currentRoute
}
this.logoutUser()
} else {
this.errorPage(error.response.status, error.response.statusText)
}
},
downloadFile(url) {
// TODO: This might not be a best way for big files as the content
// will be stored (temporarily) in browser memory
// TODO: This method does not show the download progress in the browser
// but it could be implemented in the UI, axios has 'progress' property
axios.get(url, { responseType: 'blob' })
.then(response => {
const link = document.createElement('a')
const contentDisposition = response.headers['content-disposition']
let filename = 'unknown'
if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+)"/);
if (match.length === 2) {
filename = match[1];
}
}
link.href = window.URL.createObjectURL(response.data)
link.download = filename
link.click()
})
},
price(price, currency) {
return (price/100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' })
},
priceLabel(cost, units = 1, discount) {
let index = ''
if (units < 0) {
units = 1
}
if (discount) {
cost = Math.floor(cost * ((100 - discount) / 100))
index = '\u00B9'
}
return this.price(cost * units) + '/month' + index
},
clickRecord(event) {
if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) {
$(event.target).closest('tr').find('a')[0].click()
}
},
domainStatusClass(domain) {
if (domain.isDeleted) {
return 'text-muted'
}
if (domain.isSuspended) {
return 'text-warning'
}
if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) {
return 'text-danger'
}
return 'text-success'
},
domainStatusText(domain) {
if (domain.isDeleted) {
return 'Deleted'
}
if (domain.isSuspended) {
return 'Suspended'
}
if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) {
return 'Not Ready'
}
return 'Active'
},
userStatusClass(user) {
if (user.isDeleted) {
return 'text-muted'
}
if (user.isSuspended) {
return 'text-warning'
}
if (!user.isImapReady || !user.isLdapReady) {
return 'text-danger'
}
return 'text-success'
},
userStatusText(user) {
if (user.isDeleted) {
return 'Deleted'
}
if (user.isSuspended) {
return 'Suspended'
}
if (!user.isImapReady || !user.isLdapReady) {
return 'Not Ready'
}
return 'Active'
}
}
})
// Add a axios request interceptor
window.axios.interceptors.request.use(
config => {
// This is the only way I found to change configuration options
// on a running application. We need this for browser testing.
config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider
return config
},
error => {
// Do something with request error
return Promise.reject(error)
}
)
// Add a axios response interceptor for general/validation error handler
window.axios.interceptors.response.use(
response => {
// Do nothing
return response
},
error => {
let error_msg
let status = error.response ? error.response.status : 200
if (error.response && status == 422) {
error_msg = "Form validation error"
const modal = $('div.modal.show')
$(modal.length ? modal : 'form').each((i, form) => {
form = $(form)
$.each(error.response.data.errors || {}, (idx, msg) => {
const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx
let input = form.find('#' + input_name)
if (!input.length) {
input = form.find('[name="' + input_name + '"]');
}
if (input.length) {
// Create an error message\
// API responses can use a string, array or object
let msg_text = ''
if ($.type(msg) !== 'string') {
$.each(msg, (index, str) => {
msg_text += str + ' '
})
}
else {
msg_text = msg
}
let feedback = $('
').text(msg_text)
if (input.is('.list-input')) {
// List input widget
input.children(':not(:first-child)').each((index, element) => {
if (msg[index]) {
$(element).find('input').addClass('is-invalid')
}
})
input.addClass('is-invalid').next('.invalid-feedback').remove()
input.after(feedback)
}
else {
// Standard form element
input.addClass('is-invalid')
input.parent().find('.invalid-feedback').remove()
input.parent().append(feedback)
}
}
})
form.find('.is-invalid:not(.listinput-widget)').first().focus()
})
}
else if (error.response && error.response.data) {
error_msg = error.response.data.message
}
else {
error_msg = error.request ? error.request.statusText : error.message
}
app.$toast.error(error_msg || "Server Error")
// Pass the error as-is
return Promise.reject(error)
}
)
diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js
index 318a8da5..18fc16a8 100644
--- a/src/resources/js/fontawesome.js
+++ b/src/resources/js/fontawesome.js
@@ -1,55 +1,57 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
//import { } from '@fortawesome/free-brands-svg-icons'
import {
faCheckSquare,
faCreditCard,
faSquare,
} from '@fortawesome/free-regular-svg-icons'
import {
faCheck,
faCheckCircle,
+ faComments,
faDownload,
faGlobe,
faExclamationCircle,
faInfoCircle,
faLock,
faKey,
faPlus,
faSearch,
faSignInAlt,
faSyncAlt,
faTrashAlt,
faUser,
faUserCog,
faUsers,
faWallet
} from '@fortawesome/free-solid-svg-icons'
// Register only these icons we need
library.add(
faCheck,
faCheckCircle,
faCheckSquare,
+ faComments,
faCreditCard,
faDownload,
faExclamationCircle,
faGlobe,
faInfoCircle,
faLock,
faKey,
faPlus,
faSearch,
faSignInAlt,
faSquare,
faSyncAlt,
faTrashAlt,
faUser,
faUserCog,
faUsers,
faWallet
)
export default FontAwesomeIcon
diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js
index acbb5ef8..601b5c95 100644
--- a/src/resources/js/meet/app.js
+++ b/src/resources/js/meet/app.js
@@ -1,797 +1,805 @@
import anchorme from 'anchorme'
import { library } from '@fortawesome/fontawesome-svg-core'
import { OpenVidu } from 'openvidu-browser'
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 audioEnabled = true // True if the audio track of publisher is active
let videoEnabled = true // True if the video track of publisher is active
let numOfVideos = 0 // Keeps track of the number of videos that are being shown
let audioSource = '' // Currently selected microphone
let videoSource = '' // Currently selected camera
let sessionData // Room session metadata
let screenOV // OpenVidu object to initialize a screen sharing session
let screenSession // Session object where the user will connect for screen sharing
let screenPublisher // Publisher object which the user will publish the screen sharing
let publisherDefaults = {
publishAudio: true, // Whether to start publishing with your audio unmuted or not
publishVideo: true, // Whether to start publishing with your video enabled or not
resolution: '640x480', // The resolution of your video
frameRate: 30, // The frame rate of your video
mirror: true // Whether to mirror your local video or not
}
let cameras = [] // List of user video devices
let microphones = [] // List of user audio devices
let connections = {} // Connected users in the session
let containerWidth
let containerHeight
let chatCount = 0
let volumeElement
let setupProps
OV = new OpenVidu()
screenOV = new OpenVidu()
// if there's anything to do, do it here.
//OV.setAdvancedConfiguration(config)
// Disconnect participant when browser's window close
window.addEventListener('beforeunload', () => {
leaveRoom()
})
window.addEventListener('resize', resize)
// Public methods
this.isScreenSharingSupported = isScreenSharingSupported
this.joinRoom = joinRoom
this.leaveRoom = leaveRoom
this.setup = setup
this.setupSetAudioDevice = setupSetAudioDevice
this.setupSetVideoDevice = setupSetVideoDevice
this.switchAudio = switchAudio
this.switchScreen = switchScreen
this.switchVideo = switchVideo
/**
* Join the room session
*
* @param data Session metadata (session, token, shareToken, nickname, chatElement, menuElement)
*/
function joinRoom(data) {
resize();
volumeMeterStop()
data.params = {
nickname: data.nickname, // user nickname
// avatar: undefined // avatar image
}
sessionData = data
// Init a session
session = OV.initSession()
// On every new Stream received...
session.on('streamCreated', event => {
let connection = event.stream.connection
let connectionId = connection.connectionId
let metadata = JSON.parse(connection.data)
let wrapper = addVideoWrapper(container, metadata, event.stream)
// Subscribe to the Stream to receive it
let subscriber = session.subscribe(event.stream, wrapper);
// When the new video is added to DOM, update the page layout
subscriber.on('videoElementCreated', event => {
numOfVideos++
updateLayout()
connections[connectionId] = {
element: wrapper
}
// Send the current user status to the connecting user
// otherwise e.g. nickname might be not up to date
signalUserUpdate(connection)
})
// When a video is removed from DOM, update the page layout
subscriber.on('videoElementDestroyed', event => {
numOfVideos--
updateLayout()
delete connections[connectionId]
})
})
/*
// On every new Stream destroyed...
session.on('streamDestroyed', event => {
// Update the page layout
numOfVideos--
updateLayout()
})
*/
// Register handler for signals from other participants
session.on('signal', signalEventHandler)
// Connect with the token
session.connect(data.token, data.params)
.then(() => {
data.params.publisher = true
let wrapper = addVideoWrapper(container, data.params)
publisher.on('videoElementCreated', event => {
$(event.element).prop('muted', true) // Mute local video to avoid feedback
numOfVideos++
updateLayout()
})
publisher.createVideoElement(wrapper, 'PREPEND')
sessionData.wrapper = wrapper
// Publish the stream
session.publish(publisher)
})
.catch(error => {
console.error('There was an error connecting to the session: ', error.message);
})
// Prepare the chat
setupChat()
}
/**
* Leave the room (disconnect)
*/
function leaveRoom() {
+ if (publisher) {
+ volumeMeterStop()
+ publisher.publishAudio(false)
+ publisher.publishVideo(false)
+ }
+
if (session) {
session.disconnect();
+ session = null
}
if (screenSession) {
screenSession.disconnect();
+ screenSession = null
}
- volumeMeterStop()
+ publisher = 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, success, error)
*/
function setup(props) {
setupProps = props
- publisher = OV.initPublisher(null, publisherDefaults)
+ publisher = OV.initPublisher(undefined, publisherDefaults)
publisher.once('accessDenied', error => {
props.error(error)
})
publisher.once('accessAllowed', async () => {
let mediaStream = publisher.stream.getMediaStream()
let videoStream = mediaStream.getVideoTracks()[0]
let audioStream = mediaStream.getAudioTracks()[0]
audioEnabled = !!audioStream
videoEnabled = !!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.success({
microphones,
cameras,
audioSource,
videoSource,
audioEnabled,
videoEnabled
})
})
}
/**
* Change the publisher audio device
*
* @param deviceId Device identifier string
*/
async function setupSetAudioDevice(deviceId) {
if (!deviceId) {
publisher.publishAudio(false)
volumeMeterStop()
audioEnabled = false
} else if (deviceId == audioSource) {
publisher.publishAudio(true)
volumeMeterStart()
audioEnabled = true
} else {
const mediaStream = publisher.stream.mediaStream
const oldTrack = mediaStream.getAudioTracks()[0]
let properties = Object.assign({}, publisherDefaults, {
publishAudio: true,
publishVideo: videoEnabled,
audioSource: deviceId,
videoSource: videoSource
})
volumeMeterStop()
// Note: We're not using publisher.replaceTrack() as it wasn't working for me
// Stop and remove the old track
if (oldTrack) {
oldTrack.stop()
mediaStream.removeTrack(oldTrack)
}
// TODO: Handle errors
await OV.getUserMedia(properties)
.then(async (newMediaStream) => {
publisher.stream.mediaStream = newMediaStream
volumeMeterStart()
audioEnabled = true
audioSource = deviceId
})
}
return audioEnabled
}
/**
* Change the publisher video device
*
* @param deviceId Device identifier string
*/
async function setupSetVideoDevice(deviceId) {
if (!deviceId) {
publisher.publishVideo(false)
videoEnabled = false
} else if (deviceId == videoSource) {
publisher.publishVideo(true)
videoEnabled = true
} else {
const mediaStream = publisher.stream.mediaStream
const oldTrack = mediaStream.getAudioTracks()[0]
let properties = Object.assign({}, publisherDefaults, {
publishAudio: audioEnabled,
publishVideo: true,
audioSource: audioSource,
videoSource: deviceId
})
volumeMeterStop()
// Stop and remove the old track
if (oldTrack) {
oldTrack.stop()
mediaStream.removeTrack(oldTrack)
}
// TODO: Handle errors
await OV.getUserMedia(properties)
.then(async (newMediaStream) => {
publisher.stream.mediaStream = newMediaStream
volumeMeterStart()
videoEnabled = true
videoSource = deviceId
})
}
return videoEnabled
}
/**
* Setup the chat UI
*/
function setupChat() {
// The UI elements are created in the vue template
// Here we add a logic for how they work
const textarea = $(sessionData.chatElement).find('textarea')
const button = $(sessionData.menuElement).find('.link-chat')
textarea.on('keydown', e => {
if (e.keyCode == 13 && !e.shiftKey) {
if (textarea.val().length) {
signalChat(textarea.val())
textarea.val('')
}
return false
}
})
// Add an element for the count of unread messages on the chat button
button.append('')
.on('click', () => { button.find('.badge').text('') })
}
/**
* Signal events handler
*/
function signalEventHandler(signal) {
let conn, data
switch (signal.type) {
case 'signal:userChanged':
if (conn = connections[signal.from.connectionId]) {
data = JSON.parse(signal.data)
$(conn.element).find('.nickname > span').text(data.nickname || '')
$(conn.element).find('.status-audio')[data.audioEnabled ? 'addClass' : 'removeClass']('d-none')
$(conn.element).find('.status-video')[data.videoEnabled ? 'addClass' : 'removeClass']('d-none')
}
break
case 'signal:chat':
data = JSON.parse(signal.data)
data.id = signal.from.connectionId
pushChatMessage(data)
break
}
}
/**
* Send the chat message to other participants
*
* @param message Message string
*/
function signalChat(message) {
let data = {
nickname: sessionData.params.nickname,
message
}
session.signal({
data: JSON.stringify(data),
type: 'chat'
})
}
/**
* Add a message to the chat
*
* @param data Object with a message, nickname, id (of the connection, empty for self)
*/
function pushChatMessage(data) {
let message = $('').text(data.message).text() // make the message secure
// Format the message, convert emails and urls to links
message = anchorme({
input: message,
options: {
attributes: {
target: "_blank"
},
// any link above 20 characters will be truncated
// to 20 characters and ellipses at the end
truncate: 20,
// characters will be taken out of the middle
middleTruncation: true
}
// TODO: anchorme is extensible, we could support
// github/phabricator's markup e.g. backticks for code samples
})
message = message.replace(/\r?\n/, ' ')
// Display the message
let isSelf = data.id == publisher.stream.connection.connectionId
let chat = $(sessionData.chatElement).find('.chat')
let box = chat.find('.message').last()
message = $('
').html(message)
message.find('a').attr('rel', 'noreferrer')
if (box.length && box.data('id') == data.id) {
// A message from the same user as the last message, no new box needed
message.appendTo(box)
} else {
box = $('
').data('id', data.id)
.append($('
').text(data.nickname || ''))
.append(message)
.appendTo(chat)
if (isSelf) {
box.addClass('self')
}
}
// Count unread messages
if (!$(sessionData.chatElement).is('.open')) {
if (!isSelf) {
chatCount++
}
} else {
chatCount = 0
}
$(sessionData.menuElement).find('.link-chat .badge').text(chatCount ? chatCount : '')
}
/**
* Send the user properties update signal to other participants
*
* @param connection Optional connection to which the signal will be sent
* If not specified the signal is sent to all participants
*/
function signalUserUpdate(connection) {
let data = {
audioEnabled,
videoEnabled,
nickname: sessionData.params.nickname
}
// TODO: The same for screen sharing session?
session.signal({
data: JSON.stringify(data),
type: 'userChanged',
to: connection ? [connection] : undefined
})
}
/**
* Mute/Unmute audio for current session publisher
*/
function switchAudio() {
audioEnabled = !audioEnabled
publisher.publishAudio(audioEnabled)
$(sessionData.wrapper).find('.status-audio')[audioEnabled ? 'addClass' : 'removeClass']('d-none')
signalUserUpdate()
return audioEnabled
}
/**
* Mute/Unmute video for current session publisher
*/
function switchVideo() {
videoEnabled = !videoEnabled
publisher.publishVideo(videoEnabled)
$(sessionData.wrapper).find('.status-video')[videoEnabled ? 'addClass' : 'removeClass']('d-none')
signalUserUpdate()
return videoEnabled
}
/**
* Switch on/off screen sharing
*/
function switchScreen(callback) {
if (screenPublisher) {
screenSession.unpublish(screenPublisher)
screenPublisher = null
if (callback) {
callback(false)
}
return
}
screenConnect(callback)
}
/**
* Detect if screen sharing is supported by the browser
*/
function isScreenSharingSupported() {
return !!OV.checkScreenSharingCapabilities();
}
/**
* Create a