├── .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 |
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 |
--------------------------------------------------------------------------------