whepts 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/errors.d.ts CHANGED
@@ -8,7 +8,9 @@ export type ErrorType = string;
8
8
  export declare const ErrorTypes: {
9
9
  SIGNAL_ERROR: string;
10
10
  STATE_ERROR: string;
11
- NETWORK_ERROR: string;
11
+ REQUEST_ERROR: string;
12
+ NOT_FOUND_ERROR: string;
13
+ CONNECT_ERROR: string;
12
14
  MEDIA_ERROR: string;
13
15
  OTHER_ERROR: string;
14
16
  };
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- const e={SIGNAL_ERROR:"SignalError",STATE_ERROR:"StateError",NETWORK_ERROR:"NetworkError",MEDIA_ERROR:"MediaError",OTHER_ERROR:"OtherError"};class t extends Error{constructor(e,t,s){super(t,s),this.type=e}}class s{static async supportsNonAdvertisedCodec(e,t){return new Promise(n=>{const r=new RTCPeerConnection({iceServers:[]}),i="audio";let a="";r.addTransceiver(i,{direction:"recvonly"}),r.createOffer().then(n=>{if(!n.sdp)throw new Error("SDP not present");if(n.sdp.includes(` ${e}`))throw new Error("already present");const c=n.sdp.split(`m=${i}`),o=c.slice(1).map(e=>e.split("\r\n")[0].split(" ").slice(3)).reduce((e,t)=>[...e,...t],[]);a=s.reservePayloadType(o);const h=c[1].split("\r\n");return h[0]+=` ${a}`,h.splice(h.length-1,0,`a=rtpmap:${a} ${e}`),void 0!==t&&h.splice(h.length-1,0,`a=fmtp:${a} ${t}`),c[1]=h.join("\r\n"),n.sdp=c.join(`m=${i}`),r.setLocalDescription(n)}).then(()=>r.setRemoteDescription(new RTCSessionDescription({type:"answer",sdp:`v=0\r\no=- 6539324223450680508 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=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\nm=${i} 9 UDP/TLS/RTP/SAVPF ${a}\r\nc=IN IP4 0.0.0.0\r\na=ice-pwd:7c3bf4770007e7432ee4ea4d697db675\r\na=ice-ufrag:29e036dc\r\na=sendonly\r\na=rtcp-mux\r\na=rtpmap:${a} ${e}\r\n${void 0!==t?`a=fmtp:${a} ${t}\r\n`:""}`}))).then(()=>n(!0)).catch(()=>n(!1)).finally(()=>r.close())})}static unquoteCredential(e){return JSON.parse(`"${e}"`)}static linkToIceServers(n){return n?n.split(", ").map(n=>{const r=n.match(/^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i);if(!r)throw new t(e.SIGNAL_ERROR,"Invalid ICE server link format");const i={urls:[r[1]]};return r[3]&&(i.username=s.unquoteCredential(r[3]),i.credential=s.unquoteCredential(r[4]),i.credentialType="password"),i}):[]}static reservePayloadType(e){for(let t=30;t<=127;t++)if((t<=63||t>=96)&&!e.includes(t.toString())){const s=t.toString();return e.push(s),s}throw new Error("unable to find a free payload type")}}class n{constructor(e,t){this.getState=e,this.callbacks=t}detect(){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(e=>s.supportsNonAdvertisedCodec(e[0],e[1]).then(t=>!!t&&e[0]))).then(e=>e.filter(e=>!1!==e)).then(s=>{if("getting_codecs"!==this.getState())throw new t(e.STATE_ERROR,"closed");this.callbacks.onCodecsDetected(s)}).catch(e=>this.callbacks.onError(e))}}class r{static parseOffer(e){const t={iceUfrag:"",icePwd:"",medias:[]};for(const s of e.split("\r\n"))s.startsWith("m=")?t.medias.push(s.slice(2)):""===t.iceUfrag&&s.startsWith("a=ice-ufrag:")?t.iceUfrag=s.slice(12):""===t.icePwd&&s.startsWith("a=ice-pwd:")&&(t.icePwd=s.slice(10));return t}static reservePayloadType(e){for(let t=30;t<=127;t++)if((t<=63||t>=96)&&!e.includes(t.toString())){const s=t.toString();return e.push(s),s}throw new Error("unable to find a free payload type")}static enableStereoPcmau(e,t){const s=t.split("\r\n");let n=r.reservePayloadType(e);return s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} PCMU/8000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} PCMA/8000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),s.join("\r\n")}static enableMultichannelOpus(e,t){const s=t.split("\r\n");let n=r.reservePayloadType(e);return s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/3`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,2,1;num_streams=2;coupled_streams=1`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/4`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,1,2,3;num_streams=2;coupled_streams=2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/5`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,4,1,2,3;num_streams=3;coupled_streams=2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/6`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/7`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,4,1,2,3,5,6;num_streams=4;coupled_streams=4`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/8`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,6,1,4,5,2,3,7;num_streams=5;coupled_streams=4`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),s.join("\r\n")}static enableL16(e,t){const s=t.split("\r\n");let n=r.reservePayloadType(e);return s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} L16/8000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} L16/16000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} L16/48000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),s.join("\r\n")}static enableStereoOpus(e){let t="";const s=e.split("\r\n");for(let e=0;e<s.length;e++)if(s[e].startsWith("a=rtpmap:")&&s[e].toLowerCase().includes("opus/")){t=s[e].slice(9).split(" ")[0];break}if(""===t)return e;for(let e=0;e<s.length;e++)s[e].startsWith(`a=fmtp:${t} `)&&(s[e].includes("stereo")||(s[e]+=";stereo=1"),s[e].includes("sprop-stereo")||(s[e]+=";sprop-stereo=1"));return s.join("\r\n")}static editOffer(e,t){const s=e.split("m="),n=s.slice(1).map(e=>e.split("\r\n")[0].split(" ").slice(3)).reduce((e,t)=>[...e,...t],[]);for(let e=1;e<s.length;e++)if(s[e].startsWith("audio")){s[e]=r.enableStereoOpus(s[e]),t.includes("pcma/8000/2")&&(s[e]=r.enableStereoPcmau(n,s[e])),t.includes("multiopus/48000/6")&&(s[e]=r.enableMultichannelOpus(n,s[e])),t.includes("L16/48000/2")&&(s[e]=r.enableL16(n,s[e]));break}return s.join("m=")}static generateSdpFragment(e,t){const s={};for(const e of t){const t=e.sdpMLineIndex;t&&(void 0===s[t]&&(s[t]=[]),s[t].push(e))}let n=`a=ice-ufrag:${e.iceUfrag}\r\na=ice-pwd:${e.icePwd}\r\n`,r=0;for(const t of e.medias){if(void 0!==s[r]){n+=`m=${t}\r\na=mid:${r}\r\n`;for(const e of s[r])n+=`a=${e.candidate}\r\n`}r++}return n}}class i{constructor(e,t,s=[]){this.getState=e,this.callbacks=t,this.nonAdvertisedCodecs=s}async setupPeerConnection(s){if("running"!==this.getState())throw new t(e.STATE_ERROR,"closed");const n=new RTCPeerConnection({iceServers:s,sdpSemantics:"unified-plan"});this.pc=n;const i="recvonly";return n.addTransceiver("video",{direction:i}),n.addTransceiver("audio",{direction:i}),n.onicecandidate=e=>this.onLocalCandidate(e),n.onconnectionstatechange=()=>this.onConnectionState(),n.oniceconnectionstatechange=()=>this.onIceConnectionState(),n.ontrack=e=>this.callbacks.onTrack(e),n.createOffer().then(s=>{if(!s.sdp)throw new t(e.SIGNAL_ERROR,"Failed to create offer SDP");return s.sdp=r.editOffer(s.sdp,this.nonAdvertisedCodecs),this.offerData=r.parseOffer(s.sdp),n.setLocalDescription(s).then(()=>s.sdp)})}async setAnswer(s){if("running"!==this.getState())throw new t(e.STATE_ERROR,"closed");return this.pc.setRemoteDescription(new RTCSessionDescription({type:"answer",sdp:s}))}getPeerConnection(){return this.pc}getOfferData(){return this.offerData}close(){this.pc?.close(),this.pc=void 0,this.offerData=void 0}onLocalCandidate(e){"running"===this.getState()&&e.candidate&&this.callbacks.onCandidate(e.candidate)}onConnectionState(){"running"===this.getState()&&this.pc&&("failed"!==this.pc.connectionState&&"closed"!==this.pc.connectionState||this.callbacks.onError(new t(e.OTHER_ERROR,"peer connection closed")))}onIceConnectionState(){"running"===this.getState()&&this.pc&&"failed"===this.pc.iceConnectionState&&this.pc.restartIce()}}class a{constructor(e,t,s){this.config=e,this.getState=t,this.onError=s}authHeader(){if(this.config.user&&""!==this.config.user){return{Authorization:`Basic ${btoa(`${this.config.user}:${this.config.pass}`)}`}}return this.config.token&&""!==this.config.token?{Authorization:`Bearer ${this.config.token}`}:{}}async requestICEServers(){return this.config.iceServers&&this.config.iceServers.length>0?this.config.iceServers:fetch(this.config.url,{method:"OPTIONS",headers:{...this.authHeader()}}).then(e=>s.linkToIceServers(e.headers.get("Link")))}async sendOffer(s){if("running"!==this.getState())throw new t(e.STATE_ERROR,"closed");return fetch(this.config.url,{method:"POST",headers:{...this.authHeader(),"Content-Type":"application/sdp"},body:s}).then(s=>{switch(s.status){case 201:break;case 404:throw new t(e.NETWORK_ERROR,"stream not found");case 406:throw new t(e.NETWORK_ERROR,"stream not supported");case 400:return s.json().then(s=>{throw new t(e.NETWORK_ERROR,s.error)});default:throw new t(e.NETWORK_ERROR,`bad status code ${s.status}`)}const n=s.headers.get("Location"),r=n?new URL(n,this.config.url).toString():void 0;return s.text().then(e=>({sessionUrl:r,answer:e}))})}sendLocalCandidates(s,n,i){fetch(s,{method:"PATCH",headers:{"Content-Type":"application/trickle-ice-sdpfrag","If-Match":"*"},body:r.generateSdpFragment(n,i)}).then(s=>{switch(s.status){case 204:break;case 404:throw new t(e.NETWORK_ERROR,"stream not found");default:throw new t(e.NETWORK_ERROR,`bad status code ${s.status}`)}}).catch(e=>this.onError(e))}}class c{constructor(e){this.container=e}onTrack(e){this.stream=e.streams[0],this.stopObserver(),this.observer=new IntersectionObserver(([e])=>{e.isIntersecting?this.resume():this.pause()},{threshold:.5}),this.observer.observe(this.container)}stopObserver(){this.observer&&(this.observer.disconnect(),this.observer=void 0)}get paused(){return null===this.container.srcObject}pause(){this.container.srcObject=null}resume(){this.stream&&this.paused&&(this.container.srcObject=this.stream)}stop(){this.stopObserver(),this.stream=void 0}}class o{constructor(e){this.lastBytesReceived=0,this.checkInterval=e.interval,this.onError=e.onError}setPeerConnection(e){this.pc=e}start(){this.stop(),this.checkTimer=setInterval(()=>this.checkFlowState(),this.checkInterval)}stop(){this.checkTimer&&(clearInterval(this.checkTimer),this.checkTimer=void 0)}async checkFlowState(){if(!this.pc)return;const s=await this.pc.getStats();let n=0;s.forEach(e=>{const t=e;"inbound-rtp"===e.type&&"video"===t.kind&&(n=t.bytesReceived||0)}),n!==this.lastBytesReceived||"connected"!==this.pc.connectionState?this.lastBytesReceived=n:this.onError(new t(e.NETWORK_ERROR,"data stream interruption"))}}class h{constructor(e){this.retryPause=2e3,this.queuedCandidates=[],this.nonAdvertisedCodecs=[],this.conf=e,this.state="getting_codecs",this.flowCheck=new o({interval:5e3,onError:e=>this.handleError(e)}),this.httpClient=new a(this.conf,()=>this.state,e=>this.handleError(e)),this.connectionManager=new i(()=>this.state,{onCandidate:e=>this.handleCandidate(e),onTrack:e=>{this.trackManager.onTrack(e),this.flowCheck.start()},onError:e=>this.handleError(e)},this.nonAdvertisedCodecs),this.trackManager=new c(this.conf.container),this.codecDetector=new n(()=>this.state,{onCodecsDetected:e=>this.handleCodecsDetected(e),onError:e=>this.handleError(e)}),this.codecDetector.detect()}get isRunning(){return"running"===this.state}close(){this.state="closed",this.connectionManager.close(),this.trackManager.stop(),this.flowCheck.stop(),this.restartTimeout&&clearTimeout(this.restartTimeout)}handleError(s){this.flowCheck.stop(),"getting_codecs"===this.state||s instanceof t&&s.type===e.SIGNAL_ERROR?this.state="failed":"running"===this.state&&(this.connectionManager.close(),this.queuedCandidates=[],this.sessionUrl&&(fetch(this.sessionUrl,{method:"DELETE"}),this.sessionUrl=void 0),this.state="restarting",this.restartTimeout=setTimeout(()=>{this.restartTimeout=void 0,this.state="running",this.start()},this.retryPause),s.message=`${s.message}, retrying in some seconds`),this.conf.onError&&(s instanceof t?this.conf.onError(s):this.conf.onError(new t(e.OTHER_ERROR,s.message)))}handleCodecsDetected(e){this.nonAdvertisedCodecs=e,this.state="running",this.start()}start(){this.httpClient.requestICEServers().then(e=>this.connectionManager.setupPeerConnection(e)).then(e=>this.httpClient.sendOffer(e)).then(({sessionUrl:e,answer:t})=>this.handleOfferResponse(e,t)).catch(e=>this.handleError(e))}handleOfferResponse(e,t){return e&&(this.sessionUrl=e),this.connectionManager.setAnswer(t).then(()=>{if("running"===this.state&&0!==this.queuedCandidates.length){const e=this.connectionManager.getOfferData();e&&this.sessionUrl&&(this.httpClient.sendLocalCandidates(this.sessionUrl,e,this.queuedCandidates),this.queuedCandidates=[])}})}handleCandidate(e){if(this.sessionUrl){const t=this.connectionManager.getOfferData();t&&this.httpClient.sendLocalCandidates(this.sessionUrl,t,[e])}else this.queuedCandidates.push(e)}get paused(){return this.trackManager.paused}pause(){this.trackManager.pause()}resume(){this.trackManager.resume()}}export{e as ErrorTypes,t as WebRTCError,h as default};
1
+ const e={SIGNAL_ERROR:"SignalError",STATE_ERROR:"StateError",REQUEST_ERROR:"RequestError",NOT_FOUND_ERROR:"NotFoundError",CONNECT_ERROR:"ConnectError",MEDIA_ERROR:"MediaError",OTHER_ERROR:"OtherError"};class t extends Error{constructor(e,t,s){super(t,s),this.type=e}}class s{static async supportsNonAdvertisedCodec(e,t){return new Promise(n=>{const r=new RTCPeerConnection({iceServers:[]}),i="audio";let a="";r.addTransceiver(i,{direction:"recvonly"}),r.createOffer().then(n=>{if(!n.sdp)throw new Error("SDP not present");if(n.sdp.includes(` ${e}`))throw new Error("already present");const c=n.sdp.split(`m=${i}`),o=c.slice(1).map(e=>e.split("\r\n")[0].split(" ").slice(3)).reduce((e,t)=>[...e,...t],[]);a=s.reservePayloadType(o);const h=c[1].split("\r\n");return h[0]+=` ${a}`,h.splice(h.length-1,0,`a=rtpmap:${a} ${e}`),void 0!==t&&h.splice(h.length-1,0,`a=fmtp:${a} ${t}`),c[1]=h.join("\r\n"),n.sdp=c.join(`m=${i}`),r.setLocalDescription(n)}).then(()=>r.setRemoteDescription(new RTCSessionDescription({type:"answer",sdp:`v=0\r\no=- 6539324223450680508 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=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\nm=${i} 9 UDP/TLS/RTP/SAVPF ${a}\r\nc=IN IP4 0.0.0.0\r\na=ice-pwd:7c3bf4770007e7432ee4ea4d697db675\r\na=ice-ufrag:29e036dc\r\na=sendonly\r\na=rtcp-mux\r\na=rtpmap:${a} ${e}\r\n${void 0!==t?`a=fmtp:${a} ${t}\r\n`:""}`}))).then(()=>n(!0)).catch(()=>n(!1)).finally(()=>r.close())})}static unquoteCredential(e){return JSON.parse(`"${e}"`)}static linkToIceServers(n){return n?n.split(", ").map(n=>{const r=n.match(/^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i);if(!r)throw new t(e.SIGNAL_ERROR,"Invalid ICE server link format");const i={urls:[r[1]]};return r[3]&&(i.username=s.unquoteCredential(r[3]),i.credential=s.unquoteCredential(r[4]),i.credentialType="password"),i}):[]}static reservePayloadType(e){for(let t=30;t<=127;t++)if((t<=63||t>=96)&&!e.includes(t.toString())){const s=t.toString();return e.push(s),s}throw new Error("unable to find a free payload type")}}class n{constructor(e,t){this.getState=e,this.callbacks=t}detect(){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(e=>s.supportsNonAdvertisedCodec(e[0],e[1]).then(t=>!!t&&e[0]))).then(e=>e.filter(e=>!1!==e)).then(s=>{if("getting_codecs"!==this.getState())throw new t(e.STATE_ERROR,"closed");this.callbacks.onCodecsDetected(s)}).catch(e=>this.callbacks.onError(e))}}class r{static parseOffer(e){const t={iceUfrag:"",icePwd:"",medias:[]};for(const s of e.split("\r\n"))s.startsWith("m=")?t.medias.push(s.slice(2)):""===t.iceUfrag&&s.startsWith("a=ice-ufrag:")?t.iceUfrag=s.slice(12):""===t.icePwd&&s.startsWith("a=ice-pwd:")&&(t.icePwd=s.slice(10));return t}static reservePayloadType(e){for(let t=30;t<=127;t++)if((t<=63||t>=96)&&!e.includes(t.toString())){const s=t.toString();return e.push(s),s}throw new Error("unable to find a free payload type")}static enableStereoPcmau(e,t){const s=t.split("\r\n");let n=r.reservePayloadType(e);return s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} PCMU/8000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} PCMA/8000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),s.join("\r\n")}static enableMultichannelOpus(e,t){const s=t.split("\r\n");let n=r.reservePayloadType(e);return s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/3`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,2,1;num_streams=2;coupled_streams=1`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/4`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,1,2,3;num_streams=2;coupled_streams=2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/5`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,4,1,2,3;num_streams=3;coupled_streams=2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/6`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/7`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,4,1,2,3,5,6;num_streams=4;coupled_streams=4`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} multiopus/48000/8`),s.splice(s.length-1,0,`a=fmtp:${n} channel_mapping=0,6,1,4,5,2,3,7;num_streams=5;coupled_streams=4`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),s.join("\r\n")}static enableL16(e,t){const s=t.split("\r\n");let n=r.reservePayloadType(e);return s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} L16/8000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} L16/16000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),n=r.reservePayloadType(e),s[0]+=` ${n}`,s.splice(s.length-1,0,`a=rtpmap:${n} L16/48000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${n} transport-cc`),s.join("\r\n")}static enableStereoOpus(e){let t="";const s=e.split("\r\n");for(let e=0;e<s.length;e++)if(s[e].startsWith("a=rtpmap:")&&s[e].toLowerCase().includes("opus/")){t=s[e].slice(9).split(" ")[0];break}if(""===t)return e;for(let e=0;e<s.length;e++)s[e].startsWith(`a=fmtp:${t} `)&&(s[e].includes("stereo")||(s[e]+=";stereo=1"),s[e].includes("sprop-stereo")||(s[e]+=";sprop-stereo=1"));return s.join("\r\n")}static editOffer(e,t){const s=e.split("m="),n=s.slice(1).map(e=>e.split("\r\n")[0].split(" ").slice(3)).reduce((e,t)=>[...e,...t],[]);for(let e=1;e<s.length;e++)if(s[e].startsWith("audio")){s[e]=r.enableStereoOpus(s[e]),t.includes("pcma/8000/2")&&(s[e]=r.enableStereoPcmau(n,s[e])),t.includes("multiopus/48000/6")&&(s[e]=r.enableMultichannelOpus(n,s[e])),t.includes("L16/48000/2")&&(s[e]=r.enableL16(n,s[e]));break}return s.join("m=")}static generateSdpFragment(e,t){const s={};for(const e of t){const t=e.sdpMLineIndex;t&&(void 0===s[t]&&(s[t]=[]),s[t].push(e))}let n=`a=ice-ufrag:${e.iceUfrag}\r\na=ice-pwd:${e.icePwd}\r\n`,r=0;for(const t of e.medias){if(void 0!==s[r]){n+=`m=${t}\r\na=mid:${r}\r\n`;for(const e of s[r])n+=`a=${e.candidate}\r\n`}r++}return n}}class i{constructor(e,t,s=[]){this.getState=e,this.callbacks=t,this.nonAdvertisedCodecs=s}async setupPeerConnection(s){if("running"!==this.getState())throw new t(e.STATE_ERROR,"closed");const n=new RTCPeerConnection({iceServers:s,sdpSemantics:"unified-plan"});this.pc=n;const i="recvonly";return n.addTransceiver("video",{direction:i}),n.addTransceiver("audio",{direction:i}),n.onicecandidate=e=>this.onLocalCandidate(e),n.onconnectionstatechange=()=>this.onConnectionState(),n.oniceconnectionstatechange=()=>this.onIceConnectionState(),n.ontrack=e=>this.callbacks.onTrack(e),n.createOffer().then(s=>{if(!s.sdp)throw new t(e.SIGNAL_ERROR,"Failed to create offer SDP");return s.sdp=r.editOffer(s.sdp,this.nonAdvertisedCodecs),this.offerData=r.parseOffer(s.sdp),n.setLocalDescription(s).then(()=>s.sdp)})}async setAnswer(s){if("running"!==this.getState())throw new t(e.STATE_ERROR,"closed");return this.pc.setRemoteDescription(new RTCSessionDescription({type:"answer",sdp:s}))}getPeerConnection(){return this.pc}getOfferData(){return this.offerData}close(){this.pc?.close(),this.pc=void 0,this.offerData=void 0}onLocalCandidate(e){"running"===this.getState()&&e.candidate&&this.callbacks.onCandidate(e.candidate)}onConnectionState(){"running"===this.getState()&&this.pc&&("failed"!==this.pc.connectionState&&"closed"!==this.pc.connectionState||this.callbacks.onError(new t(e.OTHER_ERROR,"peer connection closed")))}onIceConnectionState(){"running"===this.getState()&&this.pc&&"failed"===this.pc.iceConnectionState&&this.pc.restartIce()}}class a{constructor(e,t,s){this.config=e,this.getState=t,this.onError=s}authHeader(){if(this.config.user&&""!==this.config.user){return{Authorization:`Basic ${btoa(`${this.config.user}:${this.config.pass}`)}`}}return this.config.token&&""!==this.config.token?{Authorization:`Bearer ${this.config.token}`}:{}}async requestICEServers(){return this.config.iceServers&&this.config.iceServers.length>0?this.config.iceServers:fetch(this.config.url,{method:"OPTIONS",headers:{...this.authHeader()}}).then(e=>s.linkToIceServers(e.headers.get("Link")))}async sendOffer(s){if("running"!==this.getState())throw new t(e.STATE_ERROR,"closed");return fetch(this.config.url,{method:"POST",headers:{...this.authHeader(),"Content-Type":"application/sdp"},body:s}).then(s=>{switch(s.status){case 201:break;case 404:case 406:throw new t(e.NOT_FOUND_ERROR,"stream not found");case 400:return s.json().then(s=>{throw new t(e.REQUEST_ERROR,s.error)});default:throw new t(e.REQUEST_ERROR,`bad status code ${s.status}`)}const n=s.headers.get("Location"),r=n?new URL(n,this.config.url).toString():void 0;return s.text().then(e=>({sessionUrl:r,answer:e}))})}sendLocalCandidates(s,n,i){fetch(s,{method:"PATCH",headers:{"Content-Type":"application/trickle-ice-sdpfrag","If-Match":"*"},body:r.generateSdpFragment(n,i)}).then(s=>{switch(s.status){case 204:break;case 404:throw new t(e.NOT_FOUND_ERROR,"stream not found");default:throw new t(e.REQUEST_ERROR,`bad status code ${s.status}`)}}).catch(e=>this.onError(e))}}class c{constructor(e){this.container=e}onTrack(e){this.stream=e.streams[0],this.stopObserver(),this.observer=new IntersectionObserver(([e])=>{e.isIntersecting?this.resume():this.pause()},{threshold:.5}),this.observer.observe(this.container)}stopObserver(){this.observer&&(this.observer.disconnect(),this.observer=void 0)}get paused(){return null===this.container.srcObject}pause(){this.container.srcObject=null}resume(){this.stream&&this.paused&&(this.container.srcObject=this.stream)}stop(){this.stopObserver(),this.stream=void 0}}class o{constructor(e){this.lastBytesReceived=0,this.checkInterval=e.interval,this.onError=e.onError}setPeerConnection(e){this.pc=e}start(){this.stop(),this.checkTimer=setInterval(()=>this.checkFlowState(),this.checkInterval)}stop(){this.checkTimer&&(clearInterval(this.checkTimer),this.checkTimer=void 0)}async checkFlowState(){if(!this.pc)return;const s=await this.pc.getStats();let n=0;s.forEach(e=>{const t=e;"inbound-rtp"===e.type&&"video"===t.kind&&(n=t.bytesReceived||0)}),n!==this.lastBytesReceived||"connected"!==this.pc.connectionState?this.lastBytesReceived=n:this.onError(new t(e.CONNECT_ERROR,"data stream interruption"))}}class h{constructor(e){this.retryPause=2e3,this.queuedCandidates=[],this.nonAdvertisedCodecs=[],this.conf=e,this.state="getting_codecs",this.flowCheck=new o({interval:5e3,onError:e=>this.handleError(e)}),this.httpClient=new a(this.conf,()=>this.state,e=>this.handleError(e)),this.connectionManager=new i(()=>this.state,{onCandidate:e=>this.handleCandidate(e),onTrack:e=>{this.trackManager.onTrack(e),this.flowCheck.start()},onError:e=>this.handleError(e)},this.nonAdvertisedCodecs),this.trackManager=new c(this.conf.container),this.codecDetector=new n(()=>this.state,{onCodecsDetected:e=>this.handleCodecsDetected(e),onError:e=>this.handleError(e)}),this.codecDetector.detect()}get isRunning(){return"running"===this.state}close(){this.state="closed",this.connectionManager.close(),this.trackManager.stop(),this.flowCheck.stop(),this.restartTimeout&&clearTimeout(this.restartTimeout)}handleError(s){this.flowCheck.stop(),"getting_codecs"===this.state||s instanceof t&&[e.SIGNAL_ERROR,e.NOT_FOUND_ERROR,e.REQUEST_ERROR].includes(s.type)?this.state="failed":"running"===this.state&&(this.connectionManager.close(),this.queuedCandidates=[],this.sessionUrl&&(fetch(this.sessionUrl,{method:"DELETE"}),this.sessionUrl=void 0),this.state="restarting",this.restartTimeout=setTimeout(()=>{this.restartTimeout=void 0,this.state="running",this.start()},this.retryPause),s.message=`${s.message}, retrying in some seconds`),this.conf.onError&&(s instanceof t?this.conf.onError(s):this.conf.onError(new t(e.OTHER_ERROR,s.message)))}handleCodecsDetected(e){this.nonAdvertisedCodecs=e,this.state="running",this.start()}start(){this.httpClient.requestICEServers().then(e=>this.connectionManager.setupPeerConnection(e)).then(e=>this.httpClient.sendOffer(e)).then(({sessionUrl:e,answer:t})=>this.handleOfferResponse(e,t)).catch(e=>this.handleError(e))}handleOfferResponse(e,t){return e&&(this.sessionUrl=e),this.connectionManager.setAnswer(t).then(()=>{if("running"===this.state&&0!==this.queuedCandidates.length){const e=this.connectionManager.getOfferData();e&&this.sessionUrl&&(this.httpClient.sendLocalCandidates(this.sessionUrl,e,this.queuedCandidates),this.queuedCandidates=[])}})}handleCandidate(e){if(this.sessionUrl){const t=this.connectionManager.getOfferData();t&&this.httpClient.sendLocalCandidates(this.sessionUrl,t,[e])}else this.queuedCandidates.push(e)}get paused(){return this.trackManager.paused}pause(){this.trackManager.pause()}resume(){this.trackManager.resume()}}export{e as ErrorTypes,t as WebRTCError,h as default};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "whepts",
3
3
  "type": "module",
4
- "version": "1.0.3",
4
+ "version": "1.0.4",
5
5
  "description": "基于 mediamtx 的 WebRTC WHEP 播放器,支持 ZLM 和 Mediamtx 的播放地址",
6
6
  "author": "mapleafgo",
7
7
  "license": "MIT",
@@ -31,10 +31,10 @@
31
31
  "@rollup/plugin-eslint": "^9.2.0",
32
32
  "@rollup/plugin-terser": "^0.4.4",
33
33
  "@rollup/plugin-typescript": "^12.3.0",
34
- "@types/node": "^25.0.3",
34
+ "@types/node": "^25.0.9",
35
35
  "eslint": "^9.39.2",
36
- "eslint-plugin-format": "^1.1.0",
37
- "rollup": "^4.54.0",
36
+ "eslint-plugin-format": "^1.3.1",
37
+ "rollup": "^4.55.3",
38
38
  "rollup-plugin-delete": "^3.0.2",
39
39
  "tslib": "^2.8.1",
40
40
  "typescript": "^5.9.3"
@@ -1,9 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Skill(superpowers:brainstorming)",
5
- "Skill(superpowers:executing-plans)",
6
- "Bash(npm run build:*)"
7
- ]
8
- }
9
- }
@@ -1,38 +0,0 @@
1
- {
2
- // Disable the default formatter
3
- "prettier.enable": false,
4
- "editor.formatOnSave": false,
5
-
6
- // Auto fix
7
- "editor.codeActionsOnSave": {
8
- "source.fixAll.eslint": "explicit",
9
- "source.organizeImports": "never"
10
- },
11
-
12
- // Silent the stylistic rules in you IDE, but still auto fix them
13
- "eslint.rules.customizations": [
14
- { "rule": "style/*", "severity": "off" },
15
- { "rule": "*-indent", "severity": "off" },
16
- { "rule": "*-spacing", "severity": "off" },
17
- { "rule": "*-spaces", "severity": "off" },
18
- { "rule": "*-order", "severity": "off" },
19
- { "rule": "*-dangle", "severity": "off" },
20
- { "rule": "*-newline", "severity": "off" },
21
- { "rule": "*quotes", "severity": "off" },
22
- { "rule": "*semi", "severity": "off" }
23
- ],
24
-
25
- // The following is optional.
26
- // It's better to put under project setting `.vscode/settings.json`
27
- // to avoid conflicts with working with different eslint configs
28
- // that does not support all formats.
29
- "eslint.validate": [
30
- "javascript",
31
- "typescript",
32
- "html",
33
- "markdown",
34
- "json",
35
- "jsonc",
36
- "yaml"
37
- ]
38
- }
package/AGENTS.md DELETED
@@ -1,92 +0,0 @@
1
- # AGENTS.md - WhepTS Player
2
-
3
- ## Build/Lint Commands
4
-
5
- - `npm run build` - Production build with minification
6
- - `npm run build:debug` - Debug build with source maps
7
- - `npm run lint` - Run ESLint to check code quality
8
- - `npm run lint:fix` - Auto-fix ESLint issues
9
-
10
- **Note**: No test framework is currently configured.
11
-
12
- ## Code Style Guidelines
13
-
14
- ### Imports
15
-
16
- - Use `~/` alias for imports from `src/` (e.g., `import X from '~/utils/observer'`)
17
- - Import types explicitly using `import type { T } from './module'`
18
- - Group external dependencies first, then internal imports
19
-
20
- ### Formatting
21
-
22
- - ESLint uses `@antfu/eslint-config` with formatters enabled
23
- - Run `npm run lint:fix` before committing
24
- - No manual formatting required - let ESLint handle it
25
-
26
- ### Types
27
-
28
- - Strict mode enabled in `tsconfig.json`
29
- - Use `interface` for object shapes, `type` for unions/aliases
30
- - Mark optional properties with `?` (e.g., `onError?: (err: WebRTCError) => void`)
31
- - Use TypeScript strictly - no `any` types
32
-
33
- ### Naming Conventions
34
-
35
- - **Classes**: PascalCase (e.g., `WebRTCWhep`, `VisibilityObserver`)
36
- - **Interfaces**: PascalCase (e.g., `Conf`, `ErrorType`)
37
- - **Functions/Methods**: camelCase (e.g., `setupPeerConnection`, `handleError`)
38
- - **Constants**: UPPER_SNAKE_CASE (e.g., `ErrorTypes`)
39
- - **Private members**: prefix with `private` keyword
40
-
41
- ### Error Handling
42
-
43
- - Use custom `WebRTCError` class for all errors (defined in `src/errors.ts`)
44
- - Error types: `SIGNAL_ERROR`, `STATE_ERROR`, `NETWORK_ERROR`, `MEDIA_ERROR`, `OTHER_ERROR`
45
- - Pattern: `throw new WebRTCError(ErrorTypes.NETWORK_ERROR, 'message')`
46
- - Call `this.handleError(err)` for centralized error management
47
-
48
- ### Comments
49
-
50
- - Use JSDoc for public APIs and class constructors (in English)
51
- - Keep implementation comments concise in Chinese as established
52
- - Example:
53
- ```typescript
54
- /**
55
- * Create a WebRTCWhep.
56
- * @param {Conf} conf - Configuration.
57
- */
58
- constructor(conf: Conf)
59
- ```
60
-
61
- ### File Organization
62
-
63
- - Core logic in `src/` directory
64
- - Utilities in `src/utils/` (e.g., `observer.ts`, `flow-check.ts`, `sdp.ts`)
65
- - Error types in `src/errors.ts`
66
- - Export main class from `src/index.ts`
67
-
68
- ### State Management
69
-
70
- - Use union literal types for state (e.g., `'getting_codecs' | 'running' | 'restarting' | 'closed' | 'failed'`)
71
- - Always check state before operations that depend on it
72
- - Use getters for derived properties (e.g., `get isRunning()`)
73
-
74
- ### WebRTC Specifics
75
-
76
- - Always use `unified-plan` SDP semantics
77
- - Handle ICE candidates with queuing when session URL not ready
78
- - Support non-advertised codecs (PCMA, multiopus, L16)
79
- - Use `IntersectionObserver` for visibility-based playback control
80
-
81
- ## Tech Stack
82
-
83
- - TypeScript 5.9 with strict mode
84
- - Rollup for bundling (ES module output)
85
- - ESLint with @antfu/eslint-config
86
- - pnpm as package manager
87
-
88
- ## Before Committing
89
-
90
- 1. Run `npm run lint` and fix all issues
91
- 2. Build with `npm run build` to verify production build works
92
- 3. No test framework - manually verify WebRTC functionality
package/src/core/codec.ts DELETED
@@ -1,33 +0,0 @@
1
- import type { State } from '~/types'
2
- import { ErrorTypes, WebRTCError } from '~/errors'
3
- import { WebRtcUtils } from '../utils/webrtc'
4
-
5
- export interface CodecDetectorCallbacks {
6
- onCodecsDetected: (codecs: string[]) => void
7
- onError: (err: Error) => void
8
- }
9
-
10
- export class CodecDetector {
11
- constructor(
12
- private getState: () => State,
13
- private callbacks: CodecDetectorCallbacks,
14
- ) {}
15
-
16
- detect(): void {
17
- Promise.all(
18
- [
19
- ['pcma/8000/2'],
20
- ['multiopus/48000/6', 'channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2'],
21
- ['L16/48000/2'],
22
- ].map(c => WebRtcUtils.supportsNonAdvertisedCodec(c[0], c[1]).then(r => (r ? c[0] : false))),
23
- )
24
- .then(c => c.filter(e => e !== false))
25
- .then((codecs) => {
26
- if (this.getState() !== 'getting_codecs')
27
- throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
28
-
29
- this.callbacks.onCodecsDetected(codecs as string[])
30
- })
31
- .catch(err => this.callbacks.onError(err))
32
- }
33
- }
@@ -1,103 +0,0 @@
1
- import type { ParsedOffer } from '../utils/sdp'
2
- import type { State } from '~/types'
3
- import { ErrorTypes, WebRTCError } from '~/errors'
4
- import { SdpUtils } from '../utils/sdp'
5
-
6
- export interface ConnectionManagerCallbacks {
7
- onCandidate: (candidate: RTCIceCandidate) => void
8
- onTrack: (evt: RTCTrackEvent) => void
9
- onError: (err: WebRTCError) => void
10
- }
11
-
12
- export class ConnectionManager {
13
- private pc?: RTCPeerConnection
14
- private offerData?: ParsedOffer
15
-
16
- constructor(
17
- private getState: () => State,
18
- private callbacks: ConnectionManagerCallbacks,
19
- private nonAdvertisedCodecs: string[] = [],
20
- ) {}
21
-
22
- async setupPeerConnection(iceServers: RTCIceServer[]): Promise<string> {
23
- if (this.getState() !== 'running')
24
- throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
25
-
26
- const pc = new RTCPeerConnection({
27
- iceServers,
28
- sdpSemantics: 'unified-plan',
29
- })
30
- this.pc = pc
31
-
32
- const direction: RTCRtpTransceiverDirection = 'recvonly'
33
- pc.addTransceiver('video', { direction })
34
- pc.addTransceiver('audio', { direction })
35
-
36
- pc.onicecandidate = (evt: RTCPeerConnectionIceEvent) => this.onLocalCandidate(evt)
37
- pc.onconnectionstatechange = () => this.onConnectionState()
38
- pc.oniceconnectionstatechange = () => this.onIceConnectionState()
39
- pc.ontrack = (evt: RTCTrackEvent) => this.callbacks.onTrack(evt)
40
-
41
- return pc.createOffer().then((offer) => {
42
- if (!offer.sdp)
43
- throw new WebRTCError(ErrorTypes.SIGNAL_ERROR, 'Failed to create offer SDP')
44
-
45
- offer.sdp = SdpUtils.editOffer(offer.sdp, this.nonAdvertisedCodecs)
46
- this.offerData = SdpUtils.parseOffer(offer.sdp)
47
-
48
- return pc.setLocalDescription(offer).then(() => offer.sdp!)
49
- })
50
- }
51
-
52
- async setAnswer(answer: string): Promise<void> {
53
- if (this.getState() !== 'running')
54
- throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
55
-
56
- return this.pc!.setRemoteDescription(
57
- new RTCSessionDescription({
58
- type: 'answer',
59
- sdp: answer,
60
- }),
61
- )
62
- }
63
-
64
- getPeerConnection(): RTCPeerConnection | undefined {
65
- return this.pc
66
- }
67
-
68
- getOfferData(): ParsedOffer | undefined {
69
- return this.offerData
70
- }
71
-
72
- close(): void {
73
- this.pc?.close()
74
- this.pc = undefined
75
- this.offerData = undefined
76
- }
77
-
78
- private onLocalCandidate(evt: RTCPeerConnectionIceEvent): void {
79
- if (this.getState() !== 'running')
80
- return
81
-
82
- if (evt.candidate)
83
- this.callbacks.onCandidate(evt.candidate)
84
- }
85
-
86
- private onConnectionState(): void {
87
- if (this.getState() !== 'running' || !this.pc)
88
- return
89
-
90
- if (this.pc.connectionState === 'failed' || this.pc.connectionState === 'closed')
91
- this.callbacks.onError(new WebRTCError(ErrorTypes.OTHER_ERROR, 'peer connection closed'))
92
- }
93
-
94
- private onIceConnectionState(): void {
95
- if (this.getState() !== 'running' || !this.pc)
96
- return
97
-
98
- if (this.pc.iceConnectionState === 'failed') {
99
- console.warn('ICE connection failed')
100
- this.pc.restartIce()
101
- }
102
- }
103
- }
package/src/core/http.ts DELETED
@@ -1,93 +0,0 @@
1
- import type { ParsedOffer } from '../utils/sdp'
2
- import type { Conf, State } from '~/types'
3
- import { ErrorTypes, WebRTCError } from '~/errors'
4
- import { SdpUtils } from '../utils/sdp'
5
- import { WebRtcUtils } from '../utils/webrtc'
6
-
7
- export class HttpClient {
8
- constructor(
9
- private config: Conf,
10
- private getState: () => State,
11
- private onError: (err: Error | WebRTCError) => void,
12
- ) {}
13
-
14
- private authHeader(): Record<string, string> {
15
- if (this.config.user && this.config.user !== '') {
16
- const credentials = btoa(`${this.config.user}:${this.config.pass}`)
17
- return { Authorization: `Basic ${credentials}` }
18
- }
19
- if (this.config.token && this.config.token !== '') {
20
- return { Authorization: `Bearer ${this.config.token}` }
21
- }
22
- return {}
23
- }
24
-
25
- async requestICEServers(): Promise<RTCIceServer[]> {
26
- if (this.config.iceServers && this.config.iceServers.length > 0)
27
- return this.config.iceServers
28
-
29
- return fetch(this.config.url, {
30
- method: 'OPTIONS',
31
- headers: {
32
- ...this.authHeader(),
33
- },
34
- }).then(res => WebRtcUtils.linkToIceServers(res.headers.get('Link')))
35
- }
36
-
37
- async sendOffer(offer: string): Promise<{ sessionUrl?: string, answer: string }> {
38
- if (this.getState() !== 'running')
39
- throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
40
-
41
- return fetch(this.config.url, {
42
- method: 'POST',
43
- headers: {
44
- ...this.authHeader(),
45
- 'Content-Type': 'application/sdp',
46
- },
47
- body: offer,
48
- }).then((res) => {
49
- switch (res.status) {
50
- case 201:
51
- break
52
- case 404:
53
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, 'stream not found')
54
- case 406:
55
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, 'stream not supported')
56
- case 400:
57
- return res.json().then((e: { error: string }) => {
58
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, e.error)
59
- })
60
- default:
61
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, `bad status code ${res.status}`)
62
- }
63
-
64
- const location = res.headers.get('Location')
65
- const sessionUrl = location
66
- ? new URL(location, this.config.url).toString()
67
- : undefined
68
- return res.text().then(answer => ({ sessionUrl, answer }))
69
- })
70
- }
71
-
72
- sendLocalCandidates(sessionUrl: string, offerData: ParsedOffer, candidates: RTCIceCandidate[]): void {
73
- fetch(sessionUrl, {
74
- method: 'PATCH',
75
- headers: {
76
- 'Content-Type': 'application/trickle-ice-sdpfrag',
77
- 'If-Match': '*',
78
- },
79
- body: SdpUtils.generateSdpFragment(offerData, candidates),
80
- })
81
- .then((res) => {
82
- switch (res.status) {
83
- case 204:
84
- break
85
- case 404:
86
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, 'stream not found')
87
- default:
88
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, `bad status code ${res.status}`)
89
- }
90
- })
91
- .catch(err => this.onError(err))
92
- }
93
- }
package/src/core/track.ts DELETED
@@ -1,51 +0,0 @@
1
- export class TrackManager {
2
- private stream?: MediaStream
3
- private observer?: IntersectionObserver
4
-
5
- constructor(private container: HTMLMediaElement) {}
6
-
7
- onTrack(evt: RTCTrackEvent): void {
8
- this.stream = evt.streams[0]
9
-
10
- // 停止之前的观察器
11
- this.stopObserver()
12
-
13
- // 创建新的可见性观察器,自动处理暂停/恢复
14
- this.observer = new IntersectionObserver(
15
- ([entry]) => {
16
- if (entry.isIntersecting)
17
- this.resume()
18
- else
19
- this.pause()
20
- },
21
- { threshold: 0.5 },
22
- )
23
-
24
- this.observer.observe(this.container)
25
- }
26
-
27
- private stopObserver(): void {
28
- if (this.observer) {
29
- this.observer.disconnect()
30
- this.observer = undefined
31
- }
32
- }
33
-
34
- get paused(): boolean {
35
- return this.container.srcObject === null
36
- }
37
-
38
- pause(): void {
39
- this.container.srcObject = null
40
- }
41
-
42
- resume(): void {
43
- if (this.stream && this.paused)
44
- this.container.srcObject = this.stream
45
- }
46
-
47
- stop(): void {
48
- this.stopObserver()
49
- this.stream = undefined
50
- }
51
- }
package/src/errors.ts DELETED
@@ -1,27 +0,0 @@
1
- /**
2
- * 错误类型
3
- */
4
- export type ErrorType = string
5
-
6
- /**
7
- * 错误类型常量
8
- */
9
- export const ErrorTypes = {
10
- SIGNAL_ERROR: 'SignalError', // 信令异常
11
- STATE_ERROR: 'StateError', // 状态异常
12
- NETWORK_ERROR: 'NetworkError',
13
- MEDIA_ERROR: 'MediaError',
14
- OTHER_ERROR: 'OtherError',
15
- }
16
-
17
- /**
18
- * 错误
19
- */
20
- export class WebRTCError extends Error {
21
- type: ErrorType
22
-
23
- constructor(type: ErrorType, message: string, options?: ErrorOptions) {
24
- super(message, options)
25
- this.type = type
26
- }
27
- }
package/src/index.ts DELETED
@@ -1,7 +0,0 @@
1
- import type { Conf, State } from './types'
2
- import { ErrorTypes, WebRTCError } from './errors'
3
- import WebRTCWhep from './whep'
4
-
5
- export { Conf, ErrorTypes, State, WebRTCError }
6
-
7
- export default WebRTCWhep
package/src/types.ts DELETED
@@ -1,37 +0,0 @@
1
- import type { WebRTCError } from './errors'
2
-
3
- /**
4
- * Configuration interface for WebRTCWhep.
5
- */
6
- export interface Conf {
7
- /** Absolute URL of the WHEP endpoint */
8
- url: string
9
- /** Media player container */
10
- container: HTMLMediaElement
11
- /** Username for authentication */
12
- user?: string
13
- /** Password for authentication */
14
- pass?: string
15
- /** Token for authentication */
16
- token?: string
17
- /** ice server list */
18
- iceServers?: RTCIceServer[]
19
- /** Called when there's an error */
20
- onError?: (err: WebRTCError) => void
21
- }
22
-
23
- /**
24
- * State type for WebRTCWhep.
25
- */
26
- export type State = 'getting_codecs' | 'running' | 'restarting' | 'closed' | 'failed'
27
-
28
- /** Extend RTCConfiguration to include experimental properties */
29
- declare global {
30
- interface RTCConfiguration {
31
- sdpSemantics?: 'plan-b' | 'unified-plan'
32
- }
33
-
34
- interface RTCIceServer {
35
- credentialType?: 'password'
36
- }
37
- }
@@ -1,73 +0,0 @@
1
- import { ErrorTypes, WebRTCError } from '~/errors'
2
-
3
- export interface FlowCheckParams {
4
- interval: number
5
- onError: (err: WebRTCError) => void
6
- }
7
-
8
- /**
9
- * Flow checking logic (断流检测)
10
- */
11
- export class FlowCheck {
12
- private checkInterval: number
13
- private lastBytesReceived: number = 0
14
- private checkTimer?: NodeJS.Timeout
15
- private pc?: RTCPeerConnection
16
- private onError: (err: WebRTCError) => void
17
-
18
- constructor(params: FlowCheckParams) {
19
- this.checkInterval = params.interval
20
- this.onError = params.onError
21
- }
22
-
23
- setPeerConnection(pc: RTCPeerConnection) {
24
- this.pc = pc
25
- }
26
-
27
- /**
28
- * 启动断流检测
29
- */
30
- start(): void {
31
- this.stop()
32
- this.checkTimer = setInterval(() => this.checkFlowState(), this.checkInterval)
33
- }
34
-
35
- /**
36
- * 停止断流检测
37
- */
38
- stop(): void {
39
- if (this.checkTimer) {
40
- clearInterval(this.checkTimer)
41
- this.checkTimer = undefined
42
- }
43
- }
44
-
45
- /**
46
- * 检测流状态(通过接收字节数判断是否断流)
47
- */
48
- private async checkFlowState(): Promise<void> {
49
- if (!this.pc) {
50
- return
51
- }
52
-
53
- const stats = await this.pc.getStats()
54
- let currentBytes = 0
55
-
56
- // 遍历统计信息,获取视频接收字节数
57
- stats.forEach((stat: RTCStats) => {
58
- const inboundRtpStat = stat as RTCInboundRtpStreamStats
59
- if (stat.type === 'inbound-rtp' && inboundRtpStat.kind === 'video') {
60
- currentBytes = inboundRtpStat.bytesReceived || 0
61
- }
62
- })
63
-
64
- // 断流判定:连接正常但字节数无变化
65
- if (currentBytes === this.lastBytesReceived && this.pc.connectionState === 'connected') {
66
- this.onError(new WebRTCError(ErrorTypes.NETWORK_ERROR, 'data stream interruption'))
67
- return
68
- }
69
-
70
- // 更新上一次字节数
71
- this.lastBytesReceived = currentBytes
72
- }
73
- }
package/src/utils/sdp.ts DELETED
@@ -1,253 +0,0 @@
1
- /** Type for parsed offer data */
2
- export interface ParsedOffer {
3
- iceUfrag: string
4
- icePwd: string
5
- medias: string[]
6
- }
7
-
8
- /**
9
- * SDP processing utilities
10
- */
11
- export class SdpUtils {
12
- /**
13
- * Parse an offer SDP into iceUfrag, icePwd, and medias.
14
- */
15
- static parseOffer(sdp: string): ParsedOffer {
16
- const ret: ParsedOffer = {
17
- iceUfrag: '',
18
- icePwd: '',
19
- medias: [],
20
- }
21
-
22
- for (const line of sdp.split('\r\n')) {
23
- if (line.startsWith('m=')) {
24
- ret.medias.push(line.slice('m='.length))
25
- }
26
- else if (ret.iceUfrag === '' && line.startsWith('a=ice-ufrag:')) {
27
- ret.iceUfrag = line.slice('a=ice-ufrag:'.length)
28
- }
29
- else if (ret.icePwd === '' && line.startsWith('a=ice-pwd:')) {
30
- ret.icePwd = line.slice('a=ice-pwd:'.length)
31
- }
32
- }
33
-
34
- return ret
35
- }
36
-
37
- /**
38
- * Reserve a payload type.
39
- */
40
- static reservePayloadType(payloadTypes: string[]): string {
41
- // everything is valid between 30 and 127, except for interval between 64 and 95
42
- // https://chromium.googlesource.com/external/webrtc/+/refs/heads/master/call/payload_type.h#29
43
- for (let i = 30; i <= 127; i++) {
44
- if ((i <= 63 || i >= 96) && !payloadTypes.includes(i.toString())) {
45
- const pl = i.toString()
46
- payloadTypes.push(pl)
47
- return pl
48
- }
49
- }
50
- throw new Error('unable to find a free payload type')
51
- }
52
-
53
- /**
54
- * Enable stereo PCMA/PCMU codecs.
55
- */
56
- static enableStereoPcmau(payloadTypes: string[], section: string): string {
57
- const lines = section.split('\r\n')
58
-
59
- let payloadType = SdpUtils.reservePayloadType(payloadTypes)
60
- lines[0] += ` ${payloadType}`
61
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} PCMU/8000/2`)
62
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
63
-
64
- payloadType = SdpUtils.reservePayloadType(payloadTypes)
65
- lines[0] += ` ${payloadType}`
66
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} PCMA/8000/2`)
67
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
68
-
69
- return lines.join('\r\n')
70
- }
71
-
72
- /**
73
- * Enable multichannel Opus codec.
74
- */
75
- static enableMultichannelOpus(payloadTypes: string[], section: string): string {
76
- const lines = section.split('\r\n')
77
-
78
- let payloadType = SdpUtils.reservePayloadType(payloadTypes)
79
- lines[0] += ` ${payloadType}`
80
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/3`)
81
- lines.splice(lines.length - 1, 0, `a=fmtp:${payloadType} channel_mapping=0,2,1;num_streams=2;coupled_streams=1`)
82
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
83
-
84
- payloadType = SdpUtils.reservePayloadType(payloadTypes)
85
- lines[0] += ` ${payloadType}`
86
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/4`)
87
- lines.splice(lines.length - 1, 0, `a=fmtp:${payloadType} channel_mapping=0,1,2,3;num_streams=2;coupled_streams=2`)
88
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
89
-
90
- payloadType = SdpUtils.reservePayloadType(payloadTypes)
91
- lines[0] += ` ${payloadType}`
92
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/5`)
93
- lines.splice(
94
- lines.length - 1,
95
- 0,
96
- `a=fmtp:${payloadType} channel_mapping=0,4,1,2,3;num_streams=3;coupled_streams=2`,
97
- )
98
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
99
-
100
- payloadType = SdpUtils.reservePayloadType(payloadTypes)
101
- lines[0] += ` ${payloadType}`
102
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/6`)
103
- lines.splice(
104
- lines.length - 1,
105
- 0,
106
- `a=fmtp:${payloadType} channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2`,
107
- )
108
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
109
-
110
- payloadType = SdpUtils.reservePayloadType(payloadTypes)
111
- lines[0] += ` ${payloadType}`
112
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/7`)
113
- lines.splice(
114
- lines.length - 1,
115
- 0,
116
- `a=fmtp:${payloadType} channel_mapping=0,4,1,2,3,5,6;num_streams=4;coupled_streams=4`,
117
- )
118
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
119
-
120
- payloadType = SdpUtils.reservePayloadType(payloadTypes)
121
- lines[0] += ` ${payloadType}`
122
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/8`)
123
- lines.splice(
124
- lines.length - 1,
125
- 0,
126
- `a=fmtp:${payloadType} channel_mapping=0,6,1,4,5,2,3,7;num_streams=5;coupled_streams=4`,
127
- )
128
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
129
-
130
- return lines.join('\r\n')
131
- }
132
-
133
- /**
134
- * Enable L16 codec.
135
- */
136
- static enableL16(payloadTypes: string[], section: string): string {
137
- const lines = section.split('\r\n')
138
-
139
- let payloadType = SdpUtils.reservePayloadType(payloadTypes)
140
- lines[0] += ` ${payloadType}`
141
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} L16/8000/2`)
142
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
143
-
144
- payloadType = SdpUtils.reservePayloadType(payloadTypes)
145
- lines[0] += ` ${payloadType}`
146
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} L16/16000/2`)
147
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
148
-
149
- payloadType = SdpUtils.reservePayloadType(payloadTypes)
150
- lines[0] += ` ${payloadType}`
151
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} L16/48000/2`)
152
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
153
-
154
- return lines.join('\r\n')
155
- }
156
-
157
- /**
158
- * Enable stereo Opus codec.
159
- */
160
- static enableStereoOpus(section: string): string {
161
- let opusPayloadFormat = ''
162
- const lines = section.split('\r\n')
163
-
164
- for (let i = 0; i < lines.length; i++) {
165
- if (lines[i].startsWith('a=rtpmap:') && lines[i].toLowerCase().includes('opus/')) {
166
- opusPayloadFormat = lines[i].slice('a=rtpmap:'.length).split(' ')[0]
167
- break
168
- }
169
- }
170
-
171
- if (opusPayloadFormat === '') {
172
- return section
173
- }
174
-
175
- for (let i = 0; i < lines.length; i++) {
176
- if (lines[i].startsWith(`a=fmtp:${opusPayloadFormat} `)) {
177
- if (!lines[i].includes('stereo')) {
178
- lines[i] += ';stereo=1'
179
- }
180
- if (!lines[i].includes('sprop-stereo')) {
181
- lines[i] += ';sprop-stereo=1'
182
- }
183
- }
184
- }
185
-
186
- return lines.join('\r\n')
187
- }
188
-
189
- /**
190
- * Edit an offer SDP to enable non-advertised codecs.
191
- */
192
- static editOffer(sdp: string, nonAdvertisedCodecs: string[]): string {
193
- const sections = sdp.split('m=')
194
-
195
- const payloadTypes = sections
196
- .slice(1)
197
- .map(s => s.split('\r\n')[0].split(' ').slice(3))
198
- .reduce((prev, cur) => [...prev, ...cur], [])
199
-
200
- for (let i = 1; i < sections.length; i++) {
201
- if (sections[i].startsWith('audio')) {
202
- sections[i] = SdpUtils.enableStereoOpus(sections[i])
203
-
204
- if (nonAdvertisedCodecs.includes('pcma/8000/2')) {
205
- sections[i] = SdpUtils.enableStereoPcmau(payloadTypes, sections[i])
206
- }
207
- if (nonAdvertisedCodecs.includes('multiopus/48000/6')) {
208
- sections[i] = SdpUtils.enableMultichannelOpus(payloadTypes, sections[i])
209
- }
210
- if (nonAdvertisedCodecs.includes('L16/48000/2')) {
211
- sections[i] = SdpUtils.enableL16(payloadTypes, sections[i])
212
- }
213
-
214
- break
215
- }
216
- }
217
-
218
- return sections.join('m=')
219
- }
220
-
221
- /**
222
- * Generate an SDP fragment.
223
- */
224
- static generateSdpFragment(od: ParsedOffer, candidates: RTCIceCandidate[]): string {
225
- const candidatesByMedia: Record<number, RTCIceCandidate[]> = {}
226
- for (const candidate of candidates) {
227
- const mid = candidate.sdpMLineIndex
228
- if (mid) {
229
- if (candidatesByMedia[mid] === undefined) {
230
- candidatesByMedia[mid] = []
231
- }
232
- candidatesByMedia[mid].push(candidate)
233
- }
234
- }
235
-
236
- let frag = `a=ice-ufrag:${od.iceUfrag}\r\n` + `a=ice-pwd:${od.icePwd}\r\n`
237
-
238
- let mid = 0
239
-
240
- for (const media of od.medias) {
241
- if (candidatesByMedia[mid] !== undefined) {
242
- frag += `m=${media}\r\n` + `a=mid:${mid}\r\n`
243
-
244
- for (const candidate of candidatesByMedia[mid]) {
245
- frag += `a=${candidate.candidate}\r\n`
246
- }
247
- }
248
- mid++
249
- }
250
-
251
- return frag
252
- }
253
- }
@@ -1,123 +0,0 @@
1
- import { ErrorTypes, WebRTCError } from '~/errors'
2
-
3
- /**
4
- * WebRTC utilities
5
- */
6
- export class WebRtcUtils {
7
- /**
8
- * Check if the browser supports a non-advertised codec.
9
- */
10
- static async supportsNonAdvertisedCodec(codec: string, fmtp?: string): Promise<boolean> {
11
- return new Promise((resolve) => {
12
- const pc = new RTCPeerConnection({ iceServers: [] })
13
- const mediaType = 'audio'
14
- let payloadType = ''
15
-
16
- pc.addTransceiver(mediaType, { direction: 'recvonly' })
17
- pc.createOffer()
18
- .then((offer) => {
19
- if (!offer.sdp) {
20
- throw new Error('SDP not present')
21
- }
22
- if (offer.sdp.includes(` ${codec}`)) {
23
- // codec is advertised, there's no need to add it manually
24
- throw new Error('already present')
25
- }
26
-
27
- const sections = offer.sdp.split(`m=${mediaType}`)
28
-
29
- const payloadTypes = sections
30
- .slice(1)
31
- .map(s => s.split('\r\n')[0].split(' ').slice(3))
32
- .reduce((prev, cur) => [...prev, ...cur], [])
33
- payloadType = WebRtcUtils.reservePayloadType(payloadTypes)
34
-
35
- const lines = sections[1].split('\r\n')
36
- lines[0] += ` ${payloadType}`
37
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} ${codec}`)
38
- if (fmtp !== undefined) {
39
- lines.splice(lines.length - 1, 0, `a=fmtp:${payloadType} ${fmtp}`)
40
- }
41
- sections[1] = lines.join('\r\n')
42
- offer.sdp = sections.join(`m=${mediaType}`)
43
- return pc.setLocalDescription(offer)
44
- })
45
- .then(() =>
46
- pc.setRemoteDescription(
47
- new RTCSessionDescription({
48
- type: 'answer',
49
- sdp:
50
- `v=0\r\n`
51
- + `o=- 6539324223450680508 0 IN IP4 0.0.0.0\r\n`
52
- + `s=-\r\n`
53
- + `t=0 0\r\n`
54
- + `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`
55
- + `m=${mediaType} 9 UDP/TLS/RTP/SAVPF ${payloadType}\r\n`
56
- + `c=IN IP4 0.0.0.0\r\n`
57
- + `a=ice-pwd:7c3bf4770007e7432ee4ea4d697db675\r\n`
58
- + `a=ice-ufrag:29e036dc\r\n`
59
- + `a=sendonly\r\n`
60
- + `a=rtcp-mux\r\n`
61
- + `a=rtpmap:${payloadType} ${codec}\r\n${fmtp !== undefined ? `a=fmtp:${payloadType} ${fmtp}\r\n` : ''}`,
62
- }),
63
- ),
64
- )
65
- .then(() => resolve(true))
66
- .catch(() => resolve(false))
67
- .finally(() => pc.close())
68
- })
69
- }
70
-
71
- /**
72
- * Unquote a credential string.
73
- */
74
- static unquoteCredential(v: string): string {
75
- return JSON.parse(`"${v}"`)
76
- }
77
-
78
- /**
79
- * Convert Link header to iceServers array.
80
- */
81
- static linkToIceServers(links: string | null): RTCIceServer[] {
82
- if (links) {
83
- return links.split(', ').map((link) => {
84
- const m = link.match(
85
- /^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i,
86
- )
87
-
88
- if (!m) {
89
- throw new WebRTCError(ErrorTypes.SIGNAL_ERROR, 'Invalid ICE server link format')
90
- }
91
-
92
- const ret: RTCIceServer = {
93
- urls: [m[1]],
94
- }
95
-
96
- if (m[3]) {
97
- ret.username = WebRtcUtils.unquoteCredential(m[3])
98
- ret.credential = WebRtcUtils.unquoteCredential(m[4])
99
- ret.credentialType = 'password'
100
- }
101
-
102
- return ret
103
- })
104
- }
105
- return []
106
- }
107
-
108
- /**
109
- * Reserve a payload type.
110
- */
111
- static reservePayloadType(payloadTypes: string[]): string {
112
- // everything is valid between 30 and 127, except for interval between 64 and 95
113
- // https://chromium.googlesource.com/external/webrtc/+/refs/heads/master/call/payload_type.h#29
114
- for (let i = 30; i <= 127; i++) {
115
- if ((i <= 63 || i >= 96) && !payloadTypes.includes(i.toString())) {
116
- const pl = i.toString()
117
- payloadTypes.push(pl)
118
- return pl
119
- }
120
- }
121
- throw new Error('unable to find a free payload type')
122
- }
123
- }
package/src/whep.ts DELETED
@@ -1,175 +0,0 @@
1
- import type { Conf, State } from './types'
2
- import { CodecDetector } from './core/codec'
3
- import { ConnectionManager } from './core/connection'
4
- import { HttpClient } from './core/http'
5
- import { TrackManager } from './core/track'
6
- import { ErrorTypes, WebRTCError } from './errors'
7
- import { FlowCheck } from './utils/flow-check'
8
-
9
- /** WebRTC/WHEP reader. */
10
- export default class WebRTCWhep {
11
- private retryPause: number = 2000
12
- private conf: Conf
13
- private state: State
14
- private restartTimeout?: NodeJS.Timeout
15
- private sessionUrl?: string
16
- private queuedCandidates: RTCIceCandidate[] = []
17
- private nonAdvertisedCodecs: string[] = []
18
- private flowCheck: FlowCheck
19
- private httpClient: HttpClient
20
- private connectionManager: ConnectionManager
21
- private trackManager: TrackManager
22
- private codecDetector: CodecDetector
23
-
24
- constructor(conf: Conf) {
25
- this.conf = conf
26
- this.state = 'getting_codecs'
27
-
28
- this.flowCheck = new FlowCheck({
29
- interval: 5000,
30
- onError: (err: WebRTCError) => this.handleError(err),
31
- })
32
-
33
- this.httpClient = new HttpClient(
34
- this.conf,
35
- () => this.state,
36
- err => this.handleError(err),
37
- )
38
-
39
- this.connectionManager = new ConnectionManager(
40
- () => this.state,
41
- {
42
- onCandidate: candidate => this.handleCandidate(candidate),
43
- onTrack: (evt) => {
44
- this.trackManager.onTrack(evt)
45
- this.flowCheck.start()
46
- },
47
- onError: err => this.handleError(err),
48
- },
49
- this.nonAdvertisedCodecs,
50
- )
51
-
52
- this.trackManager = new TrackManager(this.conf.container)
53
-
54
- this.codecDetector = new CodecDetector(
55
- () => this.state,
56
- {
57
- onCodecsDetected: codecs => this.handleCodecsDetected(codecs),
58
- onError: err => this.handleError(err),
59
- },
60
- )
61
-
62
- this.codecDetector.detect()
63
- }
64
-
65
- get isRunning(): boolean {
66
- return this.state === 'running'
67
- }
68
-
69
- close(): void {
70
- this.state = 'closed'
71
- this.connectionManager.close()
72
- this.trackManager.stop()
73
- this.flowCheck.stop()
74
- if (this.restartTimeout) {
75
- clearTimeout(this.restartTimeout)
76
- }
77
- }
78
-
79
- private handleError(err: Error | WebRTCError): void {
80
- this.flowCheck.stop()
81
-
82
- if (this.state === 'getting_codecs') {
83
- this.state = 'failed'
84
- }
85
- else if (err instanceof WebRTCError && err.type === ErrorTypes.SIGNAL_ERROR) {
86
- this.state = 'failed'
87
- }
88
- else if (this.state === 'running') {
89
- this.connectionManager.close()
90
- this.queuedCandidates = []
91
-
92
- if (this.sessionUrl) {
93
- fetch(this.sessionUrl, {
94
- method: 'DELETE',
95
- })
96
- this.sessionUrl = undefined
97
- }
98
-
99
- this.state = 'restarting'
100
-
101
- this.restartTimeout = setTimeout(() => {
102
- this.restartTimeout = undefined
103
- this.state = 'running'
104
- this.start()
105
- }, this.retryPause)
106
-
107
- err.message = `${err.message}, retrying in some seconds`
108
- }
109
-
110
- if (this.conf.onError) {
111
- if (err instanceof WebRTCError) {
112
- this.conf.onError(err)
113
- }
114
- else {
115
- this.conf.onError(new WebRTCError(ErrorTypes.OTHER_ERROR, err.message))
116
- }
117
- }
118
- }
119
-
120
- private handleCodecsDetected(codecs: string[]): void {
121
- this.nonAdvertisedCodecs = codecs
122
- this.state = 'running'
123
- this.start()
124
- }
125
-
126
- private start(): void {
127
- this.httpClient.requestICEServers()
128
- .then(iceServers => this.connectionManager.setupPeerConnection(iceServers))
129
- .then(offer => this.httpClient.sendOffer(offer))
130
- .then(({ sessionUrl, answer }) => this.handleOfferResponse(sessionUrl, answer))
131
- .catch(err => this.handleError(err))
132
- }
133
-
134
- private handleOfferResponse(sessionUrl: string | undefined, answer: string): Promise<void> {
135
- if (sessionUrl)
136
- this.sessionUrl = sessionUrl
137
-
138
- return this.connectionManager.setAnswer(answer).then(() => {
139
- if (this.state !== 'running')
140
- return
141
-
142
- if (this.queuedCandidates.length !== 0) {
143
- const offerData = this.connectionManager.getOfferData()
144
- if (offerData && this.sessionUrl) {
145
- this.httpClient.sendLocalCandidates(this.sessionUrl, offerData, this.queuedCandidates)
146
- this.queuedCandidates = []
147
- }
148
- }
149
- })
150
- }
151
-
152
- private handleCandidate(candidate: RTCIceCandidate): void {
153
- if (this.sessionUrl) {
154
- const offerData = this.connectionManager.getOfferData()
155
- if (offerData) {
156
- this.httpClient.sendLocalCandidates(this.sessionUrl, offerData, [candidate])
157
- }
158
- }
159
- else {
160
- this.queuedCandidates.push(candidate)
161
- }
162
- }
163
-
164
- get paused(): boolean {
165
- return this.trackManager.paused
166
- }
167
-
168
- pause(): void {
169
- this.trackManager.pause()
170
- }
171
-
172
- resume(): void {
173
- this.trackManager.resume()
174
- }
175
- }