UNPKG

simple-webrtc-element-whep

Version:
434 lines 17.8 kB
export default class WHEPClient { constructor(options) { this.retryPause = 2000; this.nonAdvertisedCodecs = []; this.pc = null; this.restartTimeout = null; this.sessionUrl = ''; this.queuedCandidates = []; this.defaultControls = false; this.online = false; this.unquoteCredential = (v) => JSON.parse(`"${v}"`); this.linkToIceServers = (links) => links !== null ? links.split(', ').map(link => { const m = link.match(/^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i); if (m === null) { return null; } const ret = { urls: [m[1]] }; if (m[3] !== undefined) { ret.username = this.unquoteCredential(m[3]); ret.credential = this.unquoteCredential(m[4]); ret.credentialType = 'password'; } return ret; }) : []; this.parseOffer = (sdp) => { const ret = { iceUfrag: '', icePwd: '', medias: [] }; for (const line of sdp.split('\r\n')) { if (line.startsWith('m=')) { ret.medias.push(line.slice('m='.length)); } else if (ret.iceUfrag === '' && line.startsWith('a=ice-ufrag:')) { ret.iceUfrag = line.slice('a=ice-ufrag:'.length); } else if (ret.icePwd === '' && line.startsWith('a=ice-pwd:')) { ret.icePwd = line.slice('a=ice-pwd:'.length); } } return ret; }; this.enableStereoPcmau = (section) => { let lines = section.split('\r\n'); lines[0] += ' 118'; lines.splice(lines.length - 1, 0, 'a=rtpmap:118 PCMU/8000/2'); lines.splice(lines.length - 1, 0, 'a=rtcp-fb:118 transport-cc'); lines[0] += ' 119'; lines.splice(lines.length - 1, 0, 'a=rtpmap:119 PCMA/8000/2'); lines.splice(lines.length - 1, 0, 'a=rtcp-fb:119 transport-cc'); return lines.join('\r\n'); }; this.enableMultichannelOpus = (section) => { let lines = section.split('\r\n'); lines[0] += ' 112'; lines.splice(lines.length - 1, 0, 'a=rtpmap:112 multiopus/48000/3'); lines.splice(lines.length - 1, 0, 'a=fmtp:112 channel_mapping=0,2,1;num_streams=2;coupled_streams=1'); lines.splice(lines.length - 1, 0, 'a=rtcp-fb:112 transport-cc'); lines[0] += ' 113'; lines.splice(lines.length - 1, 0, 'a=rtpmap:113 multiopus/48000/4'); lines.splice(lines.length - 1, 0, 'a=fmtp:113 channel_mapping=0,1,2,3;num_streams=2;coupled_streams=2'); lines.splice(lines.length - 1, 0, 'a=rtcp-fb:113 transport-cc'); lines[0] += ' 114'; lines.splice(lines.length - 1, 0, 'a=rtpmap:114 multiopus/48000/5'); lines.splice(lines.length - 1, 0, 'a=fmtp:114 channel_mapping=0,4,1,2,3;num_streams=3;coupled_streams=2'); lines.splice(lines.length - 1, 0, 'a=rtcp-fb:114 transport-cc'); lines[0] += ' 115'; lines.splice(lines.length - 1, 0, 'a=rtpmap:115 multiopus/48000/6'); lines.splice(lines.length - 1, 0, 'a=fmtp:115 channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2'); lines.splice(lines.length - 1, 0, 'a=rtcp-fb:115 transport-cc'); lines[0] += ' 116'; lines.splice(lines.length - 1, 0, 'a=rtpmap:116 multiopus/48000/7'); lines.splice(lines.length - 1, 0, 'a=fmtp:116 channel_mapping=0,4,1,2,3,5,6;num_streams=4;coupled_streams=4'); lines.splice(lines.length - 1, 0, 'a=rtcp-fb:116 transport-cc'); lines[0] += ' 117'; lines.splice(lines.length - 1, 0, 'a=rtpmap:117 multiopus/48000/8'); lines.splice(lines.length - 1, 0, 'a=fmtp:117 channel_mapping=0,6,1,4,5,2,3,7;num_streams=5;coupled_streams=4'); lines.splice(lines.length - 1, 0, 'a=rtcp-fb:117 transport-cc'); return lines.join('\r\n'); }; this.enableL16 = (section) => { let lines = section.split('\r\n'); lines[0] += ' 120'; lines.splice(lines.length - 1, 0, 'a=rtpmap:120 L16/8000/2'); lines.splice(lines.length - 1, 0, 'a=rtcp-fb:120 transport-cc'); lines[0] += ' 121'; lines.splice(lines.length - 1, 0, 'a=rtpmap:121 L16/16000/2'); lines.splice(lines.length - 1, 0, 'a=rtcp-fb:121 transport-cc'); lines[0] += ' 122'; lines.splice(lines.length - 1, 0, 'a=rtpmap:122 L16/48000/2'); lines.splice(lines.length - 1, 0, 'a=rtcp-fb:122 transport-cc'); return lines.join('\r\n'); }; this.enableStereoOpus = (section) => { let opusPayloadFormat = ''; let lines = section.split('\r\n'); for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('a=rtpmap:') && lines[i].toLowerCase().includes('opus/')) { opusPayloadFormat = lines[i].slice('a=rtpmap:'.length).split(' ')[0]; break; } } if (opusPayloadFormat === '') { return section; } for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('a=fmtp:' + opusPayloadFormat + ' ')) { if (!lines[i].includes('stereo')) { lines[i] += ';stereo=1'; } if (!lines[i].includes('sprop-stereo')) { lines[i] += ';sprop-stereo=1'; } } } return lines.join('\r\n'); }; this.editOffer = (sdp) => { const sections = sdp.split('m='); for (let i = 0; i < sections.length; i++) { if (sections[i].startsWith('audio')) { sections[i] = this.enableStereoOpus(sections[i]); if (this.nonAdvertisedCodecs.includes('pcma/8000/2')) { sections[i] = this.enableStereoPcmau(sections[i]); } if (this.nonAdvertisedCodecs.includes('multiopus/48000/6')) { sections[i] = this.enableMultichannelOpus(sections[i]); } if (this.nonAdvertisedCodecs.includes('L16/48000/2')) { sections[i] = this.enableL16(sections[i]); } break; } } return sections.join('m='); }; this.generateSdpFragment = (od, candidates) => { const candidatesByMedia = {}; for (const candidate of candidates) { const mid = candidate.sdpMLineIndex; if (candidatesByMedia[mid] === undefined) { candidatesByMedia[mid] = []; } candidatesByMedia[mid].push(candidate); } let frag = 'a=ice-ufrag:' + od.iceUfrag + '\r\n' + 'a=ice-pwd:' + od.icePwd + '\r\n'; let mid = 0; for (const media of od.medias) { if (candidatesByMedia[mid] !== undefined) { frag += 'm=' + media + '\r\n' + 'a=mid:' + mid + '\r\n'; for (const candidate of candidatesByMedia[mid]) { frag += 'a=' + candidate.candidate + '\r\n'; } } mid++; } return frag; }; this.loadStream = () => { this.requestICEServers(); }; this.supportsNonAdvertisedCodec = (codec, fmtp) => new Promise((resolve, reject) => { const pc = new RTCPeerConnection({ iceServers: [] }); pc.addTransceiver('audio', { direction: 'recvonly' }); pc.createOffer() .then(offer => { if (offer.sdp === undefined) { resolve(false); return; } if (offer.sdp.includes(' ' + codec)) { // codec is advertised, there's no need to add it manually resolve(false); return; } const sections = offer.sdp.split('m=audio'); const lines = sections[1].split('\r\n'); lines[0] += ' 118'; lines.splice(lines.length - 1, 0, 'a=rtpmap:118 ' + codec); if (fmtp !== undefined) { lines.splice(lines.length - 1, 0, 'a=fmtp:118 ' + fmtp); } sections[1] = lines.join('\r\n'); offer.sdp = sections.join('m=audio'); return pc.setLocalDescription(offer); }) .then(() => { return pc.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: 'v=0\r\n' + 'o=- 6539324223450680508 0 IN IP4 0.0.0.0\r\n' + 's=-\r\n' + 't=0 0\r\n' + 'a=fingerprint:sha-256 0D:9F:78:15:42:B5:4B:E6:E2:94:3E:5B:37:78:E1:4B:54:59:A3:36:3A:E5:05:EB:27:EE:8F:D2:2D:41:29:25\r\n' + 'm=audio 9 UDP/TLS/RTP/SAVPF 118\r\n' + 'c=IN IP4 0.0.0.0\r\n' + 'a=ice-pwd:7c3bf4770007e7432ee4ea4d697db675\r\n' + 'a=ice-ufrag:29e036dc\r\n' + 'a=sendonly\r\n' + 'a=rtcp-mux\r\n' + 'a=rtpmap:118 ' + codec + '\r\n' + (fmtp !== undefined ? 'a=fmtp:118 ' + fmtp + '\r\n' : '') })); }) .then(() => { resolve(true); }) .catch(err => { resolve(false); }) .finally(() => { pc.close(); }); }); this.getNonAdvertisedCodecs = () => { Promise.all([ ['pcma/8000/2'], ['multiopus/48000/6', 'channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2'], ['L16/48000/2'] ].map(c => this.supportsNonAdvertisedCodec(c[0], c[1]).then(r => (r ? c[0] : false)))) .then(c => c.filter(e => e !== false)) .then(codecs => { this.nonAdvertisedCodecs = codecs; this.loadStream(); }); }; this.onError = (err) => { console.error('whep stream error', err); this.onOffline(); this.online = false; if (this.restartTimeout === null) { if (this.pc !== null) { this.pc.close(); this.pc = null; } this.restartTimeout = setTimeout(() => { this.restartTimeout = null; this.loadStream(); }, this.retryPause); if (this.sessionUrl) { fetch(this.sessionUrl, { method: 'DELETE' }); } this.sessionUrl = ''; this.queuedCandidates = []; } }; this.createOffer = () => { if (this.pc) { this.pc .createOffer() .then(offer => { offer.sdp = this.editOffer(offer.sdp); this.offerData = this.parseOffer(offer.sdp); if (this.pc) this.pc .setLocalDescription(offer) .then(() => { this.sendOffer(offer); }) .catch(err => { this.onError(err.toString()); }); }) .catch(err => { this.onError(err.toString()); }); } }; this.onConnectionState = () => { if (this.restartTimeout !== null) { return; } if (this.pc?.iceConnectionState === 'disconnected') { this.onError(new Error('peer connection closed')); } }; this.onTrack = (evt) => { this.video.srcObject = evt.streams[0]; this.onOnline(); this.online = true; }; this.requestICEServers = () => { fetch(this.whepUri, { method: 'OPTIONS' }) .then(res => { this.pc = new RTCPeerConnection({ iceServers: this.linkToIceServers(res.headers.get('Link')), // https://webrtc.org/getting-started/unified-plan-transition-guide // @ts-ignore sdpSemantics: 'unified-plan' }); const direction = 'sendrecv'; this.pc.addTransceiver('video', { direction }); this.pc.addTransceiver('audio', { direction }); this.pc.onicecandidate = evt => this.onLocalCandidate(evt); this.pc.oniceconnectionstatechange = () => this.onConnectionState(); this.pc.ontrack = evt => this.onTrack(evt); this.createOffer(); }) .catch(err => { this.onError(err.toString()); }); }; this.init = () => { this.getNonAdvertisedCodecs(); }; this.video = options.videoElement; this.whepUri = options.whepUri; if (options.controls !== false) this.video.controls = true; if (options.muted !== false) this.video.muted = true; if (options.autoplay !== true) this.video.autoplay = true; if (options.playsInline !== true) this.video.playsInline = true; this.defaultControls = this.video.controls; this.onOnline = options.onOnline || (() => { console.log('online'); }); this.onOffline = options.onOffline || (() => { console.log('offline'); }); this.init(); } sendLocalCandidates(candidates) { fetch(this.sessionUrl, { method: 'PATCH', headers: { 'Content-Type': 'application/trickle-ice-sdpfrag', 'If-Match': '*' }, body: this.generateSdpFragment(this.offerData, candidates) }) .then(res => { switch (res.status) { case 204: break; case 404: throw new Error('stream not found'); default: throw new Error(`bad status code ${res.status}`); } }) .catch(err => { this.onError(err.toString()); }); } onLocalCandidate(evt) { if (this.restartTimeout !== null) { return; } if (evt.candidate !== null) { if (this.sessionUrl === '') { this.queuedCandidates.push(evt.candidate); } else { this.sendLocalCandidates([evt.candidate]); } } } onRemoteAnswer(sdp) { if (this.restartTimeout !== null || this.pc === null) { return; } this.pc .setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp })) .then(() => { if (this.queuedCandidates.length !== 0) { this.sendLocalCandidates(this.queuedCandidates); this.queuedCandidates = []; } }) .catch(err => { this.onError(err.toString()); }); } sendOffer(offer) { fetch(this.whepUri, { method: 'POST', headers: { 'Content-Type': 'application/sdp' }, body: offer.sdp }) .then(res => { switch (res.status) { case 201: break; case 404: throw new Error('stream not found'); case 400: return res.json().then(e => { throw new Error(e.error); }); default: throw new Error(`bad status code ${res.status}`); } this.sessionUrl = new URL(res.headers.get('location'), this.whepUri.replace('/whep', '/')).toString(); console.log('session', this.sessionUrl); return res.text().then(sdp => this.onRemoteAnswer(sdp)); }) .catch(err => { this.onError(err.toString()); }); } close() { this.pc ? this.pc.close() : null; this.pc = null; if (this.sessionUrl) { fetch(this.sessionUrl, { method: 'DELETE' }); } } } //# sourceMappingURL=index.js.map