UNPKG

simple-webrtc-element-whep

Version:
539 lines (466 loc) 15.2 kB
export default class WHEPClient { retryPause = 2000 video: HTMLVideoElement nonAdvertisedCodecs: string[] = [] pc: RTCPeerConnection | null = null restartTimeout: number | null = null sessionUrl = '' offerData: | { iceUfrag: string icePwd: string medias: string[] } | undefined queuedCandidates: RTCIceCandidate[] = [] defaultControls = false onOnline: () => void onOffline: () => void online = false whepUri: string constructor(options: { videoElement: HTMLVideoElement whepUri: string controls?: boolean muted?: boolean autoplay?: boolean playsInline?: boolean onOnline?: () => void onOffline?: () => void }) { 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() } unquoteCredential = (v: string) => JSON.parse(`"${v}"`) linkToIceServers = (links: string) => 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: string[] username?: string credential?: string credentialType?: string } = { urls: [m[1]] } if (m[3] !== undefined) { ret.username = this.unquoteCredential(m[3]) ret.credential = this.unquoteCredential(m[4]) ret.credentialType = 'password' } return ret }) : [] parseOffer = (sdp: string) => { const ret: { iceUfrag: string icePwd: string medias: string[] } = { 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 } enableStereoPcmau = (section: string) => { 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') } enableMultichannelOpus = (section: string) => { 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') } enableL16 = (section: string) => { 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') } enableStereoOpus = (section: string) => { 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') } editOffer = (sdp: string) => { 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=') } generateSdpFragment = (od: any, candidates: any) => { const candidatesByMedia: any = {} 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 } loadStream = () => { this.requestICEServers() } supportsNonAdvertisedCodec = (codec: string, fmtp: string) => 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() }) }) 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 as string[] this.loadStream() }) } onError = (err: Error) => { 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 = [] } } sendLocalCandidates(candidates: any) { 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: any) { if (this.restartTimeout !== null) { return } if (evt.candidate !== null) { if (this.sessionUrl === '') { this.queuedCandidates.push(evt.candidate) } else { this.sendLocalCandidates([evt.candidate]) } } } onRemoteAnswer(sdp: string) { 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: any) { 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') as string, this.whepUri.replace('/whep', '/')).toString() console.log('session', this.sessionUrl) return res.text().then(sdp => this.onRemoteAnswer(sdp)) }) .catch(err => { this.onError(err.toString()) }) } createOffer = () => { if (this.pc) { this.pc .createOffer() .then(offer => { offer.sdp = this.editOffer(offer.sdp as string) 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()) }) } } onConnectionState = () => { if (this.restartTimeout !== null) { return } if (this.pc?.iceConnectionState === 'disconnected') { this.onError(new Error('peer connection closed')) } } onTrack = (evt: RTCTrackEvent) => { this.video.srcObject = evt.streams[0] this.onOnline() this.online = true } requestICEServers = () => { fetch(this.whepUri, { method: 'OPTIONS' }) .then(res => { this.pc = new RTCPeerConnection({ iceServers: this.linkToIceServers(res.headers.get('Link') as string) as RTCIceServer[], // 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()) }) } init = () => { this.getNonAdvertisedCodecs() } close() { this.pc ? this.pc.close() : null this.pc = null if (this.sessionUrl) { fetch(this.sessionUrl, { method: 'DELETE' }) } } }