├── .gitignore ├── public ├── favicon.ico ├── index.html ├── css │ └── main.css └── js │ ├── main.js │ └── webrtc.js ├── package.json ├── LICENSE ├── server.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avoup/webrtc-video-conference/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webrtc-video-conference", 3 | "version": "1.0.0", 4 | "description": "webrtc video conference example (not using webrtc libraries)", 5 | "main": "server.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node server.js", 9 | "dev": "nodemon server.js" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/avoup/webrtc-video-conference.git" 14 | }, 15 | "keywords": [ 16 | "webrtc", 17 | "sample", 18 | "example", 19 | "video", 20 | "conference", 21 | "tutorial" 22 | ], 23 | "author": "avoup", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/avoup/webrtc-video-conference/issues" 27 | }, 28 | "homepage": "https://github.com/avoup/webrtc-video-conference#readme", 29 | "optionalDependencies": { 30 | "nodemon": "^2.0.12" 31 | }, 32 | "dependencies": { 33 | "express": "^4.17.1", 34 | "socket.io": "^4.1.3" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | WebRTC Video conference 5 | 6 | 7 | 8 | 9 | 10 |

11 | 12 | 13 | 14 | 15 | 16 | 17 |

18 | 19 |
20 | 21 |
22 | 23 |
24 |
25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /public/css/main.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | text-align: center; 3 | } 4 | 5 | .grid-container { 6 | display: grid; 7 | grid-template-columns: auto auto auto; 8 | /* padding: 10px; */ 9 | } 10 | .grid-item { 11 | padding: 10px; 12 | position: relative; 13 | width: fit-content; 14 | height: fit-content; 15 | /* border: 1px solid rgba(0, 0, 0, 0.8); */ 16 | } 17 | .grid-item p { 18 | font-size: 20px; 19 | margin: 0; 20 | position: absolute; 21 | color: white; 22 | font-weight: bold; 23 | } 24 | .grid-item video { 25 | max-height: 300px; 26 | max-width: 100%; 27 | box-shadow: -2px -2px 15px #888888; 28 | } 29 | .grid-item .kick_btn { 30 | position: absolute; 31 | bottom: 0; 32 | right: 0; 33 | font-weight: bold; 34 | font-size: 20px; 35 | } 36 | 37 | #localVideo-container { 38 | height: 250px; 39 | position: absolute; 40 | bottom: 5px; 41 | right: 5px; 42 | box-shadow: -2px -2px 15px #888888; 43 | } 44 | 45 | #localVideo-container video { 46 | width: 100%; 47 | height: 100%; 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 avoup 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. -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const express = require('express'); 3 | const app = express(); 4 | const socketIO = require('socket.io'); 5 | 6 | const port = process.env.PORT || 8080; 7 | const env = process.env.NODE_ENV || 'development'; 8 | 9 | // Redirect to https 10 | app.get('*', (req, res, next) => { 11 | if (req.headers['x-forwarded-proto'] !== 'https' && env !== 'development') { 12 | return res.redirect(['https://', req.get('Host'), req.url].join('')); 13 | } 14 | next(); 15 | }); 16 | 17 | app.use(express.static(path.join(__dirname, 'public'))); 18 | app.use(express.static(path.join(__dirname, 'node_modules'))); 19 | 20 | const server = require('http').createServer(app); 21 | server.listen(port, () => { 22 | console.log(`listening on port ${port}`); 23 | }); 24 | 25 | /** 26 | * Socket.io events 27 | */ 28 | const io = socketIO(server); 29 | io.sockets.on('connection', function (socket) { 30 | /** 31 | * Log actions to the client 32 | */ 33 | function log() { 34 | const array = ['Server:']; 35 | array.push.apply(array, arguments); 36 | socket.emit('log', array); 37 | } 38 | 39 | /** 40 | * Handle message from a client 41 | * If toId is provided message will be sent ONLY to the client with that id 42 | * If toId is NOT provided and room IS provided message will be broadcast to that room 43 | * If NONE is provided message will be sent to all clients 44 | */ 45 | socket.on('message', (message, toId = null, room = null) => { 46 | log('Client ' + socket.id + ' said: ', message); 47 | 48 | if (toId) { 49 | console.log('From ', socket.id, ' to ', toId, message.type); 50 | 51 | io.to(toId).emit('message', message, socket.id); 52 | } else if (room) { 53 | console.log('From ', socket.id, ' to room: ', room, message.type); 54 | 55 | socket.broadcast.to(room).emit('message', message, socket.id); 56 | } else { 57 | console.log('From ', socket.id, ' to everyone ', message.type); 58 | 59 | socket.broadcast.emit('message', message, socket.id); 60 | } 61 | }); 62 | 63 | let roomAdmin; // save admins socket id (will get overwritten if new room gets created) 64 | 65 | /** 66 | * When room gets created or someone joins it 67 | */ 68 | socket.on('create or join', (room) => { 69 | log('Create or Join room: ' + room); 70 | 71 | // Get number of clients in the room 72 | const clientsInRoom = io.sockets.adapter.rooms.get(room); 73 | let numClients = clientsInRoom ? clientsInRoom.size : 0; 74 | 75 | if (numClients === 0) { 76 | // Create room 77 | socket.join(room); 78 | roomAdmin = socket.id; 79 | socket.emit('created', room, socket.id); 80 | } else { 81 | log('Client ' + socket.id + ' joined room ' + room); 82 | 83 | // Join room 84 | io.sockets.in(room).emit('join', room); // Notify users in room 85 | socket.join(room); 86 | io.to(socket.id).emit('joined', room, socket.id); // Notify client that they joined a room 87 | io.sockets.in(room).emit('ready', socket.id); // Room is ready for creating connections 88 | } 89 | }); 90 | 91 | /** 92 | * Kick participant from a call 93 | */ 94 | socket.on('kickout', (socketId, room) => { 95 | if (socket.id === roomAdmin) { 96 | socket.broadcast.emit('kickout', socketId); 97 | io.sockets.sockets.get(socketId).leave(room); 98 | } else { 99 | console.log('not an admin'); 100 | } 101 | }); 102 | 103 | // participant leaves room 104 | socket.on('leave room', (room) => { 105 | socket.leave(room); 106 | socket.emit('left room', room); 107 | socket.broadcast.to(room).emit('message', { type: 'leave' }, socket.id); 108 | }); 109 | 110 | /** 111 | * When participant leaves notify other participants 112 | */ 113 | socket.on('disconnecting', () => { 114 | socket.rooms.forEach((room) => { 115 | if (room === socket.id) return; 116 | socket.broadcast 117 | .to(room) 118 | .emit('message', { type: 'leave' }, socket.id); 119 | }); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /public/js/main.js: -------------------------------------------------------------------------------- 1 | 'use strinct'; 2 | 3 | const socket = io.connect(); 4 | 5 | const localVideo = document.querySelector('#localVideo-container video'); 6 | const videoGrid = document.querySelector('#videoGrid'); 7 | const notification = document.querySelector('#notification'); 8 | const notify = (message) => { 9 | notification.innerHTML = message; 10 | }; 11 | 12 | const pcConfig = { 13 | iceServers: [ 14 | { 15 | urls: [ 16 | 'stun:stun.l.google.com:19302', 17 | 'stun:stun1.l.google.com:19302', 18 | 'stun:stun2.l.google.com:19302', 19 | 'stun:stun3.l.google.com:19302', 20 | 'stun:stun4.l.google.com:19302', 21 | ], 22 | }, 23 | { 24 | urls: 'turn:numb.viagenie.ca', 25 | credential: 'muazkh', 26 | username: 'webrtc@live.com', 27 | }, 28 | { 29 | urls: 'turn:numb.viagenie.ca', 30 | credential: 'muazkh', 31 | username: 'webrtc@live.com', 32 | }, 33 | { 34 | urls: 'turn:192.158.29.39:3478?transport=udp', 35 | credential: 'JZEOEt2V3Qb0y27GRntt2u2PAYA=', 36 | username: '28224511:1379330808', 37 | }, 38 | ], 39 | }; 40 | 41 | /** 42 | * Initialize webrtc 43 | */ 44 | const webrtc = new Webrtc(socket, pcConfig, { 45 | log: true, 46 | warn: true, 47 | error: true, 48 | }); 49 | 50 | /** 51 | * Create or join a room 52 | */ 53 | const roomInput = document.querySelector('#roomId'); 54 | const joinBtn = document.querySelector('#joinBtn'); 55 | joinBtn.addEventListener('click', () => { 56 | const room = roomInput.value; 57 | if (!room) { 58 | notify('Room ID not provided'); 59 | return; 60 | } 61 | 62 | webrtc.joinRoom(room); 63 | }); 64 | 65 | const setTitle = (status, e) => { 66 | const room = e.detail.roomId; 67 | 68 | console.log(`Room ${room} was ${status}`); 69 | 70 | notify(`Room ${room} was ${status}`); 71 | document.querySelector('h1').textContent = `Room: ${room}`; 72 | webrtc.gotStream(); 73 | }; 74 | webrtc.addEventListener('createdRoom', setTitle.bind(this, 'created')); 75 | webrtc.addEventListener('joinedRoom', setTitle.bind(this, 'joined')); 76 | 77 | /** 78 | * Leave the room 79 | */ 80 | const leaveBtn = document.querySelector('#leaveBtn'); 81 | leaveBtn.addEventListener('click', () => { 82 | webrtc.leaveRoom(); 83 | }); 84 | webrtc.addEventListener('leftRoom', (e) => { 85 | const room = e.detail.roomId; 86 | document.querySelector('h1').textContent = ''; 87 | notify(`Left the room ${room}`); 88 | }); 89 | 90 | /** 91 | * Get local media 92 | */ 93 | webrtc 94 | .getLocalStream(true, { width: 640, height: 480 }) 95 | .then((stream) => (localVideo.srcObject = stream)); 96 | 97 | webrtc.addEventListener('kicked', () => { 98 | document.querySelector('h1').textContent = 'You were kicked out'; 99 | videoGrid.innerHTML = ''; 100 | }); 101 | 102 | webrtc.addEventListener('userLeave', (e) => { 103 | console.log(`user ${e.detail.socketId} left room`); 104 | }); 105 | 106 | /** 107 | * Handle new user connection 108 | */ 109 | webrtc.addEventListener('newUser', (e) => { 110 | const socketId = e.detail.socketId; 111 | const stream = e.detail.stream; 112 | 113 | const videoContainer = document.createElement('div'); 114 | videoContainer.setAttribute('class', 'grid-item'); 115 | videoContainer.setAttribute('id', socketId); 116 | 117 | const video = document.createElement('video'); 118 | video.setAttribute('autoplay', true); 119 | video.setAttribute('muted', true); // set to false 120 | video.setAttribute('playsinline', true); 121 | video.srcObject = stream; 122 | 123 | const p = document.createElement('p'); 124 | p.textContent = socketId; 125 | 126 | videoContainer.append(p); 127 | videoContainer.append(video); 128 | 129 | // If user is admin add kick buttons 130 | if (webrtc.isAdmin) { 131 | const kickBtn = document.createElement('button'); 132 | kickBtn.setAttribute('class', 'kick_btn'); 133 | kickBtn.textContent = 'Kick'; 134 | 135 | kickBtn.addEventListener('click', () => { 136 | webrtc.kickUser(socketId); 137 | }); 138 | 139 | videoContainer.append(kickBtn); 140 | } 141 | videoGrid.append(videoContainer); 142 | }); 143 | 144 | /** 145 | * Handle user got removed 146 | */ 147 | webrtc.addEventListener('removeUser', (e) => { 148 | const socketId = e.detail.socketId; 149 | if (!socketId) { 150 | // remove all remote stream elements 151 | videoGrid.innerHTML = ''; 152 | return; 153 | } 154 | document.getElementById(socketId).remove(); 155 | }); 156 | 157 | /** 158 | * Handle errors 159 | */ 160 | webrtc.addEventListener('error', (e) => { 161 | const error = e.detail.error; 162 | console.error(error); 163 | 164 | notify(error); 165 | }); 166 | 167 | /** 168 | * Handle notifications 169 | */ 170 | webrtc.addEventListener('notification', (e) => { 171 | const notif = e.detail.notification; 172 | console.log(notif); 173 | 174 | notify(notif); 175 | }); 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # WebRTC Video Conference 6 | 7 | **Live demo:** https://webrtc-video-conference-sample.herokuapp.com/ 8 | 9 | WebRTC video conference sample application. Uses Mesh architecture (every participant sends and receives its media to all other participants). 10 | 11 | ## Getting Started 12 | 13 | In your terminal type: 14 | 15 | ```bash 16 | # Clone from Github 17 | git clone https://github.com/avoup/webrtc-video-conference myproject 18 | 19 | # Change directory 20 | cd myproject 21 | 22 | # Install NPM dependencies 23 | npm install 24 | 25 | # Start app 26 | npm start 27 | 28 | ``` 29 | 30 | ## Project Structure 31 | 32 | | Name | Description | 33 | | ----------------------- | ------------------------------------------------------------ | 34 | | **public**/js/webrtc.js | Main webrtc logic. | 35 | | **public**/js/main.js | Js for using webrtc.js. | 36 | | **public**/css/main.css | Style | 37 | | **public**/index.html | Landing page. | 38 | | .gitignore | Folder and files to be ignored by git. | 39 | | server.js | Sample server for webrtc signaling using socket.io. | 40 | | package.json | NPM dependencies. | 41 | | package-lock.json | Contains exact versions of NPM dependencies in package.json. | 42 | 43 |
44 | 45 | # Documentation 46 | 47 | Project uses Webrtc API without external libraries, for signaling it uses socket.io, stun and turn servers are publicly available free servers, see the list [here](https://gist.github.com/sagivo/3a4b2f2c7ac6e1b5267c2f1f59ac6c6b). 48 | 49 | For easy implementation and modular design all the webrtc logic is contained in the Webrtc class in `public/js/webrtc.js` file. This class is an extension of EventTarget class, meaning it emits events and we can add event listeners to it. 50 | The class's construction function takes 3 arguments 51 |
52 |
53 | **_Note:_** _private properties and methods of the class are named starting with underscore('\_privateProp') and should not be accessed from outside. 54 |
55 | JS's built in private properties ('#privateProp') are not used as that's a relatively new future and only newest versions of the browsers support it._ 56 | 57 | ```js 58 | class Webrtc extends EventTarget { 59 | constructor(socket, pcConfig, logging) { 60 | super(); 61 | ... 62 | } 63 | ``` 64 | 65 | example initialization 66 | 67 | ```js 68 | const webrtc = new Webrtc( 69 | socket, 70 | { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }, 71 | { 72 | log: true, 73 | warn: true, 74 | error: true, 75 | } 76 | ); 77 | ``` 78 | 79 | ## Arguments 80 | 81 | - **socket** - socket.io instance (_required at least version 4.1.3_) 82 | - **pcConfig** - peer connection configuration. Stun/Turn servers can be passed here. If not provided webrtc will not use any servers and will be operational only on local network. Any number of stun and turn servers can be passed. 83 | 84 | ```js 85 | { 86 | iceServers: [ 87 | { 88 | urls: [ 89 | 'stun:stun.l.google.com:19302', 90 | 'stun:stun1.l.google.com:19302' 91 | ], 92 | }, 93 | { 94 | urls: 'turn:numb.viagenie.ca', 95 | credential: 'muazkh', 96 | username: 'webrtc@live.com', 97 | }, 98 | ], 99 | } 100 | ``` 101 | 102 | - **logging** - enable or disable logging on actions. 103 | - log - enable console.log 104 | - warn - enable console.warn 105 | - error - enable console.error 106 | 107 | ```js 108 | { 109 | log: true, 110 | warn: true, 111 | error: true, 112 | } 113 | ``` 114 | 115 | ## webrtc.getLocalStream() 116 | 117 | After initialization local media stream should be accessed. 118 | 119 | ```js 120 | webrtc 121 | .getLocalStream(true, { width: 640, height: 480 }) 122 | .then((stream) => (localVideo.srcObject = stream)); 123 | ``` 124 | 125 | getLocalStream takes two arguments for `audio` and `video` constraints. It returns promise, which returns a stream from local media that can be attached to an HTML audio or video element as a source, element should be set on `autoplay`. 126 | 127 | The method actually returns [navigator.getUserMedia](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia). 128 | 129 | ## webrtc.joinRoom() 130 | 131 | After initialization room can be joined with `joinRoom` method. 132 | 133 | ```js 134 | webrtc.joinRoom('room-1'); 135 | ``` 136 | 137 | events `createdRoom` and `joinedRoom` will be emitted if join or creation of room was successful. After that `webrtc.gotStream()` can be called to notify server that local stream is ready for sharing. 138 | 139 | ## webrtc.leaveRoom() 140 | 141 | Closes all open connections and leaves the current room. On successful leave event `leftRoom` will be emitted with room ID. 142 | 143 | ## webrtc.kickUser() 144 | 145 | kick user with the given socket id from the call. 146 | 147 | ```js 148 | webrtc.kickUser(socketId); 149 | ``` 150 | 151 | ## Events 152 | 153 | As mentioned `Webrtc` instance emits events, which can be listened to with [EventTarget.addEventListener()](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener). Some of the events return data in `event.detail` property. 154 | 155 | | Name | Description | Data | 156 | | ------------ | --------------------------------------------- | -------------------------------------------------------------------------------------- | 157 | | createdRoom | Successfuly created a room. | | 158 | | joinedRoom | Successfuly joined a room. | | 159 | | leftRoom | Successfuly left a room. | **roomId** - ID of the abandoned room | 160 | | kicked | You were kicked out of conference. | | 161 | | userLeave | User left the conference. | **socketId** - socket id of the user that left | 162 | | newUser | New user joined. | **socketId** - socket id of the joined user.
**stream** - media stream of new user | 163 | | removeUser | Connections with user was closed and removed. | **socketId** - socket id of the removed user | 164 | | notification | Notification. | **notification** - notification text | 165 | | error | An error occured. | **error** - Error object | 166 | 167 | ## Getters 168 | 169 | | Name | Description | 170 | | ------------ | --------------------------------------- | 171 | | localStream | Returns local stream. | 172 | | myId | Returns current socket id. | 173 | | isAdmin | If current user is admin(created room). | 174 | | roomId | Returns joined room id. | 175 | | participants | Returns participants' ids in room. | 176 | 177 | # Stun and Turn servers 178 | 179 | The project uses free stun and turn servers. For production use you might need to consider other alternatives. 180 |
181 | If you want to build your own turn server consider using [coturn server](https://github.com/coturn/coturn). 182 | 183 | If you are willing to pay to get these services there are providers who offer them: 184 | 185 | - [Twilio](https://www.twilio.com/stun-turn) 186 | - [Xirsys](https://xirsys.com/) 187 | - [Kurento](https://www.kurento.org/) 188 | 189 | You might find these articles helpful: 190 | 191 | - https://medium.com/swlh/setup-your-own-coturn-server-using-aws-ec2-instance-29303101e7b5 192 | - https://kostya-malsev.medium.com/set-up-a-turn-server-on-aws-in-15-minutes-25beb145bc77 193 | - https://nextcloud-talk.readthedocs.io/en/latest/TURN/ 194 | 195 | _...more information is being added_ 196 | 197 | --- 198 | 199 | ## Contributing 200 | 201 | If you find any error in code or demo, or have an idea for more optimal solution for the code, please either open an issue or submit a pull request. 202 | Contributions are welcome! 203 | 204 | ## License 205 | 206 | MIT License 207 | 208 | Copyright (c) 2021 avoup 209 | 210 | Permission is hereby granted, free of charge, to any person obtaining a copy 211 | of this software and associated documentation files (the "Software"), to deal 212 | in the Software without restriction, including without limitation the rights 213 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 214 | copies of the Software, and to permit persons to whom the Software is 215 | furnished to do so, subject to the following conditions: 216 | 217 | The above copyright notice and this permission notice shall be included in all 218 | copies or substantial portions of the Software. 219 | 220 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 221 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 222 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 223 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 224 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 225 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 226 | SOFTWARE. 227 | -------------------------------------------------------------------------------- /public/js/webrtc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class Webrtc extends EventTarget { 4 | constructor( 5 | socket, 6 | pcConfig = null, 7 | logging = { log: true, warn: true, error: true } 8 | ) { 9 | super(); 10 | this.room; 11 | this.socket = socket; 12 | this.pcConfig = pcConfig; 13 | 14 | this._myId = null; 15 | this.pcs = {}; // Peer connections 16 | this.streams = {}; 17 | this.currentRoom; 18 | this.inCall = false; 19 | this.isReady = false; // At least 2 users are in room 20 | this.isInitiator = false; // Initiates connections if true 21 | this._isAdmin = false; // Should be checked on the server 22 | this._localStream = null; 23 | 24 | // Manage logging 25 | this.log = logging.log ? console.log : () => {}; 26 | this.warn = logging.warn ? console.warn : () => {}; 27 | this.error = logging.error ? console.error : () => {}; 28 | 29 | // Initialize socket.io listeners 30 | this._onSocketListeners(); 31 | } 32 | 33 | // Custom event emitter 34 | _emit(eventName, details) { 35 | this.dispatchEvent( 36 | new CustomEvent(eventName, { 37 | detail: details, 38 | }) 39 | ); 40 | } 41 | 42 | get localStream() { 43 | return this._localStream; 44 | } 45 | 46 | get myId() { 47 | return this._myId; 48 | } 49 | 50 | get isAdmin() { 51 | return this._isAdmin; 52 | } 53 | 54 | get roomId() { 55 | return this.room; 56 | } 57 | 58 | get participants() { 59 | return Object.keys(this.pcs); 60 | } 61 | 62 | gotStream() { 63 | if (this.room) { 64 | this._sendMessage({ type: 'gotstream' }, null, this.room); 65 | } else { 66 | this.warn('Should join room before sending stream'); 67 | 68 | this._emit('notification', { 69 | notification: `Should join room before sending a stream.`, 70 | }); 71 | } 72 | } 73 | 74 | joinRoom(room) { 75 | if (this.room) { 76 | this.warn('Leave current room before joining a new one'); 77 | 78 | this._emit('notification', { 79 | notification: `Leave current room before joining a new one`, 80 | }); 81 | return; 82 | } 83 | if (!room) { 84 | this.warn('Room ID not provided'); 85 | 86 | this._emit('notification', { 87 | notification: `Room ID not provided`, 88 | }); 89 | return; 90 | } 91 | this.socket.emit('create or join', room); 92 | } 93 | 94 | leaveRoom() { 95 | if (!this.room) { 96 | this.warn('You are currently not in a room'); 97 | 98 | this._emit('notification', { 99 | notification: `You are currently not in a room`, 100 | }); 101 | return; 102 | } 103 | this.isInitiator = false; 104 | this.socket.emit('leave room', this.room); 105 | } 106 | 107 | // Get local stream 108 | getLocalStream(audioConstraints, videoConstraints) { 109 | return navigator.mediaDevices 110 | .getUserMedia({ 111 | audio: audioConstraints, 112 | video: videoConstraints, 113 | }) 114 | .then((stream) => { 115 | this.log('Got local stream.'); 116 | this._localStream = stream; 117 | return stream; 118 | }) 119 | .catch(() => { 120 | this.error("Can't get usermedia"); 121 | 122 | this._emit('error', { 123 | error: new Error(`Can't get usermedia`), 124 | }); 125 | }); 126 | } 127 | 128 | /** 129 | * Try connecting to peers 130 | * if got local stream and is ready for connection 131 | */ 132 | _connect(socketId) { 133 | if (typeof this._localStream !== 'undefined' && this.isReady) { 134 | this.log('Create peer connection to ', socketId); 135 | 136 | this._createPeerConnection(socketId); 137 | this.pcs[socketId].addStream(this._localStream); 138 | 139 | if (this.isInitiator) { 140 | this.log('Creating offer for ', socketId); 141 | 142 | this._makeOffer(socketId); 143 | } 144 | } else { 145 | this.warn('NOT connecting'); 146 | } 147 | } 148 | 149 | /** 150 | * Initialize listeners for socket.io events 151 | */ 152 | _onSocketListeners() { 153 | this.log('socket listeners initialized'); 154 | 155 | // Room got created 156 | this.socket.on('created', (room, socketId) => { 157 | this.room = room; 158 | this._myId = socketId; 159 | this.isInitiator = true; 160 | this._isAdmin = true; 161 | 162 | this._emit('createdRoom', { roomId: room }); 163 | }); 164 | 165 | // Joined the room 166 | this.socket.on('joined', (room, socketId) => { 167 | this.log('joined: ' + room); 168 | 169 | this.room = room; 170 | this.isReady = true; 171 | this._myId = socketId; 172 | 173 | this._emit('joinedRoom', { roomId: room }); 174 | }); 175 | 176 | // Left the room 177 | this.socket.on('left room', (room) => { 178 | if (room === this.room) { 179 | this.warn(`Left the room ${room}`); 180 | 181 | this.room = null; 182 | this._removeUser(); 183 | this._emit('leftRoom', { 184 | roomId: room, 185 | }); 186 | } 187 | }); 188 | 189 | // Someone joins room 190 | this.socket.on('join', (room) => { 191 | this.log('Incoming request to join room: ' + room); 192 | 193 | this.isReady = true; 194 | 195 | this.dispatchEvent(new Event('newJoin')); 196 | }); 197 | 198 | // Room is ready for connection 199 | this.socket.on('ready', (user) => { 200 | this.log('User: ', user, ' joined room'); 201 | 202 | if (user !== this._myId && this.inCall) this.isInitiator = true; 203 | }); 204 | 205 | // Someone got kicked from call 206 | this.socket.on('kickout', (socketId) => { 207 | this.log('kickout user: ', socketId); 208 | 209 | if (socketId === this._myId) { 210 | // You got kicked out 211 | this.dispatchEvent(new Event('kicked')); 212 | this._removeUser(); 213 | } else { 214 | // Someone else got kicked out 215 | this._removeUser(socketId); 216 | } 217 | }); 218 | 219 | // Logs from server 220 | this.socket.on('log', (log) => { 221 | this.log.apply(console, log); 222 | }); 223 | 224 | /** 225 | * Message from the server 226 | * Manage stream and sdp exchange between peers 227 | */ 228 | this.socket.on('message', (message, socketId) => { 229 | this.log('From', socketId, ' received:', message.type); 230 | 231 | // Participant leaves 232 | if (message.type === 'leave') { 233 | this.log(socketId, 'Left the call.'); 234 | this._removeUser(socketId); 235 | this.isInitiator = true; 236 | 237 | this._emit('userLeave', { socketId: socketId }); 238 | return; 239 | } 240 | 241 | // Avoid dublicate connections 242 | if ( 243 | this.pcs[socketId] && 244 | this.pcs[socketId].connectionState === 'connected' 245 | ) { 246 | this.log( 247 | 'Connection with ', 248 | socketId, 249 | 'is already established' 250 | ); 251 | return; 252 | } 253 | 254 | switch (message.type) { 255 | case 'gotstream': // user is ready to share their stream 256 | this._connect(socketId); 257 | break; 258 | case 'offer': // got connection offer 259 | if (!this.pcs[socketId]) { 260 | this._connect(socketId); 261 | } 262 | this.pcs[socketId].setRemoteDescription( 263 | new RTCSessionDescription(message) 264 | ); 265 | this._answer(socketId); 266 | break; 267 | case 'answer': // got answer for sent offer 268 | this.pcs[socketId].setRemoteDescription( 269 | new RTCSessionDescription(message) 270 | ); 271 | break; 272 | case 'candidate': // received candidate sdp 273 | this.inCall = true; 274 | const candidate = new RTCIceCandidate({ 275 | sdpMLineIndex: message.label, 276 | candidate: message.candidate, 277 | }); 278 | this.pcs[socketId].addIceCandidate(candidate); 279 | break; 280 | } 281 | }); 282 | } 283 | 284 | _sendMessage(message, toId = null, roomId = null) { 285 | this.socket.emit('message', message, toId, roomId); 286 | } 287 | 288 | _createPeerConnection(socketId) { 289 | try { 290 | if (this.pcs[socketId]) { 291 | // Skip peer if connection is already established 292 | this.warn('Connection with ', socketId, ' already established'); 293 | return; 294 | } 295 | 296 | this.pcs[socketId] = new RTCPeerConnection(this.pcConfig); 297 | this.pcs[socketId].onicecandidate = this._handleIceCandidate.bind( 298 | this, 299 | socketId 300 | ); 301 | this.pcs[socketId].ontrack = this._handleOnTrack.bind( 302 | this, 303 | socketId 304 | ); 305 | // this.pcs[socketId].onremovetrack = this._handleOnRemoveTrack.bind( 306 | // this, 307 | // socketId 308 | // ); 309 | 310 | this.log('Created RTCPeerConnnection for ', socketId); 311 | } catch (error) { 312 | this.error('RTCPeerConnection failed: ' + error.message); 313 | 314 | this._emit('error', { 315 | error: new Error(`RTCPeerConnection failed: ${error.message}`), 316 | }); 317 | } 318 | } 319 | 320 | /** 321 | * Send ICE candidate through signaling server (socket.io in this case) 322 | */ 323 | _handleIceCandidate(socketId, event) { 324 | this.log('icecandidate event'); 325 | 326 | if (event.candidate) { 327 | this._sendMessage( 328 | { 329 | type: 'candidate', 330 | label: event.candidate.sdpMLineIndex, 331 | id: event.candidate.sdpMid, 332 | candidate: event.candidate.candidate, 333 | }, 334 | socketId 335 | ); 336 | } 337 | } 338 | 339 | _handleCreateOfferError(event) { 340 | this.error('ERROR creating offer'); 341 | 342 | this._emit('error', { 343 | error: new Error('Error while creating an offer'), 344 | }); 345 | } 346 | 347 | /** 348 | * Make an offer 349 | * Creates session descripton 350 | */ 351 | _makeOffer(socketId) { 352 | this.log('Sending offer to ', socketId); 353 | 354 | this.pcs[socketId].createOffer( 355 | this._setSendLocalDescription.bind(this, socketId), 356 | this._handleCreateOfferError 357 | ); 358 | } 359 | 360 | /** 361 | * Create an answer for incoming offer 362 | */ 363 | _answer(socketId) { 364 | this.log('Sending answer to ', socketId); 365 | 366 | this.pcs[socketId] 367 | .createAnswer() 368 | .then( 369 | this._setSendLocalDescription.bind(this, socketId), 370 | this._handleSDPError 371 | ); 372 | } 373 | 374 | /** 375 | * Set local description and send it to server 376 | */ 377 | _setSendLocalDescription(socketId, sessionDescription) { 378 | this.pcs[socketId].setLocalDescription(sessionDescription); 379 | this._sendMessage(sessionDescription, socketId); 380 | } 381 | 382 | _handleSDPError(error) { 383 | this.log('Session description error: ' + error.toString()); 384 | 385 | this._emit('error', { 386 | error: new Error(`Session description error: ${error.toString()}`), 387 | }); 388 | } 389 | 390 | _handleOnTrack(socketId, event) { 391 | this.log('Remote stream added for ', socketId); 392 | 393 | if (this.streams[socketId]?.id !== event.streams[0].id) { 394 | this.streams[socketId] = event.streams[0]; 395 | 396 | this._emit('newUser', { 397 | socketId, 398 | stream: event.streams[0], 399 | }); 400 | } 401 | } 402 | 403 | _handleUserLeave(socketId) { 404 | this.log(socketId, 'Left the call.'); 405 | this._removeUser(socketId); 406 | this.isInitiator = false; 407 | } 408 | 409 | _removeUser(socketId = null) { 410 | if (!socketId) { 411 | // close all connections 412 | for (const [key, value] of Object.entries(this.pcs)) { 413 | this.log('closing', value); 414 | value.close(); 415 | delete this.pcs[key]; 416 | } 417 | this.streams = {}; 418 | } else { 419 | if (!this.pcs[socketId]) return; 420 | this.pcs[socketId].close(); 421 | delete this.pcs[socketId]; 422 | 423 | delete this.streams[socketId]; 424 | } 425 | 426 | this._emit('removeUser', { socketId }); 427 | } 428 | 429 | kickUser(socketId) { 430 | if (!this.isAdmin) { 431 | this._emit('notification', { 432 | notification: 'You are not an admin', 433 | }); 434 | return; 435 | } 436 | this._removeUser(socketId); 437 | this.socket.emit('kickout', socketId, this.room); 438 | } 439 | } 440 | --------------------------------------------------------------------------------