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 +3 -1
- package/dist/index.js +1 -1
- package/package.json +4 -4
- package/.claude/settings.local.json +0 -9
- package/.vscode/settings.json +0 -38
- package/AGENTS.md +0 -92
- package/src/core/codec.ts +0 -33
- package/src/core/connection.ts +0 -103
- package/src/core/http.ts +0 -93
- package/src/core/track.ts +0 -51
- package/src/errors.ts +0 -27
- package/src/index.ts +0 -7
- package/src/types.ts +0 -37
- package/src/utils/flow-check.ts +0 -73
- package/src/utils/sdp.ts +0 -253
- package/src/utils/webrtc.ts +0 -123
- package/src/whep.ts +0 -175
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
|
-
|
|
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.
|
|
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.
|
|
34
|
+
"@types/node": "^25.0.9",
|
|
35
35
|
"eslint": "^9.39.2",
|
|
36
|
-
"eslint-plugin-format": "^1.1
|
|
37
|
-
"rollup": "^4.
|
|
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"
|
package/.vscode/settings.json
DELETED
|
@@ -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
|
-
}
|
package/src/core/connection.ts
DELETED
|
@@ -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
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
|
-
}
|
package/src/utils/flow-check.ts
DELETED
|
@@ -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
|
-
}
|
package/src/utils/webrtc.ts
DELETED
|
@@ -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
|
-
}
|