Skip to content

Commit

Permalink
Merge pull request #345 from CodeCrowCorp/dev
Browse files Browse the repository at this point in the history
Dev
  • Loading branch information
gagansuie authored Mar 24, 2023
2 parents 4b3f25d + 08d058f commit ddda8b9
Show file tree
Hide file tree
Showing 4 changed files with 298 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .github/codecov.yml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
fixes:
- "::/mage-website"
- "/home/runner/work/mage-website/::"
79 changes: 79 additions & 0 deletions src/lib/WHEPClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import negotiateConnectionWithClientOffer from '$lib/negotiateConnectionWithClientOffer'

/**
* Example implementation of a client that uses WHEP to playback video over WebRTC
*
* https://www.ietf.org/id/draft-murillo-whep-00.html
*/
export default class WHEPClient {
private peerConnection: RTCPeerConnection
private stream: MediaStream

constructor(private endpoint: string, private videoElement: HTMLVideoElement) {
this.stream = new MediaStream()

/**
* Create a new WebRTC connection, using public STUN servers with ICE,
* allowing the client to disover its own IP address.
* https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Protocols#ice
*/
this.peerConnection = new RTCPeerConnection({
iceServers: [
{
urls: 'stun:stun.cloudflare.com:3478'
}
],
bundlePolicy: 'max-bundle'
})

/** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/addTransceiver */
this.peerConnection.addTransceiver('video', {
direction: 'recvonly'
})
this.peerConnection.addTransceiver('audio', {
direction: 'recvonly'
})

/**
* When new tracks are received in the connection, store local references,
* so that they can be added to a MediaStream, and to the <video> element.
*
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/track_event
*/
this.peerConnection.ontrack = (event) => {
const track = event.track
const currentTracks = this.stream.getTracks()
const streamAlreadyHasVideoTrack = currentTracks.some((track) => track.kind === 'video')
const streamAlreadyHasAudioTrack = currentTracks.some((track) => track.kind === 'audio')
switch (track.kind) {
case 'video':
if (streamAlreadyHasVideoTrack) {
break
}
this.stream.addTrack(track)
break
case 'audio':
if (streamAlreadyHasAudioTrack) {
break
}
this.stream.addTrack(track)
break
default:
console.log('got unknown track ' + track)
}
}

this.peerConnection.addEventListener('connectionstatechange', (ev) => {
if (this.peerConnection.connectionState !== 'connected') {
return
}
if (!this.videoElement.srcObject) {
this.videoElement.srcObject = this.stream
}
})

this.peerConnection.addEventListener('negotiationneeded', (ev) => {
negotiateConnectionWithClientOffer(this.peerConnection, this.endpoint)
})
}
}
134 changes: 134 additions & 0 deletions src/lib/WHIPClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import negotiateConnectionWithClientOffer from '$lib/negotiateConnectionWithClientOffer'

/**
* Example implementation of a client that uses WHIP to broadcast video over WebRTC
*
* https://www.ietf.org/archive/id/draft-ietf-wish-whip-01.html
*/
export default class WHIPClient {
private peerConnection: RTCPeerConnection
private localStream?: MediaStream

constructor(private endpoint: string, private videoElement: HTMLVideoElement, trackType: string) {
/**
* Create a new WebRTC connection, using public STUN servers with ICE,
* allowing the client to disover its own IP address.
* https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Protocols#ice
*/
this.peerConnection = new RTCPeerConnection({
iceServers: [
{
urls: 'stun:stun.cloudflare.com:3478'
}
],
bundlePolicy: 'max-bundle'
})

/**
* Listen for negotiationneeded events, and use WHIP as the signaling protocol to establish a connection
*
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/negotiationneeded_event
* https://www.ietf.org/archive/id/draft-ietf-wish-whip-01.html
*/
this.peerConnection.addEventListener('negotiationneeded', async (ev) => {
console.log('Connection negotiation starting')
await negotiateConnectionWithClientOffer(this.peerConnection, this.endpoint)
console.log('Connection negotiation ended')
})

/**
* While the connection is being initialized,
* connect the video stream to the provided <video> element.
*/
this.accessLocalMediaSources(trackType)
.then((stream) => {
this.localStream = stream
videoElement.srcObject = stream as MediaProvider
})
.catch(console.error)
}

/**
* Ask for camera and microphone permissions and
* add video and audio tracks to the peerConnection.
*
* https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
*/
private async accessLocalMediaSources(trackType: string) {
if (trackType === 'screen') {
return navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }).then((stream) => {
stream.getTracks().forEach((track) => {
const transceiver = this.peerConnection.addTransceiver(track, {
/** WHIP is only for sending streaming media */
direction: 'sendonly'
})
if (track.kind == 'video' && transceiver.sender.track) {
transceiver.sender.track.applyConstraints({
width: 1280,
height: 720
})
}
})
return stream
})
} else if (trackType === 'webcam') {
return navigator.mediaDevices.getUserMedia({ video: true, audio: true }).then((stream) => {
stream.getTracks().forEach((track) => {
const transceiver = this.peerConnection.addTransceiver(track, {
/** WHIP is only for sending streaming media */
direction: 'sendonly'
})
if (track.kind == 'video' && transceiver.sender.track) {
transceiver.sender.track.applyConstraints({
width: 1280,
height: 720
})
}
})
return stream
})
} else if (trackType === 'audio') {
return navigator.mediaDevices
.getUserMedia({
video: false,
audio: {
echoCancellation: true,
noiseSuppression: true,
deviceId: 'default'
}
})
.then((stream) => {
stream.getTracks().forEach((track) => {
const transceiver = this.peerConnection.addTransceiver(track, {
/** WHIP is only for sending streaming media */
direction: 'sendonly'
})
if (track.kind == 'video' && transceiver.sender.track) {
transceiver.sender.track.applyConstraints({
width: 1280,
height: 720
})
}
})
return stream
})
}
}

