Initial deploy bundle for WebRTC signaling server and multipeer demo.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
781
www/js/main.js
Normal file
781
www/js/main.js
Normal file
@@ -0,0 +1,781 @@
|
||||
/***
|
||||
* 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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user