UNPKG

globular-mvc

Version:

Generic template to create web-application that made use of globular as backend and materialize as css (wrap in web-component's)

784 lines (651 loc) 31.3 kB
import { AppLayoutBehavior } from '@polymer/app-layout/app-layout-behavior/app-layout-behavior'; import { LogInfo, LogRqst } from 'globular-web-client/log/log_pb'; import { Application } from '../Application'; import { applicationView, ApplicationView } from '../ApplicationView'; import { Model } from '../Model'; import { setMoveable } from './moveable' import { setResizeable } from './rezieable' import '@polymer/iron-icons/communication-icons' import { fireResize } from './utility'; // Contains the stun server URL we will be using. let connectionConfig = { 'iceServers': [{ 'urls': 'stun:stun.stunprotocol.org:3478' }, { 'urls': 'stun:stun.l.google.com:19302' }, ], sdpSemantics: 'unified-plan' }; const offerOptions = { offerToReceiveAudio: 1, offerToReceiveVideo: 1 }; /** * Video conversation. */ export class VideoConversation extends HTMLElement { // attributes. // Create conversation view. constructor(conversationUuid, domain) { super() // Keep reference to the conversation this.conversationUuid = conversationUuid this.domain = domain this.eventHub = Model.getGlobule(this.domain).eventHub this.localStream = null; this.pendingCanditates = []; this.listeners = {}; // Set the shadow dom. this.attachShadow({ mode: 'open' }); // The connections with other participants. this.connections = {}; let hideheader = this.getAttribute("hideheader") != undefined // Innitialisation of the layout. this.shadowRoot.innerHTML = ` <style> #container{ width: 720px; position: fixed; max-height: calc(100vh - 100px) } .header{ display: flex; align-items: center; color: var(--palette-text-accent); background-color: var(--palette-primary-accent); } .header span{ flex-grow: 1; text-align: center; font-size: 1.1rem; font-weight: 500; display: inline-block; white-space: nowrap; overflow: hidden !important; text-overflow: ellipsis; } #local-video{ width: 200px; position: absolute; bottom: 0px; right: 0px; } .peers-video{ display: flex; position: relative; } .peers-video video{ width: 100%; height: auto; } #tool-bar { display: flex; } #start-share-screen{ display: none; } #stop-share-screen{ display: none; } #title-span{ flex-grow: 1; } paper-card{ background-color: var(--palette-background-paper); color: var(--palette-text-primary); } @media (max-width: 500px) { #local-video{ width: 100px; position: absolute; bottom: 0px; right: 0px; } #container{ display: flex; flex-direction: column; top: 0px; left: 0px; width: 100vw; } video{ max-height: 40vh; background-color: black; } } </style> <paper-card id="container" class="no-select"> <div class="header" style="${hideheader ? "display:none;" : ""}"> <paper-icon-button id="video-close-btn" icon="icons:close" style="min-width: 40px; --iron-icon-fill-color: var(--palette-text-accent);"></paper-icon-button> <paper-icon-button id="video-options-btn" icon="icons:arrow-drop-down" style="margin-left: 20px; min-width: 40px; --iron-icon-fill-color: var(--palette-text-accent);"></paper-icon-button> <span id="title-span"></span> <paper-icon-button id="start-share-screen" icon="communication:screen-share" ></paper-icon-button> </div> <div class="peers-video"> <div id="local-video-div"> </div> </paper-card> ` let container = this.shadowRoot.querySelector("#container") this.shadowRoot.querySelector("#video-close-btn").onclick = () => { Model.publish(`leave_conversation_${conversationUuid}_evt`, JSON.stringify({ "conversationUuid": conversationUuid, "participants": [], "participant": Application.account.id }), false) } let optionsBtn = this.shadowRoot.querySelector("#video-options-btn") optionsBtn.onclick = () => { let optionsPanel = this.shadowRoot.querySelector("#options-panel") if (!optionsPanel) { let html = ` <style> #options-panel{ display: flex; flex-direction: column; background-color: var(--palette-background-paper); color: var(--palette-text-primary); position: absolute; top: 0px; left: 0px; font-size: 1rem; } #options-div{ display: flex; flex-direction: column; } .table-row { display: flex; font-size: 1.1rem; padding: 5px; } .option { padding: 5px; } .label { padding: 5px; min-width: 200px; } paper-toggle-button{ --paper-toggle-button-label-color: var(--palette-text-primary); } paper-toggle-button[checked]{ --paper-toggle-button-label-color: var(--palette-text-accent); } </style> <paper-card id="options-panel"> <div id="options-div"> <div class="table-row"> <div class="label">Local video</div> <paper-toggle-button id="local-video-toggle" class="option" checked>Visible</paper-toggle-button> </div> <div class="table-row"> <div class="label">Video streaming</div> <paper-toggle-button id="video-toggle" class="option" checked>Enable</paper-toggle-button> </div> <div class="table-row"> <div class="label">Audio streaming</div> <paper-toggle-button id="audio-toggle" class="option" checked>Enable</paper-toggle-button> </div> </div> <div style="display: flex; width: 100%; justify-content: flex-end;"> <paper-button id="close-btn">Close</paper-button> </div> </paper-card> ` let range = document.createRange() container.querySelector(".peers-video").appendChild(range.createContextualFragment(html)) optionsPanel = this.shadowRoot.querySelector("#options-panel") let hideShowLocalVideoToggle = container.querySelector("#local-video-toggle") hideShowLocalVideoToggle._enabled_ = true; hideShowLocalVideoToggle.onclick = () => { let localVideo = this.shadowRoot.querySelector("#local-video") if (hideShowLocalVideoToggle.checked) { localVideo.style.display = "" hideShowLocalVideoToggle.innerHTML = "Visible" hideShowLocalVideoToggle._enabled_ = true } else { localVideo.style.display = "none" hideShowLocalVideoToggle.innerHTML = "Hidden" hideShowLocalVideoToggle._enabled_ = false } } let hideShowVideoToggle = container.querySelector("#video-toggle") hideShowVideoToggle.onclick = () => { let localVideo = this.shadowRoot.querySelector("#local-video") if (hideShowVideoToggle.checked) { let stream = localVideo.srcObject; // now get all tracks let tracks = stream.getTracks(); // now close each track by having forEach loop tracks.forEach((track) => { // stopping every track if (track.kind == "video") { track.enabled = true; } }); localVideo.style.display = "" if (hideShowLocalVideoToggle._enabled_) { hideShowLocalVideoToggle.innerHTML = "Visible" hideShowLocalVideoToggle.checked = true localVideo.style.display = "" } else { hideShowLocalVideoToggle.innerHTML = "Hidden" hideShowLocalVideoToggle.checked = false localVideo.style.display = "none" } hideShowVideoToggle.innerHTML = "Enable" } else { let stream = localVideo.srcObject; // now get all tracks let tracks = stream.getTracks(); // now close each track by having forEach loop tracks.forEach((track) => { // stopping every track if (track.kind == "video") { track.enabled = false; } }); localVideo.style.display = "none" hideShowLocalVideoToggle.innerHTML = "Disable" hideShowVideoToggle.innerHTML = "Disable" if (hideShowLocalVideoToggle.checked) { hideShowLocalVideoToggle._enabled_ = hideShowLocalVideoToggle.checked hideShowLocalVideoToggle.checked = false; } } } let muteUnmuteVideoToggle = container.querySelector("#audio-toggle") muteUnmuteVideoToggle.onclick = () => { let localVideo = this.shadowRoot.querySelector("#local-video") if (muteUnmuteVideoToggle.checked) { let stream = localVideo.srcObject; // now get all tracks let tracks = stream.getTracks(); // now close each track by having forEach loop tracks.forEach((track) => { // stopping every track if (track.kind == "audio") { track.enabled = true; } }); muteUnmuteVideoToggle.innerHTML = "Enable" } else { let stream = localVideo.srcObject; // now get all tracks let tracks = stream.getTracks(); // now close each track by having forEach loop tracks.forEach((track) => { // stopping every track if (track.kind == "audio") { track.enabled = false; } }); muteUnmuteVideoToggle.innerHTML = "Muted" } } let closeBtn = container.querySelector("#close-btn") closeBtn.onclick = () => { optionsPanel.style.display = "none" } } else { if (optionsPanel.style.display == "none") { optionsPanel.style.display = "" } else { optionsPanel.style.display = "none" } } } let offsetTop = this.shadowRoot.querySelector(".header").offsetHeight if (offsetTop == 0) { offsetTop = 60 } container.style.top = offsetTop + "px" this.peersVideo = this.shadowRoot.querySelector(".peers-video") this.startShareScreenBtn = this.shadowRoot.querySelector("#start-share-screen") container.name = "webrtc_window" if (localStorage.getItem("__webrtc_panel_dimension__")) { let dimension = JSON.parse(localStorage.getItem("__webrtc_panel_dimension__")) if (!dimension) { dimension = { with: 400, height: 400 } } // be sure the dimension is no zeros... if (dimension.width < 400) { dimension.width = 400 } if (dimension.height < 400) { dimension.height = 400 } container.style.width = dimension.width + "px" container.style.height = dimension.height + "px" localStorage.setItem("__webrtc_panel_dimension__", JSON.stringify({ width: dimension.width, height: dimension.height })) } else { container.style.width = "400px" container.style.height = "400px" localStorage.setItem("__webrtc_panel_dimension__", JSON.stringify({ width: 400, height: 400 })) } setResizeable(container, (width, height) => { // fix min size. if (height < 400) { height = 400 } if (width < 400) { width = 400 } localStorage.setItem("__webrtc_panel_dimension__", JSON.stringify({ width: width, height: height })) container.style.height = "auto" let w = ApplicationView.layout.width(); if (w < 500) { container.style.width = "100vw" } else { container.style.width = width + "px" } }) container.resizeHeightDiv.style.display = "none" container.style.height = "auto" setMoveable(this.shadowRoot.querySelector(".header"), container, (left, top) => { /** */ }, this, offsetTop) // This will replace all sender video withe the screen capture. this.startShareScreenBtn.onclick = () => { // Replace all video with the screen sharing button. this.initScreenCaptureStream(track => { for (var id in this.connections) { this.connections[id].getSenders().forEach(sender => { if (sender.track.kind === 'video') { sender.replaceTrack(track) } }) } }, err => { ApplicationView.displayMessage(err, 3000) }) } // Start a new video conversation with a remote participant if (this.listeners[`start_video_conversation_${this.conversationUuid}_evt`] == undefined) { Model.eventHub.subscribe(`start_video_conversation_${this.conversationUuid}_evt`, (uuid) => { this.listeners[`start_video_conversation_${this.conversationUuid}_evt`] = uuid }, (participant) => { // Create offer to each participants if (participant.id != Application.account._id) { let connectionId = this.conversationUuid + "_" + participant.id // That will set the on iceconnection ready callback to create offer. this.getConnection(connectionId, rtcPeerConnection => { rtcPeerConnection.onnegotiationneeded = () => { // Set the connection. rtcPeerConnection .createOffer(offerOptions) .then((offer) => { rtcPeerConnection.setLocalDescription(offer).then(() => { Model.publish(`on_webrtc_offer_${connectionId}_evt`, JSON.stringify({ "offer": offer, "connectionId": this.conversationUuid + "_" + Application.account._id }), false); }) }) .catch((err) => { ApplicationView.displayMessage(err, 3000) }); } }); } }, true, this) } if (this.listeners[`leave_conversation_${this.conversationUuid}_evt`] == undefined) { this.eventHub.subscribe(`leave_conversation_${this.conversationUuid}_evt`, (uuid) => { // todo remove listeners... this.listeners[`leave_conversation_${this.conversationUuid}_evt`] = uuid }, (evt_) => { // Remove the participant. let evt = JSON.parse(evt_) // Close the connection with the client if (evt.participant == Application.account._id) { // Here I will close all conversation connections. for (let connectionId in this.connections) { if (connectionId.startsWith(this.conversationUuid)) { this.closeConnection(connectionId) } } } else { let connectionId = this.conversationUuid + "_" + evt.participant if (this.connections[connectionId] != null) { this.closeConnection(connectionId) } } }, false, this); } // When we receive peer connection offer... if (this.listeners[`on_webrtc_offer_${this.conversationUuid + "_" + Application.account._id}_evt`] == undefined) { this.eventHub.subscribe(`on_webrtc_offer_${this.conversationUuid + "_" + Application.account._id}_evt`, (uuid) => { this.listeners[`on_webrtc_offer_${this.conversationUuid + "_" + Application.account._id}_evt`] = uuid }, (evt) => { let event = JSON.parse(evt) let connectionId = event.connectionId // Get the connections. this.getConnection(connectionId, rtcPeerConnection => { //let offer = new RTCSessionDescription(event.offer) rtcPeerConnection.setRemoteDescription(event.offer).then(() => { // Append pending ice candidate. while (this.pendingCanditates.length > 0) { rtcPeerConnection.addIceCandidate(this.pendingCanditates.pop()).catch(e => { console.error(e) }); } // create the answer. rtcPeerConnection .createAnswer() .then(answer => { rtcPeerConnection.setLocalDescription(answer).then(() => { Model.publish(`on_webrtc_answer_${connectionId}_evt`, JSON.stringify({ "answer": answer, "connectionId": this.conversationUuid + "_" + Application.account._id }), false); }) }) .catch((err) => { ApplicationView.displayMessage(err, 3000) }); }, err => { ApplicationView.displayMessage(err, 3000) }) }) }, false, this) } // When we receive peers connection answer... if (this.listeners[`on_webrtc_answer_${this.conversationUuid + "_" + Application.account._id}_evt`] == undefined) { this.eventHub.subscribe(`on_webrtc_answer_${this.conversationUuid + "_" + Application.account._id}_evt`, (uuid) => { this.listeners[`on_webrtc_answer_${this.conversationUuid + "_" + Application.account._id}_evt`] = uuid }, (evt) => { let event = JSON.parse(evt) let rtcPeerConnection = this.connections[event.connectionId] //let answer = new RTCSessionDescription(event.answer) rtcPeerConnection.setRemoteDescription(event.answer); }, false, this) } // When we receive a ace candidate answer if (this.listeners[`on_webrtc_candidate_${this.conversationUuid + "_" + Application.account._id}_evt`] == undefined) { this.eventHub.subscribe(`on_webrtc_candidate_${this.conversationUuid + "_" + Application.account._id}_evt`, (uuid) => { this.listeners[`on_webrtc_candidate_${this.conversationUuid + "_" + Application.account._id}_evt`] = uuid }, (event) => { let evt = JSON.parse(event) this.getConnection(evt.connectionId, rtcPeerConnection => { let icecandidate = new RTCIceCandidate(evt.candidate); if (rtcPeerConnection.remoteDescription) { rtcPeerConnection.addIceCandidate(icecandidate); } else { this.pendingCanditates.push(icecandidate) } }) }, false, this) } } closeConnection(connectionId) { console.log("close connection: ", connectionId) let peerVideo = this.peersVideo.querySelector("#_" + connectionId + "_video") if (peerVideo != undefined) { this.peersVideo.removeChild(peerVideo) } // Get the local video display... let localVideo = this.shadowRoot.querySelector("#local-video") if (localVideo != null) { // now get the steam let stream = localVideo.srcObject; // now get all tracks let tracks = stream.getTracks(); // now close each track by having forEach loop tracks.forEach((track) => { // stopping every track track.stop(); }); // assign null to srcObject of video localVideo.srcObject = null; localVideo.parentNode.removeChild(localVideo) } this.connections[connectionId].close() delete this.connections[connectionId] if (Object.keys(this.connections).length == 0) { this.startShareScreenBtn.style.display = "none" this.parentNode.removeChild(this) } Model.publish(`video_conversation_close_${connectionId}_evt`, {}, false); } // init a new peer connections. getConnection(connectionId, callback, onconnected) { // Get existing let rtcPeerConnection = this.connections[connectionId] if (rtcPeerConnection == null) { rtcPeerConnection = new RTCPeerConnection(connectionConfig); this.connections[connectionId] = rtcPeerConnection; } else { callback(rtcPeerConnection) // connection already exist... return } // when video is received from the remote side. rtcPeerConnection.ontrack = evt => { this.initRemoteVideoStream(connectionId, evt) } this.initLocalVideoStream(stream => { // keep a ref for further use... this.localStream = stream; rtcPeerConnection.onicecandidate = (evt) => { if (evt.candidate) { Model.publish(`on_webrtc_candidate_${connectionId}_evt`, JSON.stringify({ "candidate": evt.candidate.toJSON(), "connectionId": this.conversationUuid + "_" + Application.account._id }), false); } } // Add audio track and video track. stream.getTracks().forEach((track) => { rtcPeerConnection.addTrack(track, stream); }); // connections state handler. rtcPeerConnection.oniceconnectionstatechange = (event) => { console.log(event) switch (rtcPeerConnection.iceConnectionState) { case "connected": // The connection has become fully connected if (onconnected != undefined) { onconnected(rtcPeerConnection) } Model.publish(`video_conversation_open_${connectionId}_evt`, {}, false); break; case "disconnected": console.log("connection was disconnected", connectionId) this.closeConnection(connectionId) break; case "failed": console.log("connection has fail", connectionId) this.closeConnection(connectionId) break; case "closed": console.log("connection has fail", connectionId) // The connection has been closed this.closeConnection(connectionId) break; } } // Callback when done... if (callback != null) { callback(rtcPeerConnection) } }, err => { ApplicationView.displayMessage(err, 3000) }) } // Init the local screen capture... initScreenCaptureStream(callback, errorCallback) { // Get the stream id from the user... navigator.mediaDevices.getDisplayMedia({ cursor: true }).then(stream => { const screenTrack = stream.getTracks()[0]; this.startShareScreenBtn.style.display = "none" screenTrack.onended = () => { callback(this.localStream.getTracks()[1]) this.startShareScreenBtn.style.display = "block" } callback(screenTrack) }).catch(err => { errorCallback(err) }) } // Initialyse the local video stream object. initLocalVideoStream(callback, errorCallback) { // First i will retreived the video object. let localVideo = this.shadowRoot.getElementById("local-video"); if (localVideo != undefined) { return } localVideo = document.createElement("video") localVideo.id = "local-video" this.shadowRoot.querySelector("#local-video-div").appendChild(localVideo) // Remove the echo... localVideo.muted = true; localVideo.volume = 0; // Display the share connection button. this.startShareScreenBtn.style.display = "block" // Connect the user video navigator.mediaDevices .getUserMedia({ audio: true, video: true, }) /*.then((stream) => { localVideo.srcObject = stream; localVideo.onloadedmetadata = (e) => { localVideo.play(); fireResize() callback(stream) }; })*/ .then(function (stream) { if ("srcObject" in localVideo) { localVideo.srcObject = stream; } else { localVideo.src = window.URL.createObjectURL(stream); } localVideo.onloadedmetadata = (e) => { localVideo.play(); fireResize() callback(stream) }; }) .catch((err) => { /* handle the error */ errorCallback(err) }); } /** * Create the remote video player. * @param {*} id Must be the id of the remote user. * @param {*} stream The stream given by the web-rtc. */ initRemoteVideoStream(connectionId, e) { let remoteVideo = this.peersVideo.querySelector("#_" + connectionId + "_video") if (remoteVideo == null) { remoteVideo = document.createElement("video") remoteVideo.id = "_" + connectionId + "_video" remoteVideo.autoplay = true remoteVideo.playsinline = true this.peersVideo.appendChild(remoteVideo) remoteVideo.srcObject = new MediaStream(); } remoteVideo.srcObject.addTrack(e.track); } } customElements.define('globular-video-conversation', VideoConversation)