├── firebase-config.js
├── README.md
├── style.css
├── controls.js
├── utils.js
├── index.html
└── script.js
/firebase-config.js:
--------------------------------------------------------------------------------
1 | // Your web app's Firebase configuration
2 | var firebaseConfig = {
3 | apiKey: "AIzaSyBoLMgevk3Yvj5RDml8Cw9zSTIsv8HT7b0",
4 | authDomain: "chatty-5da9d.firebaseapp.com",
5 | databaseURL: "https://chatty-5da9d.firebaseio.com",
6 | projectId: "chatty-5da9d",
7 | storageBucket: "chatty-5da9d.appspot.com",
8 | messagingSenderId: "785393110798"
9 | };
10 | // Initialize Firebase
11 | firebase.initializeApp(firebaseConfig);
12 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # RTC Demo
2 |
3 | ## Introduction
4 |
5 | This project uses Firebase Realtime Database for sending signal between host and offerer. It has been tested on both Android and Safari on iOS.
6 |
7 | Original blog post (vietnamese): [https://blog.ngxson.com/36-tu-che-phan-mem-video-call](https://blog.ngxson.com/36-tu-che-phan-mem-video-call)
8 |
9 | Live demo: [https://ngxson.github.io/hobby-rtc-demo](https://ngxson.github.io/hobby-rtc-demo)
10 |
11 | ## How to use
12 |
13 | Create a new firebase project, then add it to `firebase-config.js`
14 |
15 | ## Credit
16 |
17 | This project is made by [ngxson](https://ngxson.com)
18 | Based on [scaledrone](https://github.com/ScaleDrone/webrtc)'s version
19 |
--------------------------------------------------------------------------------
/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: Arial, Helvetica, sans-serif;
3 | background-color: rgba(218, 219, 233, 1);
4 | color: rgb(43, 43, 43);
5 | margin: 0 0;
6 | padding: 0 0;
7 | min-height: 100vh;
8 | }
9 | #videoView {
10 | position: relative;
11 | max-width: 100vw;
12 | width: 400px;
13 | background-color: rgba(0, 0, 0, 0.5);
14 | }
15 | #remoteVideo {
16 | max-width: 100vw;
17 | width: 400px;
18 | }
19 | #localVideo {
20 | max-width: 100px;
21 | z-index: 10;
22 | position: absolute;
23 | bottom: 10px;
24 | left: 10px;
25 | }
26 | .btn {
27 | margin: 0.5em;
28 | padding: 0.7em;
29 | border: 1px solid rgba(21, 22, 39, 0.589);
30 | cursor: pointer;
31 | }
32 | .btn:hover {
33 | background-color: rgba(0, 0, 0, 0.2);
34 | }
35 | #roomId {
36 | font-family: monospace;
37 | font-size: 1.25em;
38 | padding: 2px;
39 | background-color: white;
40 | color: black;
41 | }
42 | #wrapper {
43 | position: fixed;
44 | width: 100vw;
45 | height: 100vh;
46 | overflow: auto;
47 | }
48 |
49 | /* dark mode */
50 | body.dark {
51 | background-color: rgba(21, 22, 39) !important;
52 | color: white !important;
53 | }
54 | .dark .btn:hover {
55 | background-color: rgba(255, 255, 255, 0.2) !important;
56 | }
57 | .dark .btn {
58 | border: 1px solid white !important;
59 | }
--------------------------------------------------------------------------------
/controls.js:
--------------------------------------------------------------------------------
1 | var TIMER = 0;
2 |
3 | function startTimer() {
4 | var seconds = 0,
5 | minutes = 0,
6 | hours = 0;
7 |
8 | function timerTick() {
9 | seconds++;
10 | if (seconds >= 60) {
11 | seconds = 0;
12 | minutes++;
13 | if (minutes >= 60) {
14 | minutes = 0;
15 | hours++;
16 | }
17 | }
18 |
19 | $('#timer').html((hours ? (hours > 9 ? hours : "0" + hours) : "00") + ":" + (minutes ? (minutes > 9 ? minutes : "0" + minutes) : "00") + ":" + (seconds > 9 ? seconds : "0" + seconds));
20 | }
21 |
22 | clearInterval(TIMER);
23 | TIMER = setInterval(timerTick, 1000);
24 | }
25 |
26 | function stopTimer() {
27 | clearInterval(TIMER);
28 | $('#timer').html('');
29 | }
30 |
31 | var is_has_audio = true;
32 |
33 | function toggleMute() {
34 | is_has_audio = !is_has_audio;
35 | mediaStream.getAudioTracks()[0].enabled = is_has_audio;
36 | $('#toggleMuteBtn').html(is_has_audio ? 'Mute' : 'Unmute');
37 | }
38 |
39 | var is_hangup = false;
40 |
41 | function hangup() {
42 | pc.close();
43 | stopTimer();
44 | $('#mainApp').hide();
45 | $('#hangupMsg').show();
46 | pc = null;
47 | is_hangup = true;
48 | sendMessage({
49 | 'hangup': 1
50 | });
51 | }
52 |
53 | // theme utils
54 |
55 | function isCurrentThemeDark() {
56 | var dark = window.localStorage.getItem('dark');
57 | return !!dark;
58 | }
59 |
60 | function toggleTheme() {
61 | if (!isCurrentThemeDark()) {
62 | setDarkTheme(true);
63 | window.localStorage.setItem('dark', 'true');
64 | } else {
65 | setDarkTheme(false);
66 | window.localStorage.removeItem('dark');
67 | }
68 | }
69 |
70 | function setDarkTheme(isDark) {
71 | if (isDark) {
72 | $('body').addClass('dark');
73 | $('#toggleThemeBtn').text('Light theme');
74 | } else {
75 | $('body').removeClass('dark');
76 | $('#toggleThemeBtn').text('Dark theme');
77 | }
78 | }
79 |
80 | $(document).ready(function () {
81 | setDarkTheme(isCurrentThemeDark());
82 | });
83 |
--------------------------------------------------------------------------------
/utils.js:
--------------------------------------------------------------------------------
1 | const DEBUG = true;
2 |
3 | function getParameterByName(name) {
4 | url = window.location.href;
5 | name = name.replace(/[\[\]]/g, "\\$&");
6 | var regex = new RegExp("[?&]" + name + "(=([^]*)|&|#|$)"),
7 | results = regex.exec(url);
8 | if (!results) return null;
9 | if (!results[2]) return '';
10 | return decodeURIComponent(results[2].replace(/\+/g, " "));
11 | }
12 |
13 | function dlog(m) {
14 | if (!DEBUG) return;
15 | const t = $('#debug').html();
16 | const m1 = $('
').text(m.substring(0, 120)).html();
17 | $('#debug').html(t + '
' + m1);
18 | }
19 |
20 | function nogociateHosts(members) {
21 | var arr = Object.keys(members);
22 | var idToReset = arr.sort().pop();
23 | if (idToReset === MYUID) window.location.reload();
24 | }
25 |
26 | function setupMemberObserver() {
27 | var lastHostCount = -1;
28 | memberRef.on('value', (snap) => {
29 | if (is_hangup) return;
30 | const members = snap.val() || {};
31 | const temp = JSON.stringify(members);
32 | if (temp.match(/pending/)) return;
33 | const hostCount = (temp.match(/host/g) || []).length;
34 | const offererCount = (temp.match(/offerer/g) || []).length;
35 |
36 | // if no one is in the room, ignore
37 | if (temp.length < 4) {
38 | lastHostCount = 0;
39 | return;
40 | } else if (hostCount === 1) { // if we have one host
41 | if (lastHostCount === 0 && is_offerer) { // if host have just joined
42 | window.location.reload(); // reload to send offer to host
43 | }
44 | } else if (hostCount === 0) { // the host has left
45 | if (offererCount === 1) {
46 | window.location.reload(members); // we become the host
47 | } else {
48 | nogociateHosts(members); // if wa have more than 1 offerer
49 | }
50 | } else if (hostCount > 1) { // more than 1 host
51 | nogociateHosts(members); // negociate to become the host
52 | }
53 | lastHostCount = hostCount;
54 | });
55 | }
56 |
57 | function selectText(containerid) {
58 | if (document.selection) { // IE
59 | var range = document.body.createTextRange();
60 | range.moveToElementText(document.getElementById(containerid));
61 | range.select();
62 | } else if (window.getSelection) {
63 | var range = document.createRange();
64 | range.selectNode(document.getElementById(containerid));
65 | window.getSelection().removeAllRanges();
66 | window.getSelection().addRange(range);
67 | }
68 | }
69 |
70 |
71 | if (location.href.indexOf('fbclid=') !== -1) {
72 | location.replace(location.href.replace(/[\?\&]fbclid[^#]+/, ''));
73 | }
74 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | RTC Voice Call
10 |
11 |
12 |
13 |
14 | Nui RTC Demo
15 |
16 |
17 |
Please accept camera/microphone permissions
18 |
19 |
20 |
21 |
Error: Cannot connect to micro
22 |
23 |
24 |
25 |
Error: Invalid URL
26 |
27 |
28 |
29 |
Call ended
30 |
31 |
32 |
33 |
34 |
Retry
35 |
36 |
37 |
38 | Copy this link and send it to your partner:
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
Status: Waiting...
49 |
50 | Please keep this tab opened
51 |
52 |
53 |
54 |
55 |
56 |
57 | Mute
58 | End call
59 | Dark mode
60 |
61 |
62 |
63 |
64 |
65 | debug
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/script.js:
--------------------------------------------------------------------------------
1 | navigator.getUserMedia = navigator.getUserMedia ||
2 | navigator.webkitGetUserMedia ||
3 | navigator.mozGetUserMedia;
4 |
5 | const rand16Char = () => Math.floor(Math.random() * 0xFFFFFF).toString(16);
6 | const randId = () => rand16Char() + '' + rand16Char();
7 | const getMyId = () => {
8 | const savedId = localStorage.getItem('uid');
9 | const id = savedId ? savedId : randId();
10 | localStorage.setItem('uid', id);
11 | return id;
12 | }
13 |
14 | if (!getParameterByName('r')) {
15 | window.location.href += (window.location.href.indexOf('?') !== -1) ?
16 | ('&r=' + randId()) :
17 | ('?r=' + randId());
18 | }
19 |
20 | const roomHash = getParameterByName('r') || 'none';
21 | const MYUID = getMyId();
22 |
23 | $('#roomId').text(roomHash);
24 | $('#curent_url').text(window.location.href);
25 |
26 | const signalRef = firebase.database().ref('/rdemo/' + roomHash + '/signal');
27 | const memberRef = firebase.database().ref('/rdemo/' + roomHash + '/member');
28 | memberRef.child(MYUID).onDisconnect().remove();
29 |
30 |
31 | // Room name needs to be prefixed with 'observable-'
32 | const roomName = 'observable-' + roomHash;
33 | const configuration = {
34 | iceTransportPolicy: 'all',
35 | iceCandidatePoolSize: 4,
36 | iceServers: [{
37 | urls: ['stun:stun.l.google.com:19302']
38 | },
39 | {
40 | urls: ['turn:numb.viagenie.ca'],
41 | credential: 'muazkh',
42 | username: 'webrtc@live.com'
43 | }
44 | ]
45 | };
46 | var room;
47 | var pc;
48 | var mediaStream;
49 |
50 |
51 | function onSuccess() {};
52 |
53 | function onError(error) {
54 | console.error(error);
55 | };
56 |
57 | var is_offerer = false;
58 | var is_setup_done = false;
59 |
60 | function setupApp() {
61 | if (is_setup_done) return;
62 | is_setup_done = true;
63 | memberRef.once('value').then(snap => {
64 | const members = snap.val() || {};
65 | console.log('MEMBERS', members);
66 |
67 | // check if we had a host
68 | const hostCount = (JSON.stringify(members).match(/host/g) || []).length;
69 |
70 | // If we are the second user to connect to the room we will be creating the offer
71 | var isOfferer = (hostCount > 0);
72 |
73 | is_offerer = isOfferer;
74 | memberRef.child(MYUID).set(isOfferer ? 'offerer' : 'host');
75 | setupWebRTC(isOfferer);
76 | setupMemberObserver();
77 | });
78 | }
79 |
80 |
81 | // startup
82 |
83 | $(document).ready(function () {
84 | firebase.database().ref('.info/connected').on('value', function (snap) {
85 | if (snap.val() === true) {
86 | memberRef.child(MYUID).onDisconnect().remove();
87 | memberRef.child(MYUID).set('pending').then(setupApp);
88 | }
89 | });
90 | $('#askTurnOnMic').show();
91 | $('#debug').html('Version 1.0
');
92 | });
93 |
94 |
95 |
96 |
97 | setInterval(() => {
98 | memberRef.child(MYUID).onDisconnect().remove();
99 | }, 500);
100 |
101 | // Send signaling data
102 | function sendMessage(message) {
103 | console.log(message);
104 | const data = {
105 | message: JSON.stringify(message),
106 | uid: MYUID
107 | };
108 | const key = 'msg-' + Date.now();
109 | signalRef.child(key).set(data);
110 | signalRef.child(key).onDisconnect().remove();
111 | dlog(data.message);
112 | }
113 |
114 | function setupWebRTC(isOfferer) {
115 | navigator.mediaDevices.getUserMedia({
116 | audio: {
117 | sampleSize: 16
118 | },
119 | video: true
120 | }).then(stream => {
121 | $('#askTurnOnMic').hide();
122 | $('#mainApp').show();
123 | pc = new RTCPeerConnection(configuration);
124 | document.getElementById('localVideo').srcObject = stream;
125 | stream.getTracks().forEach(track => pc.addTrack(track, stream));
126 | mediaStream = stream;
127 | startWebRTC(isOfferer);
128 | setTimeout(() => $('video').attr('controls', false), 1000);
129 | }, (error) => {
130 | $('#askTurnOnMic').hide();
131 | $('#micDenided').show();
132 | alert('Error: Cannot access microphone/camera');
133 | });
134 | }
135 |
136 | var is_rtc_ready = false;
137 | var msg_queue = [];
138 |
139 | function startWebRTC(isOfferer) {
140 | console.log('startWebRTC', isOfferer);
141 |
142 | // 'onicecandidate' notifies us whenever an ICE agent needs to deliver a
143 | // message to the other peer through the signaling server
144 | pc.onicecandidate = event => {
145 | if (event.candidate) {
146 | // var candidate = event.candidate.candidate;
147 | // if (candidate.indexOf('relay') < 0) return;
148 | sendMessage({
149 | 'candidate': event.candidate
150 | });
151 | }
152 | };
153 |
154 | // listen for connection status
155 | pc.oniceconnectionstatechange = function (event) {
156 | if (pc.iceConnectionState === "failed" ||
157 | pc.iceConnectionState === "disconnected" ||
158 | pc.iceConnectionState === "closed") {
159 | $('#status').text('Call ended');
160 | stopTimer();
161 | }
162 |
163 | if (pc.iceConnectionState === "checking") {
164 | $('#status').text('Connecting...');
165 | }
166 |
167 | if (pc.iceConnectionState === "connected") {
168 | $('#status').text('Connected!');
169 | startTimer();
170 | }
171 | };
172 |
173 | // If user is offerer let the 'negotiationneeded' event create the offer
174 | if (isOfferer) {
175 | dlog('onnegotiationneeded');
176 | pc.onnegotiationneeded = () => {
177 | dlog('createOffer');
178 | pc.createOffer({
179 | iceRestart: true,
180 | offerToReceiveAudio: 1,
181 | offerToReceiveVideo: 1
182 | }).then(localDescCreated).catch(onError);
183 | }
184 | }
185 |
186 | // When a remote stream arrives display it in the #remoteVideo element
187 | pc.ontrack = event => {
188 | const remoteVid = document.getElementById('remoteVideo');
189 | const stream = event.streams[0];
190 | if (!remoteVid.srcObject || remoteVid.srcObject.id !== stream.id) {
191 | remoteVid.srcObject = stream;
192 | }
193 | };
194 |
195 | is_rtc_ready = true;
196 |
197 | msg_queue.reverse().forEach(onSignal);
198 | }
199 |
200 | function onSignal(snap) {
201 | const signal = snap.val();
202 | // Message was sent by us
203 | if (signal.uid === MYUID) {
204 | return;
205 | }
206 |
207 | // Message is handled
208 | snap.ref.remove();
209 | const message = JSON.parse(signal.message);
210 | console.log(message);
211 |
212 | if (!is_rtc_ready) {
213 | msg_queue.push(snap);
214 | return;
215 | }
216 |
217 | if (message.sdp) {
218 | // This is called after receiving an offer or answer from another peer
219 | pc.setRemoteDescription(new RTCSessionDescription(message.sdp), () => {
220 | // When receiving an offer lets answer it
221 | if (pc.remoteDescription.type === 'offer') {
222 | pc.createAnswer().then(localDescCreated).catch(onError);
223 | }
224 | }, onError);
225 | } else if (message.candidate) {
226 | // Add the new ICE candidate to our connections remote description
227 | pc.addIceCandidate(
228 | new RTCIceCandidate(message.candidate), onSuccess, onError
229 | );
230 | } else if (message.hangup) {
231 | hangup();
232 | }
233 | }
234 |
235 |
236 | signalRef.on('child_added', onSignal);
237 |
238 |
239 | function localDescCreated(desc) {
240 | dlog('localDescCreated');
241 | pc.setLocalDescription(new RTCSessionDescription(desc))
242 | .then(function () {
243 | sendMessage({
244 | 'sdp': desc
245 | });
246 | })
247 | .catch(onError);
248 | }
--------------------------------------------------------------------------------