├── .gitignore ├── LICENSE ├── README.md ├── webapp ├── index.html ├── package.json ├── src │ ├── UIElements │ │ └── LoginForm.tsx │ ├── Utils.ts │ ├── app.scss │ ├── app.tsx │ └── img │ │ └── icon.png ├── tsconfig.json └── webpack.config.js └── webservice └── auth.php /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | #MAC 36 | .DS_Store 37 | typings 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Alexey Aylarov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VoxImplant Client Side Conferencing using P2P Calls and Web Audio 2 | Webapp folder contains web application built using Web SDK, ReactJS and TypeScript, it can act like conference host and can be connected to the conference by inbound call. 3 | Webservice folder contains PHP script used to proxy requests to VoxImplant HTTP API to create users and authorization. 4 | 5 | ## Important Tips 6 | 1. Don't forget to replace YOUR_API_KEY, YOUR_ACCOUNT_NAME, APP_NAME and SOME_PASSWORD with your data in `auth.php` 7 | 2. Don't forget to replace YOUR_VOX_APPNAME, YOUR_VOX_ACCNAME and YOUR_DOMAIN with your data in `app.tsx` 8 | 9 | ### Building and running 10 | 11 | The webapp uses webpack: 12 | 13 | 1. npm install 14 | 2. webpack 15 | 16 | -------------------------------------------------------------------------------- /webapp/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | VoxImplant Client Conferencing 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /webapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "voximplant-clientconf-app", 3 | "version": "0.0.1", 4 | "description": "VoxImplant Client Conferencing", 5 | "keywords": [ 6 | "react", 7 | "reactjs", 8 | "webpack", 9 | "voximplant" 10 | ], 11 | "author": "Alexey Aylarov (http://github.com/aylarov)", 12 | "license": "MIT", 13 | "bugs": { 14 | "url": "https://github.com/aylarov/voxclientconf/issues" 15 | }, 16 | "postinstall": "typings install voximplant-websdk && typings install jquery --ambient && typings install react --ambient && typings install react-dom --ambient && typings install react-bootstrap --ambient", 17 | "homepage": "https://github.com/aylarov/voxclientconf", 18 | "dependencies": { 19 | "css-loader": "^0.23.1", 20 | "es6-promise": "^3.1.2", 21 | "file-loader": "^0.8.5", 22 | "jquery": "^2.1.4", 23 | "node-sass": "^3.4.1", 24 | "postcss": "^5.0.16", 25 | "postcss-loader": "^0.8.1", 26 | "react": "^0.14.7", 27 | "react-bootstrap": "^0.28.3", 28 | "react-dom": "^0.14.7", 29 | "react-hot-loader": "^1.3.0", 30 | "sass-loader": "^3.1.2", 31 | "ts-jsx-loader": "^0.2.1", 32 | "ts-loader": "^0.8.1", 33 | "typescript": "^1.8.0", 34 | "url-loader": "^0.5.7", 35 | "voximplant-websdk": "^3.6.294", 36 | "webpack": "^1.12.2", 37 | "webpack-dev-server": "^1.12.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /webapp/src/UIElements/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | declare function require(string): any; 2 | 3 | import * as React from 'react'; 4 | import * as ReactDOM from 'react-dom'; 5 | import $ = require('jquery'); 6 | import { 7 | Alert, 8 | Button, 9 | Panel, 10 | Input 11 | } from 'react-bootstrap'; 12 | 13 | interface Props { 14 | onSubmit: (displayName: string) => void; 15 | ref: string; 16 | } 17 | 18 | class LoginForm extends React.Component { 19 | 20 | constructor(props: Props) { 21 | super(props); 22 | } 23 | 24 | private componentDidMount() { 25 | var el = this.refs["loginForm"]; 26 | $(el).submit(function(event) { 27 | let displayName = this.refs["displayName"].getValue(); 28 | this.props.onSubmit(displayName); 29 | event.preventDefault(); 30 | }.bind(this)); 31 | $("#display_name_input").focus(); 32 | } 33 | 34 | render() { 35 | return ( 36 | 37 |
38 | 39 | 40 |
41 |
42 | ); 43 | } 44 | } 45 | 46 | export default LoginForm; -------------------------------------------------------------------------------- /webapp/src/Utils.ts: -------------------------------------------------------------------------------- 1 | import $ = require('jquery'); 2 | 3 | export default class Utils { 4 | static generateId() { 5 | let maximum = 999999, 6 | minimum = 0, 7 | conferenceId = Math.floor(Math.random() * (maximum - minimum + 1)) + minimum; 8 | return conferenceId; 9 | } 10 | 11 | static getNameFromURI(uri: string) { 12 | if (uri.indexOf('@') != -1) uri = uri.substr(0, uri.indexOf('@')); 13 | uri = uri.replace("sip:", ""); 14 | return uri; 15 | } 16 | 17 | static queryString() { 18 | let query_string = {}, 19 | query = window.location.search.substring(1), 20 | vars = query.split("&"); 21 | for (let i = 0; i < vars.length; i++) { 22 | let pair = vars[i].split("="); 23 | if (typeof query_string[pair[0]] === "undefined") { 24 | query_string[pair[0]] = pair[1]; 25 | } else if (typeof query_string[pair[0]] === "string") { 26 | let arr = [query_string[pair[0]], pair[1]]; 27 | query_string[pair[0]] = arr; 28 | } else { 29 | query_string[pair[0]].push(pair[1]); 30 | } 31 | } 32 | return query_string; 33 | } 34 | } -------------------------------------------------------------------------------- /webapp/src/app.scss: -------------------------------------------------------------------------------- 1 | html, body { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | height: 100%; 6 | width: 100%; 7 | } 8 | 9 | #app.conference { 10 | width: 100%; 11 | height: 100%; 12 | display: flex; 13 | flex-direction: column; 14 | } 15 | 16 | .tip { 17 | font-size: 16pt; 18 | } 19 | 20 | div.ex-container { 21 | display: flex; 22 | height: 100%; 23 | } 24 | 25 | #container { 26 | padding: 0; 27 | margin: 0; 28 | flex: 1; 29 | display: flex; 30 | justify-content: center; 31 | align-items: center; 32 | flex-direction: column; 33 | } 34 | 35 | div.loginForm { 36 | width: 400px; 37 | text-align: center; 38 | } 39 | 40 | h4.modal-title { 41 | padding-left: 32px; 42 | background: url(./img/icon.png) no-repeat left center; 43 | background-size: 28px 28px; 44 | } 45 | 46 | .spinner2 { 47 | margin-top: 15px; 48 | text-align: center; 49 | } 50 | 51 | .spinner2 > div { 52 | width: 18px; 53 | height: 18px; 54 | background-color: #000; 55 | 56 | border-radius: 100%; 57 | display: inline-block; 58 | -webkit-animation: bouncedelay 1.4s infinite ease-in-out; 59 | animation: bouncedelay 1.4s infinite ease-in-out; 60 | /* Prevent first frame from flickering when animation starts */ 61 | -webkit-animation-fill-mode: both; 62 | animation-fill-mode: both; 63 | } 64 | 65 | .spinner2 .bounce1 { 66 | -webkit-animation-delay: -0.32s; 67 | animation-delay: -0.32s; 68 | } 69 | 70 | .spinner2 .bounce2 { 71 | -webkit-animation-delay: -0.16s; 72 | animation-delay: -0.16s; 73 | } 74 | 75 | @-webkit-keyframes bouncedelay { 76 | 0%, 80%, 100% { -webkit-transform: scale(0.0) } 77 | 40% { -webkit-transform: scale(1.0) } 78 | } 79 | 80 | @keyframes bouncedelay { 81 | 0%, 80%, 100% { 82 | transform: scale(0.0); 83 | -webkit-transform: scale(0.0); 84 | } 40% { 85 | transform: scale(1.0); 86 | -webkit-transform: scale(1.0); 87 | } 88 | } 89 | 90 | @-webkit-keyframes glow { 91 | to { 92 | border-color: #000; 93 | -webkit-box-shadow: 0 0 5px #000; 94 | -moz-box-shadow: 0 0 5px #000; 95 | box-shadow: 0 0 5px #000; 96 | } 97 | } 98 | 99 | @keyframes glow { 100 | to { 101 | border-color: #000; 102 | -webkit-box-shadow: 0 0 5px #000; 103 | -moz-box-shadow: 0 0 5px #000; 104 | box-shadow: 0 0 5px #000; 105 | } 106 | } 107 | 108 | .spinner { 109 | display: inline-block; 110 | opacity: 0; 111 | width: 0; 112 | margin-right: 5px; 113 | 114 | -webkit-transition: opacity 0.25s, width 0.25s; 115 | -moz-transition: opacity 0.25s, width 0.25s; 116 | -o-transition: opacity 0.25s, width 0.25s; 117 | transition: opacity 0.25s, width 0.25s; 118 | } 119 | 120 | .has-spinner.active { 121 | cursor:progress; 122 | } 123 | 124 | .has-spinner.active .spinner { 125 | opacity: 1; 126 | width: auto; /* This doesn't work, just fix for unkown width elements */ 127 | animation: spin 2s infinite linear; 128 | -webkit-animation: spin2 2s infinite linear; 129 | } 130 | 131 | .has-spinner.btn-mini.active .spinner { 132 | width: 10px; 133 | } 134 | 135 | .has-spinner.btn-small.active .spinner { 136 | width: 13px; 137 | } 138 | 139 | .has-spinner.btn.active .spinner { 140 | width: 16px; 141 | } 142 | 143 | .has-spinner.btn-large.active .spinner { 144 | width: 19px; 145 | } 146 | 147 | @keyframes spin { 148 | from { transform: scale(1) rotate(0deg); } 149 | to { transform: scale(1) rotate(360deg); } 150 | } 151 | 152 | @-webkit-keyframes spin2 { 153 | from { -webkit-transform: rotate(0deg); } 154 | to { -webkit-transform: rotate(360deg); } 155 | } -------------------------------------------------------------------------------- /webapp/src/app.tsx: -------------------------------------------------------------------------------- 1 | declare function require(string): string; 2 | import $ = require('jquery'); 3 | import jQuery = require('jquery'); 4 | import * as React from 'react'; 5 | import * as ReactDOM from 'react-dom'; 6 | import * as VoxImplant from 'voximplant-websdk'; 7 | import LoginForm from "./UIElements/LoginForm"; 8 | import { 9 | Button, 10 | Modal, 11 | Input, 12 | Row, 13 | Col, 14 | ListGroup, 15 | ListGroupItem 16 | } from 'react-bootstrap'; 17 | import Utils from './Utils'; 18 | require('./app.scss'); 19 | 20 | enum AppViews { 21 | INIT, 22 | CONNECTED, 23 | AUTH, 24 | CONFERENCE_PARTICIPANTS, 25 | CONFERENCE_CALLING, 26 | INBOUND, 27 | FINISHED 28 | } 29 | 30 | enum CallStatuses { 31 | DEFAULT, 32 | INIT, 33 | CONNECTED, 34 | STREAM_CONNECTED, 35 | ENDED 36 | } 37 | 38 | interface State { 39 | view: AppViews, 40 | tip?: String 41 | } 42 | 43 | class Mix { 44 | /** 45 | * AudioContext 46 | */ 47 | audioCtx: AudioContext; 48 | /** 49 | * Audio channels merger 50 | */ 51 | merger: ChannelMergerNode; 52 | splitter: ChannelSplitterNode; 53 | /** 54 | * User for whom this mix is created for 55 | */ 56 | forUser: string; 57 | /** 58 | * Destination 59 | */ 60 | destination: any; 61 | /** 62 | * Participants whos steams were already added to the mix 63 | */ 64 | pariticipants: string[]; 65 | host: boolean; 66 | 67 | constructor(for_user: string, localstream: MediaStream, audiocontext: AudioContext, host: boolean = false) { 68 | this.audioCtx = audiocontext; 69 | this.pariticipants = []; 70 | this.forUser = for_user; 71 | this.merger = this.audioCtx.createChannelMerger(); 72 | this.host = host; 73 | if (!host) { 74 | this.destination = this.audioCtx.createMediaStreamDestination(); 75 | } else { 76 | console.log("Send local mix to audio device"); 77 | this.destination = this.audioCtx.destination; 78 | //this.merger.connect(this.destination); 79 | } 80 | if (!host) { 81 | this.audioCtx.createMediaStreamSource(localstream).connect(this.merger, 0, 0); 82 | this.audioCtx.createMediaStreamSource(localstream).connect(this.merger, 0, 1); 83 | } 84 | console.log("MIX[" + this.forUser + "] created"); 85 | } 86 | 87 | addParticipant(name: string, mediastream: MediaStream) { 88 | let found: boolean = false; 89 | for (let i = 0; i < this.pariticipants.length; i++) { 90 | if (this.pariticipants[i] == name) { 91 | found = true; 92 | break; 93 | } 94 | } 95 | if (!found) { 96 | let source: MediaStreamAudioSourceNode = this.audioCtx.createMediaStreamSource(mediastream); 97 | source.connect(this.merger, 0, 0); 98 | source.connect(this.merger, 0, 1); 99 | this.pariticipants.push(name); 100 | console.log("MIX[" + this.forUser + "] mediastreams:"); 101 | console.log(this.pariticipants); 102 | } 103 | } 104 | 105 | getResultStream() { 106 | this.merger.connect(this.destination); 107 | return this.destination.stream; 108 | } 109 | 110 | } 111 | 112 | class App extends React.Component { 113 | 114 | // SDK instance 115 | voxAPI: VoxImplant.Client; 116 | 117 | // Account info 118 | displayName: string; 119 | username: string; 120 | appname: string = YOUR_VOX_APPNAME; 121 | accname: string = YOUR_VOX_ACCNAME; 122 | 123 | // Roster data 124 | roster: VoxImplant.RosterItem[]; 125 | presence: Object[]; 126 | 127 | peerCalls: VoxImplant.Call[]; 128 | mixes: Mix[]; 129 | QueryString: Object; 130 | participants: Object[]; 131 | 132 | localStream: MediaStream; 133 | peerStreams: MediaStream[] = []; 134 | host: boolean = false; 135 | audioCtx: AudioContext; 136 | calls: number = 0; 137 | wsURL: string = 'https://'+YOUR_DOMAIN+'/auth.php'; 138 | 139 | state: State = { 140 | view: AppViews.INIT, 141 | tip: "Please allow access to your camera and microphone" 142 | } 143 | 144 | constructor() { 145 | super(); 146 | this.QueryString = Utils.queryString(); 147 | this.peerCalls = []; 148 | this.participants = []; 149 | this.mixes = []; 150 | this.audioCtx = new AudioContext(); 151 | this.roster = []; 152 | this.presence = []; 153 | this.voxAPI = VoxImplant.getInstance(); 154 | // Init 155 | this.voxAPI.addEventListener(VoxImplant.Events.SDKReady, (e: VoxImplant.Events.SDKReady) => this.voxReady(e)); 156 | // Connection 157 | this.voxAPI.addEventListener(VoxImplant.Events.ConnectionEstablished, (e: VoxImplant.Events.ConnectionEstablished) => this.voxConnected(e)); 158 | this.voxAPI.addEventListener(VoxImplant.Events.ConnectionFailed, (e: VoxImplant.Events.ConnectionFailed) => this.voxConnectionFailed(e)); 159 | this.voxAPI.addEventListener(VoxImplant.Events.ConnectionClosed, (e: VoxImplant.Events.ConnectionClosed) => this.voxConnectionClosed(e)); 160 | // Auth 161 | this.voxAPI.addEventListener(VoxImplant.Events.AuthResult, (e: VoxImplant.Events.AuthResult) => this.voxAuthEvent(e)); 162 | // Misc 163 | this.voxAPI.addEventListener(VoxImplant.Events.MicAccessResult, (e: VoxImplant.Events.MicAccessResult) => this.voxMicAccessResult(e)); 164 | this.voxAPI.addEventListener(VoxImplant.Events.IncomingCall, (e: VoxImplant.Events.IncomingCall) => this.voxIncomingCall(e)); 165 | // Logs 166 | //this.voxAPI.writeLog = function(message) { } 167 | //this.voxAPI.writeTrace = function(message) { } 168 | // IM & Presence 169 | this.voxAPI.addEventListener(VoxImplant.IMEvents.UCConnected, (e: VoxImplant.IMEvents.UCConnected) => this.voxUCConnected(e)); 170 | this.voxAPI.addEventListener(VoxImplant.IMEvents.RosterReceived, (e: VoxImplant.IMEvents.RosterReceived) => this.voxRosterReceived(e)); 171 | this.voxAPI.addEventListener(VoxImplant.IMEvents.RosterPresenceUpdate, (e: VoxImplant.IMEvents.RosterPresenceUpdate) => this.voxRosterPresenceUpdate(e)); 172 | this.voxAPI.addEventListener(VoxImplant.IMEvents.RosterItemChange, (e: VoxImplant.IMEvents.RosterItemChange) => this.voxRosterItemChange(e)); 173 | // Init 174 | this.voxAPI.init({ 175 | micRequired: true 176 | }); 177 | } 178 | 179 | voxReady(e: VoxImplant.Events.SDKReady) { 180 | console.log("VoxImplant WebSDK v. " + e.version + " ready"); 181 | this.voxAPI.connect(); 182 | } 183 | 184 | voxConnected(e: VoxImplant.Events.ConnectionEstablished) { 185 | console.log("Connection established"); 186 | this.setState({ 187 | view: AppViews.CONNECTED 188 | }); 189 | } 190 | 191 | voxUCConnected(e: VoxImplant.IMEvents.UCConnected) { 192 | console.log("UC connected"); 193 | } 194 | 195 | voxRosterReceived(e: VoxImplant.IMEvents.RosterReceived) { 196 | this.roster = e.roster; 197 | this.forceUpdate(); 198 | } 199 | 200 | voxRosterPresenceUpdate(e: VoxImplant.IMEvents.RosterPresenceUpdate) { 201 | let user: string = e.id.substr(0, e.id.indexOf('@')); 202 | if (e.presence == VoxImplant.UserStatuses.Offline) { 203 | delete (this.presence[user]); 204 | 205 | let index: number = -1; 206 | for (let i = 0; i < this.participants.length; i++) { 207 | if (this.participants[i]["name"] == user) { 208 | index = i; 209 | break; 210 | } 211 | } 212 | if (index != -1) this.participants.splice(index, 1); 213 | 214 | } else { 215 | this.presence[e.id] = e.presence; 216 | } 217 | 218 | this.forceUpdate(); 219 | } 220 | 221 | voxRosterItemChange(e: VoxImplant.IMEvents.RosterItemChange) { 222 | if (e.type == VoxImplant.RosterItemEvent.Added) { 223 | this.roster.push({ 224 | groups: [this.appname+"."+this.accname+".voximplant.com"], 225 | id: e.id, 226 | name: e.displayName, 227 | resources: [], 228 | subscription_type: 8 229 | }); 230 | } else if (e.type == VoxImplant.RosterItemEvent.Removed) { 231 | let user: string = e.id.substr(0, e.id.indexOf('@')); 232 | let index: number = -1; 233 | for (let i = 0; i < this.roster.length; i++) { 234 | if (this.roster[i].id == user) { 235 | index = i; 236 | break; 237 | } 238 | } 239 | if (index != -1) this.roster.splice(index, 1); 240 | } 241 | } 242 | 243 | voxConnectionFailed(e: VoxImplant.Events.ConnectionFailed) { 244 | console.log("Connection failed"); 245 | this.setState({ 246 | view: AppViews.FINISHED 247 | }); 248 | } 249 | 250 | voxConnectionClosed(e: VoxImplant.Events.ConnectionClosed) { 251 | console.log("Connection closed"); 252 | this.setState({ 253 | view: AppViews.FINISHED 254 | }); 255 | } 256 | 257 | voxMicAccessResult(e: VoxImplant.Events.MicAccessResult) { 258 | console.log("Mic access " + (e.result ? "allowed" : "denied")); 259 | this.localStream = e.stream; 260 | this.setState({ 261 | tip: "Establishing connection" 262 | }); 263 | } 264 | 265 | voxAuthEvent(e: VoxImplant.Events.AuthResult) { 266 | if (e.result) { 267 | this.displayName = e.displayName; 268 | this.setState({ 269 | view: AppViews.CONFERENCE_PARTICIPANTS 270 | }); 271 | } else { 272 | if (e.code == 302) { 273 | let uid = this.username + "@" + this.appname + "." + this.accname + ".voximplant.com"; 274 | $.get(this.wsURL + '?key=' + e.key + '&username=' + this.username, function(data) { 275 | if (data != "NO_DATA") { 276 | this.voxAPI.loginWithOneTimeKey(uid, data); 277 | } 278 | }.bind(this)); 279 | } else { 280 | console.log("auth failed"); 281 | } 282 | } 283 | } 284 | 285 | authorize(displayName: string) { 286 | this.displayName = displayName; 287 | this.setState({ 288 | view: AppViews.AUTH, 289 | tip: "Authorizing" 290 | }); 291 | 292 | $.get(this.wsURL + '?action=JOIN_CONFERENCE&displayName=' + this.displayName, function(data) { 293 | try { 294 | let result = JSON.parse(data); 295 | if (typeof result.username != "undefined") { 296 | // Login 297 | console.log(result); 298 | this.username = result.username; 299 | this.voxAPI.requestOneTimeLoginKey(this.username + "@" + this.appname + "." + this.accname + ".voximplant.com"); 300 | } 301 | } catch (e) { 302 | console.log(e); 303 | } 304 | }.bind(this)); 305 | 306 | } 307 | 308 | startConference() { 309 | this.host = true; 310 | for (let i = 0; i < this.participants.length; i++) { 311 | this.participants[i]["status"] = CallStatuses.INIT; 312 | let call: VoxImplant.Call = this.voxAPI.call(this.participants[i]["name"], false, null, { "X-DirectCall": "true" }); 313 | call.addEventListener(VoxImplant.CallEvents.Connected, (e: VoxImplant.CallEvents.Connected) => this.handleCallConnected(e)); 314 | call.addEventListener(VoxImplant.CallEvents.Disconnected, (e: VoxImplant.CallEvents.Disconnected) => this.handleCallDisconnected(e)); 315 | call.addEventListener(VoxImplant.CallEvents.Failed, (e: VoxImplant.CallEvents.Failed) => this.handleCallFailed(e)); 316 | this.peerCalls.push(call); 317 | } 318 | this.mixes[this.username] = new Mix(this.username, this.localStream, this.audioCtx, this.host); 319 | this.setState({ 320 | view: AppViews.CONFERENCE_CALLING 321 | }); 322 | } 323 | 324 | finishConference() { 325 | for (let i = 0; i < this.peerCalls.length; i++) { 326 | this.peerCalls[i].hangup(); 327 | } 328 | this.setState({ 329 | view: AppViews.FINISHED 330 | }); 331 | } 332 | 333 | handleCallConnected(e: VoxImplant.CallEvents.Connected) { 334 | this.voxAPI.setCallActive(e.call, true); 335 | for (let i = 0; i < this.participants.length; i++) { 336 | if (this.participants[i]["name"] == e.call.number()) this.participants[i]["status"] = CallStatuses.CONNECTED; 337 | } 338 | this.forceUpdate(); 339 | // Remote stream doesn't appear immediately - waiting for it 340 | var ts = setInterval(() => { 341 | 342 | if (e.call.getPeerConnection().getRemoteAudioStream() != null) { 343 | 344 | //(document.getElementById(e.call.getAudioElementId()) as HTMLMediaElement).volume = 0; 345 | //console.log(e.call.number() + ": " + e.call.getAudioElementId()); 346 | 347 | clearInterval(ts); 348 | this.mixes[e.call.number()] = new Mix(e.call.number(), this.localStream, this.audioCtx); 349 | 350 | for (let i = 0; i < this.peerCalls.length; i++) { 351 | if (this.peerCalls[i].number() != e.call.number()) { 352 | console.log("Attaching " + this.peerCalls[i].number() + " audio stream to " + e.call.number() + " mix"); 353 | this.mixes[e.call.number()].addParticipant(this.peerCalls[i].number(), this.peerCalls[i].getPeerConnection().getRemoteAudioStream()); 354 | } 355 | } 356 | 357 | e.call.getPeerConnection().setLocalStream(this.mixes[e.call.number()].getResultStream()); 358 | 359 | for (let i = 0; i < this.participants.length; i++) { 360 | if (this.participants[i]["name"] == e.call.number()) this.participants[i]["status"] = CallStatuses.STREAM_CONNECTED; 361 | } 362 | this.forceUpdate(); 363 | 364 | } 365 | 366 | }, 1000); 367 | 368 | } 369 | 370 | handleCallDisconnected(e: VoxImplant.CallEvents.Disconnected) { 371 | for (let i = 0; i < this.participants.length; i++) { 372 | if (this.participants[i]["name"] == e.call.number()) this.participants[i]["status"] = CallStatuses.ENDED; 373 | } 374 | let index: number = this.peerCalls.indexOf(e.call); 375 | if (index > -1) this.peerCalls.splice(index, 1); 376 | if (this.peerCalls.length > 0) this.forceUpdate(); 377 | else { 378 | this.participants = []; 379 | this.setState({ 380 | view: AppViews.CONFERENCE_PARTICIPANTS 381 | }); 382 | } 383 | } 384 | 385 | handleCallFailed(e: VoxImplant.CallEvents.Failed) { 386 | console.log("Call to " + e.call.number() + " failed"); 387 | for (let i = 0; i < this.participants.length; i++) { 388 | if (this.participants[i]["name"] == e.call.number()) this.participants[i]["status"] = CallStatuses.ENDED; 389 | } 390 | let index: number = this.peerCalls.indexOf(e.call); 391 | if (index > -1) this.peerCalls.splice(index, 1); 392 | if (this.peerCalls.length > 0) this.forceUpdate(); 393 | else this.setState({ 394 | view: AppViews.FINISHED 395 | }); 396 | } 397 | 398 | voxIncomingCall(e: VoxImplant.Events.IncomingCall) { 399 | if (this.state.view == AppViews.INBOUND || this.state.view == AppViews.CONFERENCE_CALLING) e.call.reject(); 400 | else { 401 | this.setState({ 402 | view: AppViews.INBOUND 403 | }) 404 | // No need to do anything special - all magic is on the host side 405 | this.peerCalls.push(e.call); 406 | e.call.addEventListener(VoxImplant.CallEvents.Disconnected, (e: VoxImplant.CallEvents.Disconnected) => this.handleCallDisconnected(e)); 407 | e.call.answer(); 408 | } 409 | } 410 | 411 | mutePlayback() { 412 | console.log("Mute playback"); 413 | for (let i = 0; i < this.peerCalls.length; i++ ) { 414 | this.peerCalls[i].mutePlayback(); 415 | } 416 | } 417 | 418 | unmutePlayback() { 419 | console.log("Unmute playback"); 420 | for (let i = 0; i < this.peerCalls.length; i++) { 421 | this.peerCalls[i].unmutePlayback(); 422 | } 423 | } 424 | 425 | muteMic() { 426 | console.log("Mute microphone"); 427 | for (let i = 0; i < this.peerCalls.length; i++) { 428 | this.peerCalls[i].muteMicrophone(); 429 | } 430 | } 431 | 432 | unmuteMic() { 433 | console.log("Unmute microphone"); 434 | for (let i = 0; i < this.peerCalls.length; i++) { 435 | this.peerCalls[i].unmuteMicrophone(); 436 | } 437 | } 438 | 439 | onListItemClick(e: string) { 440 | 441 | let index: number = -1; 442 | for (let i = 0; i < this.participants.length; i++) { 443 | if (this.participants[i]["name"] == e.substr(0, e.indexOf('@'))) { 444 | index = i; 445 | break; 446 | } 447 | } 448 | 449 | if (index != -1) this.participants.splice(index, 1); 450 | else this.participants.push({ name: e.substr(0, e.indexOf('@')) }); 451 | this.forceUpdate(); 452 | } 453 | 454 | render() { 455 | if (this.state.view == AppViews.INIT || this.state.view == AppViews.AUTH) { 456 | 457 | return
458 |
{this.state.tip}
459 |
460 |
461 |
462 |
463 |
464 |
; 465 | 466 | } else if (this.state.view == AppViews.CONNECTED) { 467 | 468 | return ; 469 | 470 | } else if (this.state.view == AppViews.CONFERENCE_PARTICIPANTS) { 471 | 472 | let button: JSX.Element, 473 | msg: string; 474 | if (this.participants.length > 0) button = ; 475 | 476 | let online_users: number = 0; 477 | for (var i in this.presence) { 478 | if (this.presence[i] == VoxImplant.UserStatuses.Online) { 479 | online_users++; 480 | } 481 | } 482 | if (online_users > 0) msg = "Choose users to start the conference or wait until someone call you"; 483 | else msg = "Nobody is online at the moment"; 484 | 485 | /** 486 | * 487 | * 488 | * 489 | * 490 | */ 491 | 492 | return
493 | 494 | 495 | Client-side Audio Conference 496 | 497 | 498 | 499 |

Online Users

500 |

{msg}

501 | 502 | {this.roster.map(function(obj) { 503 | 504 | if (typeof this.presence[obj["id"]] != "undefined" && 505 | this.presence[obj["id"]] == VoxImplant.UserStatuses.Online) { 506 | 507 | let found: boolean = false; 508 | for (let i = 0; i < this.participants.length; i++) { 509 | if (this.participants[i]["name"] == obj["id"].substr(0, obj["id"].indexOf('@'))) { 510 | found = true; 511 | break; 512 | } 513 | } 514 | 515 | if (found) return this.onListItemClick(obj["id"]) } active>{obj["name"]}; 516 | else return this.onListItemClick(obj["id"]) }>{obj["name"]}; 517 | 518 | } 519 | 520 | }.bind(this)) } 521 | 522 |
523 | 524 | 525 | {button} 526 | 527 | 528 |
529 |
; 530 | } else if (this.state.view == AppViews.CONFERENCE_CALLING) { 531 | 532 | let button: JSX.Element, 533 | msg: string = "Calling selected users"; 534 | if (this.participants.length > 0 && this.host) button = ; 535 | 536 | let processed_number: number = 0; 537 | for (let i = 0; i < this.participants.length; i++) { 538 | if (this.participants[i]["status"] == CallStatuses.ENDED || 539 | this.participants[i]["status"] == CallStatuses.STREAM_CONNECTED) processed_number++; 540 | } 541 | if (processed_number == this.participants.length) msg = ""; 542 | 543 | return
544 | 545 | 546 | Client-side Audio Conference 547 | 548 | 549 | 550 |

{processed_number != this.participants.length?"Creating conference":"Conference is live"}

551 |

{msg}

552 | 553 | {this.participants.map(function(obj) { 554 | 555 | let style: string, 556 | name: string = ""; 557 | 558 | switch (obj["status"]) { 559 | case CallStatuses.INIT: 560 | style = "info"; 561 | break; 562 | 563 | case CallStatuses.CONNECTED: 564 | style = "warning"; 565 | break; 566 | 567 | case CallStatuses.STREAM_CONNECTED: 568 | style = "success"; 569 | break; 570 | 571 | case CallStatuses.ENDED: 572 | style = "danger"; 573 | break; 574 | } 575 | 576 | for (let i = 0; i < this.roster.length; i++) { 577 | if (this.roster[i].id == obj["name"]+"@"+this.appname+"."+this.accname+".voximplant.com") { 578 | name = this.roster[i].name; 579 | break; 580 | } 581 | } 582 | console.log(obj); 583 | return {name}; 584 | }.bind(this)) } 585 | 586 |
587 | 588 | 589 | {button} 590 | 591 |
592 |
; 593 | 594 | } else if (this.state.view == AppViews.INBOUND) { 595 | 596 | let button: JSX.Element = ; 597 | 598 | return
599 | 600 | 601 | Client-side Audio Conference 602 | 603 | 604 | 605 | Connected to the conference 606 | 607 | 608 | 609 | {button} 610 | 611 | 612 | 613 |
; 614 | 615 | } else { 616 | 617 | return
Thank you!
; 618 | } 619 | } 620 | } 621 | 622 | export default App; 623 | 624 | ReactDOM.render(, document.getElementById('app')); -------------------------------------------------------------------------------- /webapp/src/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aylarov/voxclientconf/76e5ebb8bd0dfb847812977fbfaf056ccf1eaf32/webapp/src/img/icon.png -------------------------------------------------------------------------------- /webapp/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "jsx": "react", 5 | "sourceMap": true 6 | }, 7 | "files": [ 8 | "src/typings/main.d.ts", 9 | "src/app.tsx" 10 | ] 11 | } -------------------------------------------------------------------------------- /webapp/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | var production = process.env.NODE_ENV === 'production'; 4 | var plugins = [] 5 | ts_loader = 'react-hot!ts-loader!ts-jsx-loader?harmony=true', 6 | entry = []; 7 | 8 | if (production) { 9 | ts_loader = 'ts-loader!ts-jsx-loader?harmony=true'; 10 | 11 | plugins = plugins.concat([ 12 | new webpack.optimize.UglifyJsPlugin({ 13 | mangle: true, 14 | compress: { 15 | warnings: false, // Suppress uglification warnings 16 | }, 17 | }) 18 | ]); 19 | 20 | entry = [ 21 | './src/app.tsx' 22 | ]; 23 | 24 | } else { 25 | 26 | entry = [ 27 | 'webpack-dev-server/client?http://localhost:8080', 28 | 'webpack/hot/only-dev-server', 29 | './src/app.tsx' 30 | ]; 31 | 32 | } 33 | 34 | module.exports = { 35 | entry: entry, 36 | output: { 37 | path: path.join(__dirname, 'build'), 38 | filename: 'bundle.js', 39 | publicPath: '/build' 40 | }, 41 | devtool: 'source-map', 42 | plugins: plugins, 43 | resolve: { 44 | extensions: ['', '.ts', '.tsx', '.js'] 45 | }, 46 | module: { 47 | loaders: [ 48 | { test: /\.ts(x?)$/, loader: ts_loader }, 49 | { test: /\.scss$/, loader: 'style-loader!css-loader!sass-loader' }, 50 | { test: /\.(png|woff|woff2|eot|ttf|svg)$/, loader: 'url-loader?limit=100000' } 51 | ] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /webservice/auth.php: -------------------------------------------------------------------------------- 1 | json_decode($result, true), 'username' => $username); 67 | } 68 | 69 | /** 70 | * Bind user to the application 71 | */ 72 | function bindUser($username) { 73 | 74 | $url = API_URL . "BindUser/?" . 75 | "account_name=" . ACCOUNT_NAME . 76 | "&api_key=" . API_KEY . 77 | "&user_name=" . $username . 78 | "&application_name=" . APP_NAME; 79 | 80 | $result = httpRequest($url); 81 | return array('api_result' => json_decode($result, true), 'username' => $username); 82 | } 83 | 84 | /** 85 | * Create user, bind it to the app and return username 86 | */ 87 | function initUser($displayName) { 88 | 89 | $create_result = createUser($displayName); 90 | if ($create_result['api_result']['result'] == 1) { 91 | $bind_result = bindUser($create_result['username']); 92 | if ($bind_result['api_result']['result'] == 1) { 93 | echo json_encode(array("result" => "SUCCESS", "username" => $bind_result["username"])); 94 | exit; 95 | } else { 96 | echo json_encode(array("result" => "ERROR")); 97 | exit; 98 | } 99 | 100 | } else { 101 | echo json_encode(array("result" => "ERROR")); 102 | exit; 103 | } 104 | 105 | } 106 | 107 | /** 108 | * Calculate hash for VoxImplant loginWithOneTimeKey 109 | */ 110 | function calculateHash($key, $username) { 111 | $hash = md5($key . "|" . md5($username . ":voximplant.com:" . PWD)); 112 | return $hash; 113 | } 114 | 115 | if (isset($_REQUEST['key']) && isset($_REQUEST['username'])) { 116 | $result = calculateHash($_REQUEST['key'], $_REQUEST['username']); 117 | echo $result; 118 | exit; 119 | } else if (isset($_REQUEST['action'])) { 120 | $action = $_REQUEST['action']; 121 | if (isset($_REQUEST['displayName'])) $displayName = urlencode($_REQUEST['displayName']); 122 | else $displayName = "Participant"; 123 | switch($action) { 124 | case "JOIN_CONFERENCE": 125 | // Create user via API and return his name to SDK for login 126 | initUser($displayName); 127 | break; 128 | } 129 | } else { 130 | echo "NO_DATA"; 131 | exit; 132 | } 133 | 134 | ?> --------------------------------------------------------------------------------