diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js index d267b932..72dd89b5 100644 --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -1,53 +1,53 @@ import { library } from '@fortawesome/fontawesome-svg-core' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' //import { } from '@fortawesome/free-brands-svg-icons' import { faCheckSquare, faSquare, } from '@fortawesome/free-regular-svg-icons' import { faCheck, faCheckCircle, - faComments, +// faComments, 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, +// faComments, 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.js b/src/resources/js/meet.js index d34cf76c..108a2ebe 100644 --- a/src/resources/js/meet.js +++ b/src/resources/js/meet.js @@ -1,71 +1,89 @@ /** * Application code for the Meet UI */ import routes from './routes-meet.js' window.routes = routes require('./bootstrap') import AppComponent from '../vue/Meet/App' import MenuComponent from '../vue/Widgets/Menu' import store from './store' const app = new Vue({ el: '#app', components: { AppComponent, MenuComponent, }, store, router: window.router, data() { return { isLoading: true, } }, methods: { 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}
` $('#app').children(':not(nav)').remove() $('#app').append(error_page) }, errorHandler(error) { if (!error.response) { // TODO: probably network connection error } else { this.errorPage(error.response.status, error.response.statusText) } } } }) // Add a axios request interceptor window.axios.interceptors.request.use( config => { // We're connecting to the API on the main domain config.url = window.config['app.url'] + config.url return config }, error => { // Do something with request error return Promise.reject(error) } ) + +// Register additional icons +import { library } from '@fortawesome/fontawesome-svg-core' + +import { + faExpand, + faMicrophone, + faPowerOff, + faVideo +} from '@fortawesome/free-solid-svg-icons' + +// Register only these icons we need +library.add( + faExpand, + faMicrophone, + faPowerOff, + faVideo +) diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js index 3e723efb..c397277d 100644 --- a/src/resources/js/meet/app.js +++ b/src/resources/js/meet/app.js @@ -1,177 +1,229 @@ 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 sessionId // Unique identifier of the session 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 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 + } - $(container).append('
') + let cameras = [] // List of user video devices + let microphones = [] // List of user audio devices OV = new OpenVidu() // if there's anything to do, do it here. //OV.setAdvancedConfiguration(config) // Disconnect participant on browser's window closed /* window.addEventListener('beforeunload', () => { if (session) session.disconnect(); }) */ // Public methods this.joinRoom = joinRoom + this.leaveRoom = leaveRoom + this.muteAudio = muteAudio + this.muteVideo = muteVideo + this.setup = setup + this.setupSetAudioDevice = setupSetAudioDevice + this.setupSetVideoDevice = setupSetVideoDevice + + + function setup(videoElement, success_callback, error_callback) { + publisher = OV.initPublisher(null, publisherDefaults) + + publisher.once('accessDenied', error => { + error_callback(error) + }) + + publisher.once('accessAllowed', async () => { + let mediaStream = publisher.stream.getMediaStream() + let videoStream = mediaStream.getVideoTracks()[0] + let audioStream = mediaStream.getAudioTracks()[0] + + audioEnabled = !!audioStream + videoEnabled = !!videoStream + + publisher.addVideoElement(videoElement) + + 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 + } + } + }) + + success_callback({ + microphones, + cameras, + audioSource, + videoSource, + audioEnabled, + videoEnabled + }) + }) + } + + async function setupSetAudioDevice(deviceId) { + if (!deviceId) { + publisher.publishAudio(false) + audioEnabled = false + } else if (deviceId == audioSource) { + publisher.publishAudio(true) + audioEnabled = true + } else { +/* + let mediaStream = publisher.stream.getMediaStream() + let audioStream = mediaStream.getAudioTracks()[0] + + audioStream.stop() + + publisher = OV.initPublisher(null, properties); + publisher.addVideoElement(videoElement) +*/ + + // FIXME: None of this is working + + let properties = Object.assign({}, publisherDefaults, { + publishAudio: true, + publishVideo: videoEnabled, + audioSource: deviceId, + videoSource: videoSource + }) + + await OV.getUserMedia(properties) + .then(async (mediaStream) => { + const track = mediaStream.getAudioTracks()[0] + await publisher.replaceTrack(track) + audioEnabled = true + }) + } + + return audioEnabled + } + + function setupSetVideoDevice(deviceId) { + if (!deviceId) { + publisher.publishVideo(false) + videoEnabled = false + } else if (deviceId == videoSource) { + publisher.publishVideo(true) + videoEnabled = true + } else { + // TODO + } + + return videoEnabled + } function joinRoom(data) { sessionId = data.session // Init a session session = OV.initSession() // On every new Stream received... session.on('streamCreated', function (event) { - // Subscribe to the Stream to receive it. HTML video will be appended to element with 'subscriber' id - var subscriber = session.subscribe(event.stream, 'videos'); + // Subscribe to the Stream to receive it + let subscriber = session.subscribe(event.stream, addVideoWrapper(container)); + // When the new video is added to DOM, update the page layout to fit one more participant subscriber.on('videoElementCreated', (event) => { numOfVideos++ updateLayout() }) }) // On every new Stream destroyed... session.on('streamDestroyed', (event) => { // Update the page layout numOfVideos-- updateLayout() }) + // TODO + let params = { + clientData: 'Test', // user nickname + avatar: undefined // avatar image + } + // Connect with the token - session.connect(data.token) + session.connect(data.token, params) .then(() => { - // Update the URL shown in the browser's navigation bar to show the session id - ///var path = (location.pathname.slice(-1) == "/" ? location.pathname : location.pathname + "/"); - ///window.history.pushState("", "", path + '#' + sessionId); - - // Auxiliary methods to show the session's view - //showSessionHideJoin() - - // Get the camera stream with the desired properties - publisher = OV.initPublisher('videos', { - audioSource: undefined, // The source of audio. If undefined default audio input - videoSource: undefined, // The source of video. If undefined default video input - 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 - insertMode: 'PREPEND', // How the video is inserted in target element 'video-container' - mirror: true // Whether to mirror your local video or not - }) + publisher.createVideoElement(addVideoWrapper(container), 'PREPEND') // When our HTML video has been added to DOM... publisher.on('videoElementCreated', (event) => { + $(event.element).addClass('publisher') + .prop('muted', true) // Mute local video to avoid feedback + // When your own video is added to DOM, update the page layout to fit it numOfVideos++ updateLayout() - $(event.element).prop('muted', true) // Mute local video to avoid feedback }) // Publish the stream session.publish(publisher) }) .catch(error => { console.log('There was an error connecting to the session:', error.code, error.message); }) } function leaveRoom() { // Leave the session by calling 'disconnect' method over the Session object - session.disconnect(); + if (session) { + session.disconnect(); + } } function muteAudio() { audioEnabled = !audioEnabled publisher.publishAudio(audioEnabled) - if (!audioEnabled) { - $('#mute-audio').removeClass('btn-primary') - $('#mute-audio').addClass('btn-default') - } else { - $('#mute-audio').addClass('btn-primary') - $('#mute-audio').removeClass('btn-default') - } + return audioEnabled } function muteVideo() { videoEnabled = !videoEnabled publisher.publishVideo(videoEnabled) - if (!videoEnabled) { - $('#mute-video').removeClass('btn-primary') - $('#mute-video').addClass('btn-default') - } else { - $('#mute-video').addClass('btn-primary') - $('#mute-video').removeClass('btn-default') - } + return videoEnabled } - // 'Session' page - function showSessionHideJoin() { - $('#nav-join').hide() - $('#nav-session').show() - $('#join').hide() - $('#session').show() - $('footer').hide() - $('#main-container').removeClass('container') - } - - // 'Join' page - function showJoinHideSession() { - $('#nav-join').show() - $('#nav-session').hide() - $('#join').show() - $('#session').hide() - $('footer').show() - $('#main-container').addClass('container') + function updateLayout() { + // update the "matrix" layout } - // Dynamic layout adjustemnt depending on number of videos - function updateLayout() { - console.warn('There are now ' + numOfVideos + ' videos') - - var publisherDiv = $('#publisher') - var publisherVideo = $("#publisher video") - var subscriberVideos = $('#videos > video') - - switch (numOfVideos) { - case 1: - publisherVideo.addClass('video1') - break - case 2: - publisherDiv.addClass('video2') - subscriberVideos.addClass('video2') - break - case 3: - publisherDiv.addClass('video3') - subscriberVideos.addClass('video3') - break - case 4: - publisherDiv.addClass('video4') - publisherVideo.addClass('video4') - subscriberVideos.addClass('video4') - break - default: - publisherDiv.addClass('videoMore') - publisherVideo.addClass('videoMore') - subscriberVideos.addClass('videoMore') - break - } + function addVideoWrapper(container) { + return $('
').appendTo(container).get(0) } } export default Meet diff --git a/src/resources/js/routes-meet.js b/src/resources/js/routes-meet.js index d1e95c01..b3dc342f 100644 --- a/src/resources/js/routes-meet.js +++ b/src/resources/js/routes-meet.js @@ -1,15 +1,16 @@ import DashboardComponent from '../vue/Meet/Dashboard' import RoomComponent from '../vue/Meet/Room' const routes = [ { path: '/', + name: 'dashboard', component: DashboardComponent }, { path: '*', component: RoomComponent } ] export default routes diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss index 0077ff37..3e1bdace 100644 --- a/src/resources/sass/app.scss +++ b/src/resources/sass/app.scss @@ -1,231 +1,232 @@ // Fonts // Variables @import 'variables'; // Bootstrap @import '~bootstrap/scss/bootstrap'; +@import 'meet'; @import 'menu'; @import 'toast'; @import 'forms'; html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; & > nav { flex-shrink: 0; z-index: 1; } & > div.container { flex-grow: 1; margin-top: 2rem; margin-bottom: 2rem; } & > .filler { flex-grow: 1; } & > div.container + .filler { display: none; } } #error-page { position: absolute; top: 0; height: 100%; width: 100%; align-items: center; display: flex; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } tfoot.table-fake-body { background-color: #f8f8f8; color: grey; text-align: center; height: 8em; td { vertical-align: middle; } tbody:not(:empty) + & { display: none; } } table { td.buttons, td.price, td.selection { width: 1%; } td.price { text-align: right; } &.form-list { td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } } } #status-box { background-color: lighten($green, 35); .progress { background-color: #fff; height: 10px; } .progress-label { font-size: 0.9em; } .progress-bar { background-color: $green; } &.process-failed { background-color: lighten($orange, 30); .progress-bar { background-color: $red; } } } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin-top: 0.5rem; text-decoration: none; width: 150px; &.disabled { pointer-events: none; opacity: 0.6; } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } & + a { margin-left: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } .plan-selector { .plan-ico { font-size: 4em; color: #f1a539; border: 3px solid #f1a539; width: 100px; margin-bottom: 1rem; border-radius: 50%; } ul:last-child { margin-bottom: 0; } } // Openvidu webcomponent improvements mat-sidenav-container { z-index: 10 !important; } #dialogChooseRoom { top: 0; left: 0; z-index: 10; background: #fff; & > .mat-card { max-height: unset; margin: 2em auto; } } diff --git a/src/resources/sass/meet.scss b/src/resources/sass/meet.scss new file mode 100644 index 00000000..8464f659 --- /dev/null +++ b/src/resources/sass/meet.scss @@ -0,0 +1,42 @@ +#meet-component { + +} + +#meet-session-toolbar { + display: flex; + justify-content: center; +} + +#meet-session-menu { + button { + font-size: 1.3rem; + padding: 0.5rem 1rem; + } +} + +#meet-session { + display: flex; + justify-content: center; +} + +.meet-video { + position: relative; + + video { + display: block; + width: 100%; + } +} + +#meet-setup { +} + +#setup-preview { + display: flex; + + video { + width: 100%; + transform: rotateY(180deg); + background: black; + } +} diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue index 96d6a20a..6ee3c7e0 100644 --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -1,71 +1,68 @@ diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue index 697bfd7f..d46caf90 100644 --- a/src/resources/vue/Meet/Room.vue +++ b/src/resources/vue/Meet/Room.vue @@ -1,35 +1,141 @@