simple-webrtc-element-whep
Version:
Simple Webrtc Html Video Element Manager
539 lines (466 loc) • 15.2 kB
text/typescript
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'
})
}
}
}