/**
* Terminate the streaming session
* 1. Notify the WHIP server by sending a DELETE request
* 2. Close the WebRTC connection
* 3. Stop using the local camera and microphone
*
* Note that once you call this method, this instance of this WHIPClient cannot be reused.
*/
public async disconnectStream() {
const response = await fetch(this.endpoint, {
method: 'DELETE',
mode: 'cors'
})
this.peerConnection.close()
this.localStream?.getTracks().forEach((track) => track.stop())
}
}
84 changes: 84 additions & 0 deletions src/lib/negotiateConnectionWithClientOffer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Performs the actual SDP exchange.
*
* 1. Constructs the client's SDP offer
* 2. Sends the SDP offer to the server,
* 3. Awaits the server's offer.
*
* SDP describes what kind of media we can send and how the server and client communicate.
*
* https://developer.mozilla.org/en-US/docs/Glossary/SDP
* https://www.ietf.org/archive/id/draft-ietf-wish-whip-01.html#name-protocol-operation
*/
export default async function negotiateConnectionWithClientOffer(
peerConnection: RTCPeerConnection,
endpoint: string
) {
/** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createOffer */
const offer = await peerConnection.createOffer()
/** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/setLocalDescription */
await peerConnection.setLocalDescription(offer)

/** Wait for ICE gathering to complete */
const ofr = await waitToCompleteICEGathering(peerConnection)
if (!ofr) {
throw Error('failed to gather ICE candidates for offer')
}

/**
* As long as the connection is open, attempt to...
*/
while (peerConnection.connectionState !== 'closed') {
/**
* This response contains the server's SDP offer.
* This specifies how the client should communicate,
* and what kind of media client and server have negotiated to exchange.
*/
const response = await postSDPOffer(endpoint, ofr.sdp)
if (response.status === 201) {
const answerSDP = await response.text()
await peerConnection.setRemoteDescription(
new RTCSessionDescription({ type: 'answer', sdp: answerSDP })
)
return response.headers.get('Location')
} else if (response.status === 405) {
console.log('Remember to update the URL passed into the WHIP or WHEP client')
} else {
const errorMessage = await response.text()
console.error(errorMessage)
}

/** Limit reconnection attempts to at-most once every 5 seconds */
await new Promise((r) => setTimeout(r, 5000))
}
}

async function postSDPOffer(endpoint: string, data: string) {
return await fetch(endpoint, {
method: 'POST',
mode: 'cors',
headers: {
'content-type': 'application/sdp'
},
body: data
})
}

/**
* Receives an RTCPeerConnection and waits until
* the connection is initialized or a timeout passes.
*
* https://www.ietf.org/archive/id/draft-ietf-wish-whip-01.html#section-4.1
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/iceGatheringState
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/icegatheringstatechange_event
*/
async function waitToCompleteICEGathering(peerConnection: RTCPeerConnection) {
return new Promise<RTCSessionDescription | null>((resolve) => {
/** Wait at most 1 second for ICE gathering. */
setTimeout(function () {
resolve(peerConnection.localDescription)
}, 1000)
peerConnection.onicegatheringstatechange = (ev) =>
peerConnection.iceGatheringState === 'complete' && resolve(peerConnection.localDescription)
})
}

0 comments on commit ddda8b9

Please sign in to comment.