diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php index 9c410571..7d0dcad6 100644 --- a/src/app/Http/Controllers/API/V4/OpenViduController.php +++ b/src/app/Http/Controllers/API/V4/OpenViduController.php @@ -1,110 +1,140 @@ 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 the room session. Each room has one owner, and the room isn't open until the owner * joins (and effectively creates the session). + * + * @param string $id Room identifier (name) + * + * @return \Illuminate\Http\JsonResponse */ 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')); } + $user = Auth::guard()->user(); + // There's no existing session if (!$room->hasSession()) { - // Only the room owner can create the session - if ($user->id != $room->user_id) { + // Participants can't join the room until the session is created by the owner + if (!$user || $user->id != $room->user_id) { return $this->errorResponse(423, \trans('meet.sessionnotfound')); } + // The room owner can create the session on request + if (empty(request()->input('init'))) { + return $this->errorResponse(424, \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')); } + // Create session token for screen sharing connection if (!empty(request()->input('screenShare'))) { $add_token = $room->getSessionToken('PUBLISHER'); $response['shareToken'] = $add_token['token']; } + // Tell the UI who's the room owner + $response['owner'] = $user && $user->id == $room->user_id; + 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) { + \Log::debug($request->getContent()); + + switch ((string) $request->input('event')) { + case 'sessionDestroyed': + // When all participants left the room OpenVidu dispatches sessionDestroyed + // event. We'll remove the session reference from the database. + $sessionId = $request->input('sessionId'); + $room = Room::where('session_id', $sessionId)->first(); + + if ($room) { + $room->session_id = null; + $room->save(); + } + + break; + } + return response('Success', 200); } } diff --git a/src/database/migrations/2020_04_30_115440_create_openvidu_tables.php b/src/database/migrations/2020_04_30_115440_create_openvidu_tables.php index 2f4bb798..97b8afe7 100644 --- a/src/database/migrations/2020_04_30_115440_create_openvidu_tables.php +++ b/src/database/migrations/2020_04_30_115440_create_openvidu_tables.php @@ -1,55 +1,55 @@ bigIncrements('id'); $table->bigInteger('user_id'); - $table->string('name', 16)->unique()->index(); - $table->string('session_id', 16)->nullable(); + $table->string('name', 16)->unique(); + $table->string('session_id', 16)->nullable()->unique(); $table->timestamps(); $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); } ); Schema::create( 'openvidu_room_settings', function (Blueprint $table) { $table->bigIncrements('id'); $table->bigInteger('room_id')->unsigned(); $table->string('key', 16); $table->string('value'); $table->timestamps(); $table->foreign('room_id')->references('id') ->on('openvidu_rooms')->onDelete('cascade'); } ); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('openvidu_room_settings'); Schema::dropIfExists('openvidu_rooms'); } } diff --git a/src/resources/js/app.js b/src/resources/js/app.js index 39583fac..87a3d500 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,359 +1,363 @@ /** * 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(response, dashboard, update) { if (!update) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') } localStorage.setItem('token', response.access_token) axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null // Refresh the token before it expires let timeout = response.expires_in || 0 // We'll refresh 60 seconds before the token expires // or immediately when we have no expiration time (on token re-use) if (timeout > 60) { timeout -= 60 } // TODO: We probably should try a few times in case of an error // TODO: We probably should prevent axios from doing any requests // while the token is being refreshed this.refreshTimeout = setTimeout(() => { axios.post('/api/auth/refresh').then(response => { this.loginUser(response.data, false, true) }) }, timeout * 1000) }, // 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' }) + if (this.hasRoute('login')) { + this.$router.push({ name: 'login' }) + } else { + window.location = window.config['app.url'] + } } clearTimeout(this.refreshTimeout) }, // 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/meet/app.js b/src/resources/js/meet/app.js index 601b5c95..89075f84 100644 --- a/src/resources/js/meet/app.js +++ b/src/resources/js/meet/app.js @@ -1,805 +1,811 @@ 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 audioEnabled = false // True if the audio track of publisher is active + let videoEnabled = false // 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 } 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(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) + // TODO: Handle enabling audio if it was never enabled (e.g. user joined the room + // without giving access to his mic) + $(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) + // TODO: Handle enabling video if it was never enabled (e.g. user joined the room + // without giving access to his camera) + $(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