Last active
March 12, 2021 09:58
-
-
Save Ibadichan/f6f7ffba85326196583d09a1a394e449 to your computer and use it in GitHub Desktop.
The implementation of WebRTC connection.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import socket from 'shared/sockets/OASocket'; | |
/** | |
* Class representing a MeetingConnection. | |
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation} | |
* to understand "The WebRTC perfect negotiation pattern". | |
*/ | |
class MeetingConnection { | |
/** | |
* Creates a meetingConnection. | |
* @param {Object} params - The params for creating meeting connection. | |
* @param {MediaStream} params.localStream - The local media stream. | |
* @param {string} params.participantSocketId - The socket id of other end user. | |
* @param {string} params.peerConnectionId - The unique string for each connection. | |
* @param {boolean} params.polite - The type of user. | |
* @param {function} params.onReceiveRemoteStream - The callback for ontrack event. | |
* @param {boolean} params.skipNegotiation - The flag to turn on/off perfect negotiation. | |
*/ | |
constructor(params) { | |
this.localStream = params.localStream; | |
this.participantSocketId = params.participantSocketId; | |
this.peerConnectionId = params.peerConnectionId; | |
this.polite = params.polite; | |
this.onReceiveRemoteStream = params.onReceiveRemoteStream; | |
this.skipNegotiation = params.skipNegotiation; | |
this.makingOffer = false; | |
this.ignoreOffer = false; | |
this.isSettingRemoteAnswerPending = false; | |
this.isActive = true; | |
this.handleCallServiceMessage = this.handleCallServiceMessage.bind(this); | |
socket.on('call-service-message', this.handleCallServiceMessage); | |
this.initializeConnection(); | |
} | |
/** | |
* Destroys a meeting connection, disables camera | |
* @param {Object} [params={ stopLocalTracks: true }] - The destroy params. | |
*/ | |
destroy(params = { stopLocalTracks: true }) { | |
const { peerConnection } = this; | |
if (peerConnection) { | |
this.removeEventListenersFromPeerConnection(); | |
if (params.stopLocalTracks) { | |
this.stopLocalStreamTracks(); | |
} | |
peerConnection.close(); | |
this.peerConnection = null; | |
this.isActive = false; | |
socket.off('call-service-message', this.handleCallServiceMessage); | |
} | |
} | |
/** | |
* Handles offer/answer/candidate messages. | |
* if offer is received, set remote description, create answer and send it to other side. | |
* if answer is received, set remote description | |
* if candidate is received, add it to peer connection. | |
* @param {Object} message - The message containing sdp or candidate. | |
*/ | |
async handleCallServiceMessage(message) { | |
if (message.peerConnectionId !== this.peerConnectionId) return; | |
const { | |
description, | |
candidate, | |
} = message; | |
const { | |
peerConnection, | |
} = this; | |
try { | |
if (description) { | |
const readyForOffer = !this.makingOffer | |
&& ( | |
peerConnection.signalingState === 'stable' | |
|| this.isSettingRemoteAnswerPending | |
); | |
const offerCollision = description.type === 'offer' && !readyForOffer; | |
this.ignoreOffer = !this.polite && offerCollision; | |
if (this.ignoreOffer) return; | |
this.isSettingRemoteAnswerPending = description.type === 'answer'; | |
if (offerCollision) { | |
await Promise.all([ | |
peerConnection.setLocalDescription({ type: 'rollback' }), | |
peerConnection.setRemoteDescription(description), | |
]); | |
} else { | |
await peerConnection.setRemoteDescription(description); | |
} | |
this.isSettingRemoteAnswerPending = false; | |
if (description.type === 'offer') { | |
const answer = await peerConnection.createAnswer(); | |
await peerConnection.setLocalDescription(answer); | |
socket.emit('call-service-message', { | |
to: this.participantSocketId, | |
peerConnectionId: this.peerConnectionId, | |
description: peerConnection.localDescription, | |
}); | |
} | |
} else if (candidate) { | |
try { | |
await peerConnection.addIceCandidate(candidate); | |
} catch (error) { | |
if (!this.ignoreOffer) throw error; | |
} | |
} | |
} catch (error) { | |
// eslint-disable-next-line no-console | |
console.error(error); | |
} | |
} | |
/** | |
* Initializes the peer connection. | |
*/ | |
initializeConnection() { | |
try { | |
this.createPeerConnection(); | |
this.attachEventListenersToPeerConnection(); | |
this.addTracksFromLocalStreamToPeerConnection(); | |
} catch (error) { | |
// eslint-disable-next-line no-console | |
console.error(error); | |
} | |
} | |
/** | |
* Creates peer connection | |
* @returns {RTCPeerConnection} instance of RTCPeerConnection. | |
*/ | |
createPeerConnection() { | |
const config = { | |
iceServers: [ | |
{ | |
urls: ['stun:turn2.l.google.com'], | |
}, | |
{ | |
urls: [`turn:${process.env.REACT_APP_OA_TURN_DOMAIN}`], | |
credential: process.env.REACT_APP_OA_TURN_PASSWORD, | |
username: process.env.REACT_APP_OA_TURN_USERNAME, | |
}, | |
], | |
}; | |
const peerConnection = new RTCPeerConnection(config); | |
this.peerConnection = peerConnection; | |
return peerConnection; | |
} | |
/** | |
* Attaches event listeners to peer connection. | |
*/ | |
attachEventListenersToPeerConnection() { | |
const { peerConnection } = this; | |
peerConnection.onnegotiationneeded = this.handleNegotiationNeededEvent.bind(this); | |
peerConnection.onicecandidate = this.handleICECandidateEvent.bind(this); | |
peerConnection.ontrack = this.handleTrackEvent.bind(this); | |
peerConnection.oniceconnectionstatechange = this.handleICEConnectionStateChangeEvent.bind(this); | |
} | |
/** | |
* Removes event listeners from peer connection | |
* (useful on destroy peer connection). | |
*/ | |
removeEventListenersFromPeerConnection() { | |
const { peerConnection } = this; | |
peerConnection.onnegotiationneeded = null; | |
peerConnection.onicecandidate = null; | |
peerConnection.ontrack = null; | |
peerConnection.oniceconnectionstatechange = null; | |
} | |
/** | |
* Attaches local media tracks to peer connection. | |
*/ | |
addTracksFromLocalStreamToPeerConnection() { | |
const { | |
localStream, | |
peerConnection, | |
} = this; | |
localStream.getTracks().forEach((track) => { | |
peerConnection.addTrack(track, localStream); | |
}); | |
} | |
/** | |
* Stop local media tracks of peer connection. | |
*/ | |
stopLocalStreamTracks() { | |
const { | |
localStream, | |
} = this; | |
localStream.getTracks().forEach((track) => track.stop()); | |
} | |
/** | |
* Replaces old track of peer connection with a new one. | |
* @param {MediaStreamTrack} track - The audio/video track to replace old track. | |
*/ | |
replaceTrackForPeerConnection(track) { | |
const { peerConnection } = this; | |
if (!peerConnection) return; | |
const trackType = track.kind; | |
try { | |
const senders = peerConnection.getSenders(); | |
const desiredSender = senders.find((sender) => ( | |
sender.track.kind === trackType | |
)); | |
if (desiredSender) { | |
desiredSender.replaceTrack(track); | |
} else { | |
throw new Error('Desired sender not found.'); | |
} | |
} catch (error) { | |
// eslint-disable-next-line no-console | |
console.error(error); | |
} | |
} | |
/** | |
* Handles onnegotiationneeded event. | |
* Tries to create offer and send it to other side. | |
* @param {Event} options - useful options used in offer creation. | |
*/ | |
async handleNegotiationNeededEvent(options) { | |
const { | |
peerConnection, | |
skipNegotiation, | |
} = this; | |
if (skipNegotiation) { | |
return; | |
} | |
if (peerConnection.signalingState === 'have-remote-offer') return; | |
if (this.makingOffer) { | |
return; | |
} | |
try { | |
this.makingOffer = true; | |
const offer = await peerConnection.createOffer(options); | |
if (peerConnection.signalingState !== 'have-remote-offer') { | |
await peerConnection.setLocalDescription(offer); | |
socket.emit('call-service-message', { | |
to: this.participantSocketId, | |
peerConnectionId: this.peerConnectionId, | |
description: peerConnection.localDescription, | |
}); | |
} | |
} catch (error) { | |
// eslint-disable-next-line no-console | |
console.error(error); | |
} finally { | |
this.makingOffer = false; | |
} | |
} | |
/** | |
* Handles ontrack event. | |
* If onReceiveRemoteStream is present, call it with received remote stream. | |
* @param {Event} event - Object containing remote stream. | |
*/ | |
handleTrackEvent(event) { | |
const { streams } = event; | |
if (this.onReceiveRemoteStream && streams[0]) { | |
this.onReceiveRemoteStream({ | |
stream: streams[0], | |
participantSocketId: this.participantSocketId, | |
}); | |
} | |
} | |
/** | |
* Handles icecandidate event. | |
* Sends ICE candidates to other side using signaling server. | |
* @param {Event} event - Object containing candidate. | |
*/ | |
handleICECandidateEvent(event) { | |
const { candidate } = event; | |
if (candidate) { | |
socket.emit('call-service-message', { | |
to: this.participantSocketId, | |
peerConnectionId: this.peerConnectionId, | |
candidate, | |
}); | |
} | |
} | |
/** | |
* Listens for connection state change, try to restart if connection fails. | |
*/ | |
handleICEConnectionStateChangeEvent() { | |
const { peerConnection } = this; | |
switch (peerConnection.iceConnectionState) { | |
case 'failed': | |
// eslint-disable-next-line no-console | |
console.error('iceConnectionState is failed.'); | |
if (peerConnection.restartIce) { | |
peerConnection.restartIce(); | |
} else { | |
peerConnection.onnegotiationneeded({ | |
iceRestart: true, | |
}); | |
} | |
break; | |
default: | |
break; | |
} | |
} | |
} | |
export default MeetingConnection; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment