├── README.md ├── controls.js ├── firebase-config.js ├── index.html ├── script.js ├── style.css └── utils.js /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | RTC Voice Call 10 | 11 | 12 | 13 |
14 |

Nui RTC Demo

15 | 16 | 19 | 20 | 23 | 24 | 27 | 28 | 36 | 37 | 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 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------