782 lines
21 KiB
JavaScript
782 lines
21 KiB
JavaScript
/***
|
|
* Excerpted from "Programming WebRTC",
|
|
* published by The Pragmatic Bookshelf.
|
|
* Copyrights apply to this code. It may not be used to create training material,
|
|
* courses, books, articles, and the like. Contact us if you are in doubt.
|
|
* We make no guarantees that this code is fit for any purpose.
|
|
* Visit https://pragprog.com/titles/ksrtc for more book information.
|
|
***/
|
|
'use strict';
|
|
|
|
/**
|
|
* Global Variables: $self and $peers
|
|
*/
|
|
|
|
const $self = {
|
|
rtcConfig: {
|
|
iceServers: [
|
|
{ urls: 'stun:stun.l.google.com:19302' },
|
|
{ urls: 'stun:stun1.l.google.com:19302' },
|
|
],
|
|
},
|
|
mediaConstraints: { audio: true, video: true },
|
|
mediaDevices: { audioinput: [], videoinput: [] },
|
|
mediaStream: new MediaStream(),
|
|
mediaTracks: {},
|
|
features: {
|
|
audio: false,
|
|
video: true,
|
|
},
|
|
};
|
|
|
|
const $peers = new Map();
|
|
|
|
|
|
|
|
/**
|
|
* Signaling-Channel Setup
|
|
*/
|
|
|
|
const namespace = prepareNamespace(window.location.hash, true);
|
|
|
|
const sc = io.connect('/' + namespace, { autoConnect: false });
|
|
|
|
registerScCallbacks();
|
|
|
|
|
|
|
|
/**
|
|
* =========================================================================
|
|
* Begin Application-Specific Code
|
|
* =========================================================================
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
* User-Interface Setup
|
|
*/
|
|
|
|
document.querySelector('#toggle-mic')
|
|
.setAttribute('aria-checked', $self.features.audio);
|
|
|
|
document.querySelector('#header h1')
|
|
.innerText = 'Welcome to Room #' + namespace;
|
|
|
|
document.querySelector('#call-button')
|
|
.addEventListener('click', handleCallButton);
|
|
|
|
document.querySelector('#footer')
|
|
.addEventListener('click', handleMediaButtons);
|
|
|
|
document.querySelector('#username-form')
|
|
.addEventListener('submit', handleUsernameForm);
|
|
|
|
|
|
|
|
/**
|
|
* User-Media Setup and Events
|
|
*/
|
|
|
|
requestUserMedia($self.mediaConstraints);
|
|
|
|
// These events can fire in rapid succession when,
|
|
// for example, a camera with built-in mic is connected.
|
|
// That's why it's necessary to debounce to 500ms before
|
|
// executing the callback. Otherwise, the callback might
|
|
// prompt users to access the camera, and then again to
|
|
// access the camera and the mic both.
|
|
navigator.mediaDevices.ondevicechange = debounce(handleMediaDeviceChange, 500);
|
|
|
|
|
|
|
|
/**
|
|
* User-Interface Functions and Callbacks
|
|
*/
|
|
|
|
function handleCallButton(event) {
|
|
const call_button = event.target;
|
|
if (call_button.className === 'join') {
|
|
console.log('Joining the call...');
|
|
call_button.className = 'leave';
|
|
call_button.innerText = 'Leave Call';
|
|
joinCall();
|
|
} else {
|
|
console.log('Leaving the call...');
|
|
call_button.className = 'join';
|
|
call_button.innerText = 'Join Call';
|
|
leaveCall();
|
|
}
|
|
}
|
|
|
|
function joinCall() {
|
|
sc.open();
|
|
}
|
|
|
|
function leaveCall() {
|
|
sc.close();
|
|
for (let id of $peers.keys()) {
|
|
resetPeer(id);
|
|
}
|
|
}
|
|
|
|
function handleMediaButtons(event) {
|
|
const target = event.target;
|
|
if (target.tagName !== 'BUTTON') return;
|
|
switch (target.id) {
|
|
case 'toggle-mic':
|
|
toggleMic(target);
|
|
break;
|
|
case 'toggle-cam':
|
|
toggleCam(target);
|
|
break;
|
|
}
|
|
}
|
|
|
|
function toggleMic(button) {
|
|
const audio = $self.mediaTracks.audio;
|
|
const enabled_state = audio.enabled = !audio.enabled;
|
|
|
|
$self.features.audio = enabled_state;
|
|
|
|
button.setAttribute('aria-checked', enabled_state);
|
|
|
|
for (let id of $peers.keys()) {
|
|
shareFeatures(id, 'audio');
|
|
}
|
|
}
|
|
|
|
function toggleCam(button) {
|
|
const video = $self.mediaTracks.video;
|
|
const enabled_state = video.enabled = !video.enabled;
|
|
|
|
$self.features.video = enabled_state;
|
|
|
|
button.setAttribute('aria-checked', enabled_state);
|
|
|
|
for (let id of $peers.keys()) {
|
|
shareFeatures(id, 'video');
|
|
}
|
|
|
|
if (enabled_state) {
|
|
$self.mediaStream.addTrack($self.mediaTracks.video);
|
|
} else {
|
|
$self.mediaStream.removeTrack($self.mediaTracks.video);
|
|
displayStream($self.mediaStream);
|
|
}
|
|
}
|
|
|
|
function enableOrDisableMediaToggleButtons() {
|
|
const audio_button = document.querySelector('#toggle-mic');
|
|
const video_button = document.querySelector('#toggle-cam');
|
|
|
|
// Set the disabled attribute's value based on the
|
|
// available media devices
|
|
audio_button.disabled = $self.mediaDevices.audioinput.length === 0;
|
|
video_button.disabled = $self.mediaDevices.videoinput.length === 0;
|
|
}
|
|
|
|
function handleUsernameForm(e) {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const username = form.querySelector('#username-input').value;
|
|
const figcaption = document.querySelector('#self figcaption');
|
|
figcaption.innerText = username;
|
|
|
|
$self.features.username = username;
|
|
|
|
for (let id of $peers.keys()) {
|
|
shareFeatures(id, 'username');
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* User-Media and Data-Channel Functions
|
|
*/
|
|
|
|
async function detectAvailableMediaDevices() {
|
|
// Assume there are no devices until we detect them
|
|
$self.mediaDevices.audioinput = [];
|
|
$self.mediaDevices.videoinput = [];
|
|
|
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
|
|
for (let device of devices) {
|
|
// Only interested in audio and video inputs
|
|
const input_kinds = ['audioinput', 'videoinput'];
|
|
if (input_kinds.includes(device.kind)) {
|
|
$self.mediaDevices[device.kind].push(device);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleMediaDeviceChange() {
|
|
const previous_devices =
|
|
$self.mediaDevices.audioinput.length > 0
|
|
|| $self.mediaDevices.videoinput.length > 0;
|
|
|
|
// First things first: on any device change,
|
|
// update the list of available media devices
|
|
await detectAvailableMediaDevices();
|
|
|
|
const available_devices =
|
|
$self.mediaDevices.audioinput.length > 0
|
|
|| $self.mediaDevices.videoinput.length > 0;
|
|
|
|
// Case One: A device has been plugged in and
|
|
// no other devices were previously available
|
|
if (!previous_devices && available_devices) {
|
|
// Request user media as though the app had just been opened,
|
|
// but await it so that we can ensure there's media available
|
|
// before adding it to the connection
|
|
await requestUserMedia($self.mediaConstraints);
|
|
|
|
for (let id of $peers.keys()) {
|
|
addStreamingMedia(id);
|
|
shareFeatures(id, 'audio', 'video');
|
|
}
|
|
}
|
|
|
|
// Case Two: The current device has been unplugged
|
|
// and there are now no devices available
|
|
if (!available_devices) {
|
|
// Reset all the media properties
|
|
$self.media = false;
|
|
$self.mediaTracks = {};
|
|
$self.mediaStream = new MediaStream();
|
|
// Toggle off the media buttons, and null out
|
|
// the self-side stream
|
|
enableOrDisableMediaToggleButtons();
|
|
displayStream(null);
|
|
// Remove tracks from peer connections
|
|
for (let id of $peers.keys()) {
|
|
removeStreamingMedia(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function requestUserMedia(media_constraints) {
|
|
// Duplicate the media constraints so as to not affect
|
|
// the original values on $self
|
|
const refined_media_constraints =
|
|
JSON.parse(JSON.stringify(media_constraints));
|
|
|
|
// See what devices are available
|
|
await detectAvailableMediaDevices();
|
|
|
|
// Refine media constraints based on device availability
|
|
if ($self.mediaDevices.audioinput.length === 0) {
|
|
// There's no audio device, so ensure that constraint is false
|
|
refined_media_constraints.audio = false;
|
|
}
|
|
if ($self.mediaDevices.videoinput.length === 0) {
|
|
// There's no video device, so ensure that constraint is false
|
|
refined_media_constraints.video = false;
|
|
}
|
|
|
|
// Disable or re-enable toggle buttons
|
|
enableOrDisableMediaToggleButtons();
|
|
|
|
if (!refined_media_constraints.audio &&
|
|
!refined_media_constraints.video) {
|
|
// If no media is available, we exit, as passing
|
|
// { audio: false, video: false } as a constraint
|
|
// results in a TypeError
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$self.media = await navigator.mediaDevices
|
|
.getUserMedia(refined_media_constraints);
|
|
// Detect the devices again, now that permissions
|
|
// have been granted; this helps capture a value
|
|
// on the "label" field on MediaDeviceInfo for
|
|
// easier inspection
|
|
await detectAvailableMediaDevices();
|
|
} catch(e) {
|
|
console.error(e.name, e.message);
|
|
}
|
|
|
|
// Hold onto audio- and video-track references
|
|
$self.mediaTracks.audio = $self.media.getAudioTracks()[0];
|
|
$self.mediaTracks.video = $self.media.getVideoTracks()[0];
|
|
|
|
if ($self.mediaTracks.audio) {
|
|
// Mute the audio if `$self.features.audio` evaluates to `false`
|
|
$self.mediaTracks.audio.enabled = !!$self.features.audio;
|
|
// Add audio track to mediaStream
|
|
$self.mediaStream.addTrack($self.mediaTracks.audio);
|
|
}
|
|
if ($self.mediaTracks.video) {
|
|
// Toggle off the camera if `$self.features.video` evaluates to `false`
|
|
$self.mediaTracks.video.enabled = !!$self.features.video;
|
|
// Add video track to mediaStream
|
|
$self.mediaStream.addTrack($self.mediaTracks.video);
|
|
}
|
|
|
|
displayStream($self.mediaStream);
|
|
}
|
|
|
|
function createVideoStructure(id) {
|
|
const figure = document.createElement('figure');
|
|
const figcaption = document.createElement('figcaption');
|
|
const video = document.createElement('video');
|
|
const attributes = {
|
|
autoplay: '',
|
|
playsinline: '',
|
|
poster: 'img/placeholder.png',
|
|
};
|
|
const attributes_list = Object.keys(attributes);
|
|
|
|
// Set attributes
|
|
figure.id = `peer-${id}`;
|
|
figcaption.innerText = id;
|
|
for (let attr of attributes_list) {
|
|
video.setAttribute(attr, attributes[attr]);
|
|
}
|
|
// Append the video and figcaption elements
|
|
figure.appendChild(video);
|
|
figure.appendChild(figcaption);
|
|
// Return the complete figure
|
|
return figure;
|
|
}
|
|
|
|
function displayStream(stream, id = 'self') {
|
|
const selector = id === 'self' ? '#self' : `#peer-${id}`;
|
|
let video_structure = document.querySelector(selector);
|
|
if (!video_structure) {
|
|
const videos = document.querySelector('#videos');
|
|
video_structure = createVideoStructure(id);
|
|
videos.appendChild(video_structure);
|
|
}
|
|
video_structure.querySelector('video').srcObject = stream;
|
|
}
|
|
|
|
function addStreamingMedia(id) {
|
|
const peer = $peers.get(id);
|
|
const tracks = Object.keys($self.mediaTracks);
|
|
for (let track of tracks) {
|
|
if ($self.mediaTracks[track]) {
|
|
peer.connection.addTrack($self.mediaTracks[track]);
|
|
}
|
|
}
|
|
}
|
|
|
|
function removeStreamingMedia(id) {
|
|
const peer = $peers.get(id);
|
|
// Detect the senders on the connection; we need
|
|
// them to remove tracks from the peer connection
|
|
const senders = peer.connection.getSenders();
|
|
const senders_list = Object.keys(senders);
|
|
// Loop through the senders, and pull the tracks
|
|
// off them, one by one
|
|
for (let sender of senders_list) {
|
|
const track = senders[sender].track;
|
|
if (track) {
|
|
// Remove the track on its associated sender
|
|
peer.connection.removeTrack(senders[sender]);
|
|
}
|
|
}
|
|
// Send a 'removeAllTracks' feature to clean things up
|
|
// on the receiving peer's side.
|
|
shareFeatures(id, 'removeAllTracks');
|
|
}
|
|
|
|
function addFeaturesChannel(id) {
|
|
const peer = $peers.get(id);
|
|
|
|
const featureFunctions = {
|
|
audio: function() {
|
|
const username = peer.features.username ? peer.features.username : id;
|
|
showUsernameAndMuteStatus(username);
|
|
},
|
|
removeAllTracks: function() {
|
|
const tracks_list = Object.keys(peer.mediaTracks);
|
|
for (let track of tracks_list) {
|
|
peer.mediaStream.removeTrack(peer.mediaTracks[track]);
|
|
}
|
|
// Empty out the media tracks object
|
|
peer.mediaTracks = {};
|
|
// Create a brand-new media stream in case tracks
|
|
// need to be added later
|
|
peer.mediaStream = new MediaStream();
|
|
// But null out the display of the media stream
|
|
// for the time being
|
|
displayStream(null, id);
|
|
},
|
|
|
|
username: function() {
|
|
// Update the username
|
|
showUsernameAndMuteStatus(peer.features.username);
|
|
},
|
|
video: function() {
|
|
// This is all just to display the poster image,
|
|
// rather than a black frame
|
|
if (peer.mediaTracks.video) {
|
|
if (peer.features.video) {
|
|
peer.mediaStream.addTrack(peer.mediaTracks.video);
|
|
} else {
|
|
peer.mediaStream.removeTrack(peer.mediaTracks.video);
|
|
displayStream(peer.mediaStream, id);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
peer.featuresChannel =
|
|
peer.connection.createDataChannel('features',
|
|
{ negotiated: true, id: 110 });
|
|
|
|
peer.featuresChannel.onopen = function() {
|
|
// send features information just as soon as the channel opens
|
|
peer.featuresChannel.send(JSON.stringify($self.features));
|
|
};
|
|
|
|
peer.featuresChannel.onmessage = function(event) {
|
|
const features = JSON.parse(event.data);
|
|
const features_list = Object.keys(features);
|
|
for (let f of features_list) {
|
|
// update the corresponding features field on $peer
|
|
peer.features[f] = features[f];
|
|
// if there's a corresponding function, run it
|
|
if (typeof featureFunctions[f] === 'function') {
|
|
featureFunctions[f]();
|
|
}
|
|
}
|
|
};
|
|
|
|
function showUsernameAndMuteStatus(username) {
|
|
const fc = document.querySelector(`#peer-${id} figcaption`);
|
|
if (peer.features.audio) {
|
|
fc.innerText = username;
|
|
} else {
|
|
fc.innerText = `${username} (Muted)`;
|
|
}
|
|
}
|
|
}
|
|
|
|
function shareFeatures(id, ...features) {
|
|
const peer = $peers.get(id);
|
|
|
|
const featuresToShare = {};
|
|
|
|
if (!peer.featuresChannel) return;
|
|
|
|
for (let f of features) {
|
|
featuresToShare[f] =
|
|
$self.features[f] ? $self.features[f] : 'true';
|
|
}
|
|
|
|
try {
|
|
peer.featuresChannel.send(JSON.stringify(featuresToShare));
|
|
} catch(e) {
|
|
console.error('Error sending features:', e);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Call Features & Reset Functions
|
|
*/
|
|
|
|
function initializePeer(id, polite) {
|
|
$peers.set(id, {
|
|
connection: new RTCPeerConnection($self.rtcConfig),
|
|
mediaStream: new MediaStream(),
|
|
mediaTracks: {},
|
|
features: {},
|
|
selfStates: {
|
|
isPolite: polite,
|
|
isMakingOffer: false,
|
|
isIgnoringOffer: false,
|
|
isSettingRemoteAnswerPending: false,
|
|
isSuppressingInitialOffer: false,
|
|
},
|
|
});
|
|
}
|
|
|
|
function establishCallFeatures(id) {
|
|
registerRtcCallbacks(id);
|
|
addFeaturesChannel(id);
|
|
// Always set up a display stream, even
|
|
// for users who might not have streaming
|
|
// media. Set the stream itself to null
|
|
displayStream(null, id);
|
|
addStreamingMedia(id);
|
|
}
|
|
|
|
function resetPeer(id) {
|
|
const peer = $peers.get(id);
|
|
displayStream(null, id);
|
|
document.querySelector(`#peer-${id}`).remove();
|
|
peer.connection.close();
|
|
$peers.delete(id);
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* WebRTC Functions and Callbacks
|
|
*/
|
|
|
|
function registerRtcCallbacks(id) {
|
|
const peer = $peers.get(id);
|
|
peer.connection
|
|
.onconnectionstatechange = handleRtcConnectionStateChange(id);
|
|
peer.connection
|
|
.onnegotiationneeded = handleRtcConnectionNegotiation(id);
|
|
peer.connection
|
|
.onicecandidate = handleRtcIceCandidate(id);
|
|
peer.connection
|
|
.ontrack = handleRtcPeerTrack(id);
|
|
}
|
|
|
|
function handleRtcPeerTrack(id) {
|
|
return function({ track }) {
|
|
const peer = $peers.get(id);
|
|
console.log(`Handle incoming ${track.kind} track from peer ID: ${id}`);
|
|
peer.mediaTracks[track.kind] = track;
|
|
peer.mediaStream.addTrack(track);
|
|
displayStream(peer.mediaStream, id);
|
|
};
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* =========================================================================
|
|
* End Application-Specific Code
|
|
* =========================================================================
|
|
*/
|
|
|
|
|
|
/**
|
|
* Reusable WebRTC Functions and Callbacks
|
|
*/
|
|
function handleRtcConnectionNegotiation(id) {
|
|
return async function() {
|
|
const peer = $peers.get(id);
|
|
const self_state = peer.selfStates;
|
|
// peers suppressing initial offers should do nothing but exit
|
|
if (self_state.isSuppressingInitialOffer) return;
|
|
// older browsers do not automatically create an offer when they
|
|
// call `setLocalDescription`
|
|
try {
|
|
self_state.isMakingOffer = true;
|
|
await peer.connection.setLocalDescription();
|
|
} catch(e) {
|
|
// manually create the offer with the automatic version fails
|
|
const offer = await peer.connection.createOffer();
|
|
await peer.connection.setLocalDescription(offer);
|
|
} finally {
|
|
// however the local description is set, send it over the
|
|
// signaling channel
|
|
sc.emit('signal',
|
|
{ recipient: id, sender: $self.id,
|
|
signal: { description: peer.connection.localDescription } });
|
|
self_state.isMakingOffer = false;
|
|
}
|
|
};
|
|
}
|
|
|
|
function handleRtcIceCandidate(id) {
|
|
return function({ candidate }) {
|
|
if (candidate) {
|
|
console.log(`Handling ICE candidate, type '${ candidate.type }'...`);
|
|
}
|
|
sc.emit('signal', { recipient: id, sender: $self.id,
|
|
signal: { candidate } });
|
|
};
|
|
}
|
|
|
|
function handleRtcConnectionStateChange(id) {
|
|
return function() {
|
|
const peer = $peers.get(id);
|
|
const connection_state = peer.connection.connectionState;
|
|
// Assume *some* element will take a unique peer ID
|
|
const peer_element = document.querySelector(`#peer-${id}`);
|
|
if (peer_element) {
|
|
peer_element.dataset.connectionState = connection_state;
|
|
}
|
|
console.log(`Connection state '${connection_state}' for Peer ID: ${id}`);
|
|
};
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Signaling-Channel Functions and Callbacks
|
|
*/
|
|
|
|
function registerScCallbacks() {
|
|
sc.on('connect', handleScConnect);
|
|
sc.on('connected peers', handleScConnectedPeers);
|
|
sc.on('connected peer', handleScConnectedPeer);
|
|
sc.on('disconnected peer', handleScDisconnectedPeer);
|
|
sc.on('signal', handleScSignal);
|
|
}
|
|
|
|
function handleScConnect() {
|
|
console.log('Successfully connected to the signaling server!');
|
|
$self.id = sc.id;
|
|
console.log(`Self ID: ${$self.id}`);
|
|
}
|
|
|
|
function handleScConnectedPeers({ peers, credentials }) {
|
|
const ids = peers;
|
|
console.log(`Connected peer IDs: ${ids.join(', ')}`);
|
|
|
|
console.log(`TURN Credentials: ${JSON.stringify(credentials)}`);
|
|
// addCredentialedTurnServer('turn:coturn.example.com:3478', credentials);
|
|
|
|
for (let id of ids) {
|
|
if (id === $self.id) continue;
|
|
// be polite with already-connected peers
|
|
initializePeer(id, true);
|
|
establishCallFeatures(id);
|
|
}
|
|
}
|
|
|
|
function handleScConnectedPeer(id) {
|
|
console.log(`Newly connected peer ID: ${id}`);
|
|
// be impolite with each newly connecting peer
|
|
initializePeer(id, false);
|
|
establishCallFeatures(id);
|
|
}
|
|
|
|
function handleScDisconnectedPeer(id) {
|
|
console.log(`Disconnected peer ID: ${id}`);
|
|
resetPeer(id);
|
|
}
|
|
|
|
function resetAndRetryConnection(id) {
|
|
const peer = $peers.get(id);
|
|
// Preserve the original politeness value
|
|
const is_polite = peer.selfStates.isPolite;
|
|
// Re-initialize the peer
|
|
initializePeer(id, is_polite);
|
|
const self_state = peer.selfStates;
|
|
// Set the offer-suppression state property to `true` for the
|
|
// polite peer
|
|
self_state.isSuppressingInitialOffer = is_polite;
|
|
|
|
// Reestablish the call features, which triggers
|
|
// the `negotiationneeded` event and its callback again
|
|
establishCallFeatures(id);
|
|
|
|
// Inform the impolite peer to reset, too:
|
|
if (is_polite) {
|
|
sc.emit('signal', { description: { type: '_reset' } });
|
|
}
|
|
}
|
|
|
|
async function handleScSignal({ sender,
|
|
signal: { candidate, description } }) {
|
|
|
|
const id = sender;
|
|
const peer = $peers.get(id);
|
|
const self_state = peer.selfStates;
|
|
|
|
if (description) {
|
|
|
|
if (description.type === '_reset') {
|
|
// Reset and retry the connection, and exit the `handleScSignal()`
|
|
// function
|
|
resetAndRetryConnection(id);
|
|
return;
|
|
}
|
|
|
|
const ready_for_offer =
|
|
!self_state.isMakingOffer &&
|
|
(peer.connection.signalingState === 'stable'
|
|
|| self_state.isSettingRemoteAnswerPending);
|
|
|
|
const offer_collision =
|
|
description.type === 'offer' && !ready_for_offer;
|
|
|
|
self_state.isIgnoringOffer = !self_state.isPolite && offer_collision;
|
|
|
|
if (self_state.isIgnoringOffer) {
|
|
return;
|
|
}
|
|
|
|
self_state.isSettingRemoteAnswerPending = description.type === 'answer';
|
|
|
|
await peer.connection.setRemoteDescription(description);
|
|
|
|
self_state.isSettingRemoteAnswerPending = false;
|
|
|
|
if (description.type === 'offer') {
|
|
await peer.connection.setLocalDescription();
|
|
sc.emit('signal', { recipient: id, sender: $self.id,
|
|
signal: { description: peer.connection.localDescription } });
|
|
}
|
|
|
|
} else if (candidate) {
|
|
// Handle ICE candidates
|
|
try {
|
|
await peer.connection.addIceCandidate(candidate);
|
|
} catch(e) {
|
|
// Log error unless state is ignoring offers
|
|
// and candidate is not an empty string
|
|
if (!self_state.isIgnoringOffer && candidate.candidate.length > 1) {
|
|
console.error(`Unable to add ICE candidate for peer ID: ${id}.`, e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Utility Functions
|
|
*/
|
|
|
|
function addCredentialedTurnServer(server_string, { username, password }) {
|
|
// Add TURN server and credentials to iceServers array
|
|
$self.rtcConfig.iceServers.push({
|
|
urls: server_string,
|
|
username: username,
|
|
password: password,
|
|
});
|
|
}
|
|
|
|
function prepareNamespace(hash, set_location) {
|
|
let ns = hash.replace(/^#/, ''); // remove # from the hash
|
|
if (/^[a-z]{4}-[a-z]{4}-[a-z]{4}$/.test(ns)) {
|
|
console.log(`Checked existing namespace '${ns}'`);
|
|
return ns;
|
|
}
|
|
ns = generateRandomAlphaString('-', 4, 4, 4);
|
|
console.log(`Created new namespace '${ns}'`);
|
|
if (set_location) window.location.hash = ns;
|
|
return ns;
|
|
}
|
|
|
|
function generateRandomAlphaString(separator, ...groups) {
|
|
const alphabet = 'abcdefghijklmnopqrstuvwxyz';
|
|
let ns = [];
|
|
for (let group of groups) {
|
|
let str = '';
|
|
for (let i = 0; i < group; i++) {
|
|
str += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
}
|
|
ns.push(str);
|
|
}
|
|
return ns.join(separator);
|
|
}
|
|
|
|
function debounce(callback_function, wait_in_milliseconds) {
|
|
let timeout;
|
|
return (...args) => {
|
|
const context = this;
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(
|
|
() => callback_function.apply(context, args),
|
|
wait_in_milliseconds);
|
|
};
|
|
}
|