whepts 1.0.2 → 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.
@@ -0,0 +1,11 @@
1
+ import type { State } from '~/types';
2
+ export interface CodecDetectorCallbacks {
3
+ onCodecsDetected: (codecs: string[]) => void;
4
+ onError: (err: Error) => void;
5
+ }
6
+ export declare class CodecDetector {
7
+ private getState;
8
+ private callbacks;
9
+ constructor(getState: () => State, callbacks: CodecDetectorCallbacks);
10
+ detect(): void;
11
+ }
@@ -0,0 +1,24 @@
1
+ import type { ParsedOffer } from '../utils/sdp';
2
+ import type { State } from '~/types';
3
+ import { WebRTCError } from '~/errors';
4
+ export interface ConnectionManagerCallbacks {
5
+ onCandidate: (candidate: RTCIceCandidate) => void;
6
+ onTrack: (evt: RTCTrackEvent) => void;
7
+ onError: (err: WebRTCError) => void;
8
+ }
9
+ export declare class ConnectionManager {
10
+ private getState;
11
+ private callbacks;
12
+ private nonAdvertisedCodecs;
13
+ private pc?;
14
+ private offerData?;
15
+ constructor(getState: () => State, callbacks: ConnectionManagerCallbacks, nonAdvertisedCodecs?: string[]);
16
+ setupPeerConnection(iceServers: RTCIceServer[]): Promise<string>;
17
+ setAnswer(answer: string): Promise<void>;
18
+ getPeerConnection(): RTCPeerConnection | undefined;
19
+ getOfferData(): ParsedOffer | undefined;
20
+ close(): void;
21
+ private onLocalCandidate;
22
+ private onConnectionState;
23
+ private onIceConnectionState;
24
+ }
@@ -0,0 +1,16 @@
1
+ import type { ParsedOffer } from '../utils/sdp';
2
+ import type { Conf, State } from '~/types';
3
+ import { WebRTCError } from '~/errors';
4
+ export declare class HttpClient {
5
+ private config;
6
+ private getState;
7
+ private onError;
8
+ constructor(config: Conf, getState: () => State, onError: (err: Error | WebRTCError) => void);
9
+ private authHeader;
10
+ requestICEServers(): Promise<RTCIceServer[]>;
11
+ sendOffer(offer: string): Promise<{
12
+ sessionUrl?: string;
13
+ answer: string;
14
+ }>;
15
+ sendLocalCandidates(sessionUrl: string, offerData: ParsedOffer, candidates: RTCIceCandidate[]): void;
16
+ }
@@ -0,0 +1,12 @@
1
+ export declare class TrackManager {
2
+ private container;
3
+ private stream?;
4
+ private observer?;
5
+ constructor(container: HTMLMediaElement);
6
+ onTrack(evt: RTCTrackEvent): void;
7
+ private stopObserver;
8
+ get paused(): boolean;
9
+ pause(): void;
10
+ resume(): void;
11
+ stop(): void;
12
+ }
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.d.ts CHANGED
@@ -1,4 +1,5 @@
1
+ import type { Conf, State } from './types';
1
2
  import { ErrorTypes, WebRTCError } from './errors';
2
- import WebRTCWhep, { Conf } from './whep';
3
- export { Conf, ErrorTypes, WebRTCError };
3
+ import WebRTCWhep from './whep';
4
+ export { Conf, ErrorTypes, State, WebRTCError };
4
5
  export default WebRTCWhep;
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{start(e,t){e&&(this.stop(),this.observer=new IntersectionObserver(([e])=>t(e.isIntersecting),{threshold:.5}),this.observer.observe(e))}stop(){this.observer&&(this.observer.disconnect(),this.observer=void 0)}}class r{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 r=0;s.forEach(e=>{const t=e;"inbound-rtp"===e.type&&"video"===t.kind&&(r=t.bytesReceived||0)}),r!==this.lastBytesReceived||"connected"!==this.pc.connectionState?this.lastBytesReceived=r:this.onError(new t(e.NETWORK_ERROR,"data stream interruption"))}}class n{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 r=n.reservePayloadType(e);return s[0]+=` ${r}`,s.splice(s.length-1,0,`a=rtpmap:${r} PCMU/8000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${r} transport-cc`),r=n.reservePayloadType(e),s[0]+=` ${r}`,s.splice(s.length-1,0,`a=rtpmap:${r} PCMA/8000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${r} transport-cc`),s.join("\r\n")}static enableMultichannelOpus(e,t){const s=t.split("\r\n");let r=n.reservePayloadType(e);return s[0]+=` ${r}`,s.splice(s.length-1,0,`a=rtpmap:${r} multiopus/48000/3`),s.splice(s.length-1,0,`a=fmtp:${r} channel_mapping=0,2,1;num_streams=2;coupled_streams=1`),s.splice(s.length-1,0,`a=rtcp-fb:${r} transport-cc`),r=n.reservePayloadType(e),s[0]+=` ${r}`,s.splice(s.length-1,0,`a=rtpmap:${r} multiopus/48000/4`),s.splice(s.length-1,0,`a=fmtp:${r} channel_mapping=0,1,2,3;num_streams=2;coupled_streams=2`),s.splice(s.length-1,0,`a=rtcp-fb:${r} transport-cc`),r=n.reservePayloadType(e),s[0]+=` ${r}`,s.splice(s.length-1,0,`a=rtpmap:${r} multiopus/48000/5`),s.splice(s.length-1,0,`a=fmtp:${r} channel_mapping=0,4,1,2,3;num_streams=3;coupled_streams=2`),s.splice(s.length-1,0,`a=rtcp-fb:${r} transport-cc`),r=n.reservePayloadType(e),s[0]+=` ${r}`,s.splice(s.length-1,0,`a=rtpmap:${r} multiopus/48000/6`),s.splice(s.length-1,0,`a=fmtp:${r} channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2`),s.splice(s.length-1,0,`a=rtcp-fb:${r} transport-cc`),r=n.reservePayloadType(e),s[0]+=` ${r}`,s.splice(s.length-1,0,`a=rtpmap:${r} multiopus/48000/7`),s.splice(s.length-1,0,`a=fmtp:${r} channel_mapping=0,4,1,2,3,5,6;num_streams=4;coupled_streams=4`),s.splice(s.length-1,0,`a=rtcp-fb:${r} transport-cc`),r=n.reservePayloadType(e),s[0]+=` ${r}`,s.splice(s.length-1,0,`a=rtpmap:${r} multiopus/48000/8`),s.splice(s.length-1,0,`a=fmtp:${r} 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:${r} transport-cc`),s.join("\r\n")}static enableL16(e,t){const s=t.split("\r\n");let r=n.reservePayloadType(e);return s[0]+=` ${r}`,s.splice(s.length-1,0,`a=rtpmap:${r} L16/8000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${r} transport-cc`),r=n.reservePayloadType(e),s[0]+=` ${r}`,s.splice(s.length-1,0,`a=rtpmap:${r} L16/16000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${r} transport-cc`),r=n.reservePayloadType(e),s[0]+=` ${r}`,s.splice(s.length-1,0,`a=rtpmap:${r} L16/48000/2`),s.splice(s.length-1,0,`a=rtcp-fb:${r} 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="),r=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]=n.enableStereoOpus(s[e]),t.includes("pcma/8000/2")&&(s[e]=n.enableStereoPcmau(r,s[e])),t.includes("multiopus/48000/6")&&(s[e]=n.enableMultichannelOpus(r,s[e])),t.includes("L16/48000/2")&&(s[e]=n.enableL16(r,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 r=`a=ice-ufrag:${e.iceUfrag}\r\na=ice-pwd:${e.icePwd}\r\n`,n=0;for(const t of e.medias){if(void 0!==s[n]){r+=`m=${t}\r\na=mid:${n}\r\n`;for(const e of s[n])r+=`a=${e.candidate}\r\n`}n++}return r}}class i{static async supportsNonAdvertisedCodec(e,t){return new Promise(s=>{const r=new RTCPeerConnection({iceServers:[]}),n="audio";let a="";r.addTransceiver(n,{direction:"recvonly"}),r.createOffer().then(s=>{if(!s.sdp)throw new Error("SDP not present");if(s.sdp.includes(` ${e}`))throw new Error("already present");const o=s.sdp.split(`m=${n}`),c=o.slice(1).map(e=>e.split("\r\n")[0].split(" ").slice(3)).reduce((e,t)=>[...e,...t],[]);a=i.reservePayloadType(c);const h=o[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}`),o[1]=h.join("\r\n"),s.sdp=o.join(`m=${n}`),r.setLocalDescription(s)}).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=${n} 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(()=>s(!0)).catch(()=>s(!1)).finally(()=>r.close())})}static unquoteCredential(e){return JSON.parse(`"${e}"`)}static linkToIceServers(s){return s?s.split(", ").map(s=>{const r=s.match(/^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i);if(!r)throw new t(e.SIGNAL_ERROR,"Invalid ICE server link format");const n={urls:[r[1]]};return r[3]&&(n.username=i.unquoteCredential(r[3]),n.credential=i.unquoteCredential(r[4]),n.credentialType="password"),n}):[]}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 a{constructor(e){this.retryPause=2e3,this.queuedCandidates=[],this.nonAdvertisedCodecs=[],this.conf=e,this.state="getting_codecs",this.observer=new s,this.flowCheck=new r({interval:5e3,onError:e=>this.handleError(e)}),this.getNonAdvertisedCodecs()}get isRunning(){return"running"===this.state}close(){this.state="closed",this.pc?.close(),this.observer.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.pc?.close(),this.pc=void 0,this.offerData=void 0,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)))}getNonAdvertisedCodecs(){Promise.all([["pcma/8000/2"],["multiopus/48000/6","channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2"],["L16/48000/2"]].map(e=>i.supportsNonAdvertisedCodec(e[0],e[1]).then(t=>!!t&&e[0]))).then(e=>e.filter(e=>!1!==e)).then(s=>{if("getting_codecs"!==this.state)throw new t(e.STATE_ERROR,"closed");this.nonAdvertisedCodecs=s,this.state="running",this.start()}).catch(e=>this.handleError(e))}start(){this.requestICEServers().then(e=>this.setupPeerConnection(e)).then(e=>this.sendOffer(e)).then(e=>this.setAnswer(e)).catch(e=>this.handleError(e))}authHeader(){if(this.conf.user&&""!==this.conf.user){return{Authorization:`Basic ${btoa(`${this.conf.user}:${this.conf.pass}`)}`}}return this.conf.token&&""!==this.conf.token?{Authorization:`Bearer ${this.conf.token}`}:{}}async requestICEServers(){return this.conf.iceServers&&this.conf.iceServers.length>0?this.conf.iceServers:fetch(this.conf.url,{method:"OPTIONS",headers:{...this.authHeader()}}).then(e=>i.linkToIceServers(e.headers.get("Link")))}async setupPeerConnection(s){if("running"!==this.state)throw new t(e.STATE_ERROR,"closed");const r=new RTCPeerConnection({iceServers:s,sdpSemantics:"unified-plan"});this.pc=r,this.flowCheck.setPeerConnection(r);const i="recvonly";return r.addTransceiver("video",{direction:i}),r.addTransceiver("audio",{direction:i}),r.onicecandidate=e=>this.onLocalCandidate(e),r.onconnectionstatechange=()=>this.onConnectionState(),r.ontrack=e=>this.onTrack(e),r.createOffer().then(s=>{if(!s.sdp)throw new t(e.SIGNAL_ERROR,"Failed to create offer SDP");return s.sdp=n.editOffer(s.sdp,this.nonAdvertisedCodecs),this.offerData=n.parseOffer(s.sdp),r.setLocalDescription(s).then(()=>s.sdp)})}sendOffer(s){if("running"!==this.state)throw new t(e.STATE_ERROR,"closed");return fetch(this.conf.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 r=s.headers.get("Location");return r&&(this.sessionUrl=new URL(r,this.conf.url).toString()),s.text()})}setAnswer(s){if("running"!==this.state)throw new t(e.STATE_ERROR,"closed");return this.pc.setRemoteDescription(new RTCSessionDescription({type:"answer",sdp:s})).then(()=>{"running"===this.state&&0!==this.queuedCandidates.length&&(this.sendLocalCandidates(this.queuedCandidates),this.queuedCandidates=[])})}onLocalCandidate(e){"running"===this.state&&e.candidate&&(this.sessionUrl?this.sendLocalCandidates([e.candidate]):this.queuedCandidates.push(e.candidate))}sendLocalCandidates(s){this.sessionUrl&&this.offerData&&fetch(this.sessionUrl,{method:"PATCH",headers:{"Content-Type":"application/trickle-ice-sdpfrag","If-Match":"*"},body:n.generateSdpFragment(this.offerData,s)}).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.handleError(e))}onConnectionState(){"running"===this.state&&this.pc&&("failed"===this.pc.connectionState||"closed"===this.pc.connectionState?this.handleError(new t(e.OTHER_ERROR,"peer connection closed")):"connected"===this.pc.connectionState&&this.flowCheck.start())}onTrack(e){this.stream=e.streams[0],this.observer.start(this.conf.container,e=>{e?this.resume():this.pause()})}get paused(){return null===this.conf.container.srcObject}pause(){this.conf.container.srcObject=null}resume(){this.stream&&this.paused&&(this.conf.container.srcObject=this.stream)}}export{e as ErrorTypes,t as WebRTCError,a 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};
@@ -0,0 +1,33 @@
1
+ import type { WebRTCError } from './errors';
2
+ /**
3
+ * Configuration interface for WebRTCWhep.
4
+ */
5
+ export interface Conf {
6
+ /** Absolute URL of the WHEP endpoint */
7
+ url: string;
8
+ /** Media player container */
9
+ container: HTMLMediaElement;
10
+ /** Username for authentication */
11
+ user?: string;
12
+ /** Password for authentication */
13
+ pass?: string;
14
+ /** Token for authentication */
15
+ token?: string;
16
+ /** ice server list */
17
+ iceServers?: RTCIceServer[];
18
+ /** Called when there's an error */
19
+ onError?: (err: WebRTCError) => void;
20
+ }
21
+ /**
22
+ * State type for WebRTCWhep.
23
+ */
24
+ export type State = 'getting_codecs' | 'running' | 'restarting' | 'closed' | 'failed';
25
+ /** Extend RTCConfiguration to include experimental properties */
26
+ declare global {
27
+ interface RTCConfiguration {
28
+ sdpSemantics?: 'plan-b' | 'unified-plan';
29
+ }
30
+ interface RTCIceServer {
31
+ credentialType?: 'password';
32
+ }
33
+ }
package/dist/whep.d.ts CHANGED
@@ -1,117 +1,27 @@
1
- import { WebRTCError } from './errors';
2
- /**
3
- * Configuration interface for WebRTCWhep.
4
- */
5
- export interface Conf {
6
- /** Absolute URL of the WHEP endpoint */
7
- url: string;
8
- /** Media player container */
9
- container: HTMLMediaElement;
10
- /** Username for authentication */
11
- user?: string;
12
- /** Password for authentication */
13
- pass?: string;
14
- /** Token for authentication */
15
- token?: string;
16
- /** ice server list */
17
- iceServers?: RTCIceServer[];
18
- /** Called when there's an error */
19
- onError?: (err: WebRTCError) => void;
20
- }
21
- /** Extend RTCConfiguration to include experimental properties */
22
- declare global {
23
- interface RTCConfiguration {
24
- sdpSemantics?: 'plan-b' | 'unified-plan';
25
- }
26
- interface RTCIceServer {
27
- credentialType?: 'password';
28
- }
29
- }
1
+ import type { Conf } from './types';
30
2
  /** WebRTC/WHEP reader. */
31
3
  export default class WebRTCWhep {
32
4
  private retryPause;
33
5
  private conf;
34
6
  private state;
35
7
  private restartTimeout?;
36
- private pc?;
37
- private offerData?;
38
8
  private sessionUrl?;
39
9
  private queuedCandidates;
40
10
  private nonAdvertisedCodecs;
41
- private observer;
42
- private stream?;
43
11
  private flowCheck;
44
- /**
45
- * Create a WebRTCWhep.
46
- * @param {Conf} conf - Configuration.
47
- */
12
+ private httpClient;
13
+ private connectionManager;
14
+ private trackManager;
15
+ private codecDetector;
48
16
  constructor(conf: Conf);
49
- /**
50
- * 媒体是否正常
51
- */
52
17
  get isRunning(): boolean;
53
- /**
54
- * Close the reader and all its resources.
55
- */
56
18
  close(): void;
57
- /**
58
- * Handle errors.
59
- */
60
19
  private handleError;
61
- /**
62
- * Get non-advertised codecs.
63
- */
64
- private getNonAdvertisedCodecs;
65
- /**
66
- * Start the WebRTC session.
67
- */
20
+ private handleCodecsDetected;
68
21
  private start;
69
- /**
70
- * Generate an authorization header.
71
- */
72
- private authHeader;
73
- /**
74
- * Request ICE servers from the endpoint.
75
- */
76
- private requestICEServers;
77
- /**
78
- * Setup a peer connection.
79
- */
80
- private setupPeerConnection;
81
- /**
82
- * Send an offer to the endpoint.
83
- */
84
- private sendOffer;
85
- /**
86
- * Set a remote answer.
87
- */
88
- private setAnswer;
89
- /**
90
- * Handle local ICE candidates.
91
- */
92
- private onLocalCandidate;
93
- /**
94
- * Send local ICE candidates to the endpoint.
95
- */
96
- private sendLocalCandidates;
97
- /**
98
- * Handle peer connection state changes.
99
- */
100
- private onConnectionState;
101
- /**
102
- * Handle incoming tracks.
103
- */
104
- private onTrack;
105
- /**
106
- * 流是否为空
107
- */
22
+ private handleOfferResponse;
23
+ private handleCandidate;
108
24
  get paused(): boolean;
109
- /**
110
- * 暂停播放
111
- */
112
25
  pause(): void;
113
- /**
114
- * 恢复播放
115
- */
116
26
  resume(): void;
117
27
  }
package/package.json CHANGED
@@ -1,8 +1,7 @@
1
1
  {
2
2
  "name": "whepts",
3
3
  "type": "module",
4
- "version": "1.0.2",
5
- "packageManager": "pnpm@10.26.2",
4
+ "version": "1.0.4",
6
5
  "description": "基于 mediamtx 的 WebRTC WHEP 播放器,支持 ZLM 和 Mediamtx 的播放地址",
7
6
  "author": "mapleafgo",
8
7
  "license": "MIT",
@@ -26,24 +25,24 @@
26
25
  ],
27
26
  "main": "dist/index.js",
28
27
  "types": "dist/index.d.ts",
29
- "scripts": {
30
- "build": "rollup --config --environment NODE_ENV:production",
31
- "build:debug": "rollup --config",
32
- "lint": "eslint",
33
- "lint:fix": "eslint --fix"
34
- },
35
28
  "devDependencies": {
36
29
  "@antfu/eslint-config": "^6.7.3",
37
30
  "@rollup/plugin-commonjs": "^29.0.0",
38
31
  "@rollup/plugin-eslint": "^9.2.0",
39
32
  "@rollup/plugin-terser": "^0.4.4",
40
33
  "@rollup/plugin-typescript": "^12.3.0",
41
- "@types/node": "^25.0.3",
34
+ "@types/node": "^25.0.9",
42
35
  "eslint": "^9.39.2",
43
- "eslint-plugin-format": "^1.1.0",
44
- "rollup": "^4.54.0",
36
+ "eslint-plugin-format": "^1.3.1",
37
+ "rollup": "^4.55.3",
45
38
  "rollup-plugin-delete": "^3.0.2",
46
39
  "tslib": "^2.8.1",
47
40
  "typescript": "^5.9.3"
41
+ },
42
+ "scripts": {
43
+ "build": "rollup --config --environment NODE_ENV:production",
44
+ "build:debug": "rollup --config",
45
+ "lint": "eslint",
46
+ "lint:fix": "eslint --fix"
48
47
  }
49
- }
48
+ }
@@ -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/QWEN.md DELETED
@@ -1,118 +0,0 @@
1
- # QWEN.md - WhepTS Player 项目上下文
2
-
3
- ## 项目概述
4
-
5
- **WhepTS Player** 是一个基于 [mediamtx](https://github.com/bluenviron/mediamtx) 的 WebRTC WHEP (WebRTC HTTP Egress Protocol) 播放器,支持 ZLMediaKit 和 Mediamtx 的播放地址。该项目使用 TypeScript 编写,提供低延迟的实时音视频流播放功能。
6
-
7
- ### 核心功能
8
-
9
- - 支持 ZLMediaKit 和 Mediamtx 的 WHEP 播放地址
10
- - 基于 WebRTC 的低延迟播放
11
- - 类型安全的 TypeScript 实现
12
- - 事件监听和错误处理机制
13
- - 可配置的连接参数
14
- - 自动断流检测与重连功能
15
- - 支持多种音视频编解码器
16
- - 支持仅可视区域播放控制(通过 `VisibilityObserver`)
17
- - 在 Chrome 上根据硬件支持 G711 和 H265 编解码器
18
-
19
- ## 技术栈与依赖
20
-
21
- - **语言**: TypeScript
22
- - **构建工具**: Rollup
23
- - **包管理器**: pnpm
24
- - **代码规范**: ESLint (使用 @antfu/eslint-config)
25
- - **核心 API**: WebRTC API
26
- - **开发依赖**:
27
- - `@antfu/eslint-config`
28
- - `@rollup/plugin-commonjs`
29
- - `@rollup/plugin-eslint`
30
- - `@rollup/plugin-terser`
31
- - `@rollup/plugin-typescript`
32
- - `@types/node`
33
- - `rollup`
34
- - `rollup-plugin-delete`
35
- - `tslib`
36
- - `typescript`
37
-
38
- ## 项目结构
39
-
40
- ```
41
- src/
42
- ├── index.ts # 主要的 WebRTCWhep 类实现
43
- └── utils/
44
- └── observer.ts # 可见性监测工具
45
- ```
46
-
47
- ## 主要代码文件
48
-
49
- ### `src/index.ts`
50
-
51
- 这是项目的核心文件,实现了 `WebRTCWhep` 类,负责处理 WebRTC 连接、WHEP 协议交互、媒体流处理等主要功能。关键特性包括:
52
-
53
- - **配置接口**: `Conf` 接口定义了播放器的配置选项,包括 WHEP 服务器地址、媒体容器、认证信息、ICE 服务器配置等。
54
- - **错误处理**: 定义了 `WebRTCError` 类和 `ErrorTypes` 常量,用于处理不同类型的错误(信令、状态、网络、媒体等)。
55
- - **连接管理**: 实现了与 WHEP 服务器的连接、发送 offer、接收 answer、处理 ICE 候选等流程。
56
- - **编解码器支持**: 包含了对非标准编解码器(如 PCMA/PCMU、multiopus、L16 等)的支持逻辑。
57
- - **断流检测**: 通过 `getStats()` 监控接收字节数,实现断流检测和自动重连。
58
- - **可见性控制**: 与 `VisibilityObserver` 集成,实现仅在元素可见时播放的功能。
59
-
60
- ### `src/utils/observer.ts`
61
-
62
- 实现了 `VisibilityObserver` 类,使用 `IntersectionObserver` API 来监测媒体元素的可见性,以便在元素进入或离开视口时暂停或恢复播放。
63
-
64
- ## 构建与开发
65
-
66
- ### 构建命令
67
-
68
- - `npm run build` - 生产环境构建(压缩代码)
69
- - `npm run build:debug` - 开发环境构建(保留源映射)
70
-
71
- ### 开发命令
72
-
73
- - `npm run dev` - 开发模式(如果 package.json 中有定义)
74
- - `npm run lint` - 代码检查
75
- - `npm run lint:fix` - 自动修复代码问题
76
-
77
- ### 构建配置
78
-
79
- - **Rollup**: 使用 `rollup.config.ts` 进行构建配置,输出格式为 ES 模块,目标文件为 `dist/index.js`。
80
- - **TypeScript**: 使用 `tsconfig.json` 进行编译配置,目标为 ES2020,启用严格模式和类型检查。
81
- - **ESLint**: 使用 `eslint.config.ts` 进行代码规范检查,基于 @antfu/eslint-config。
82
-
83
- ## API 使用
84
-
85
- ### WebRTCWhep 类
86
-
87
- #### 构造函数
88
-
89
- ```typescript
90
- WebRTCWhep(conf: Conf)
91
- ```
92
-
93
- - `conf`: 配置对象,包含 `url`、`container`、`user`、`pass`、`token`、`iceServers`、`onError` 等属性。
94
-
95
- #### 属性
96
-
97
- - `isRunning`: 检查流是否正在运行
98
- - `paused`: 检查播放器是否已暂停
99
-
100
- #### 方法
101
-
102
- - `close()`: 关闭播放器和所有资源
103
- - `pause()`: 暂停播放
104
- - `resume()`: 恢复播放
105
-
106
- ## 开发约定
107
-
108
- - 代码使用 TypeScript 编写,遵循严格的类型检查。
109
- - 使用 ESLint 进行代码风格和质量检查。
110
- - 使用 Rollup 进行模块打包。
111
- - 代码结构清晰,核心逻辑集中在 `src/index.ts`,工具类在 `src/utils/` 目录下。
112
- - 错误处理采用自定义的 `WebRTCError` 类,便于区分不同类型的错误。
113
- - 支持自动断流检测和重连,提高播放稳定性。
114
- - 支持可见性控制,优化资源使用。
115
-
116
- ## 许可证
117
-
118
- MIT
@@ -1,8 +0,0 @@
1
- /**
2
- * 可见性监测(确保回调执行完成后再处理后续,不丢弃任务)
3
- */
4
- export default class VisibilityObserver {
5
- private observer?;
6
- start(element: HTMLElement, callback: (isIntersecting: boolean) => void): void;
7
- stop(): void;
8
- }
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,6 +0,0 @@
1
- import { ErrorTypes, WebRTCError } from './errors'
2
- import WebRTCWhep, { Conf } from './whep'
3
-
4
- export { Conf, ErrorTypes, WebRTCError }
5
-
6
- export default WebRTCWhep
@@ -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
- }
@@ -1,28 +0,0 @@
1
- /**
2
- * 可见性监测(确保回调执行完成后再处理后续,不丢弃任务)
3
- */
4
- export default class VisibilityObserver {
5
- private observer?: IntersectionObserver
6
-
7
- start(element: HTMLElement, callback: (isIntersecting: boolean) => void) {
8
- if (!element)
9
- return
10
-
11
- // 先停止之前的任务
12
- this.stop()
13
-
14
- this.observer = new IntersectionObserver(
15
- ([entry]) => callback(entry.isIntersecting),
16
- { threshold: 0.5 },
17
- )
18
-
19
- this.observer.observe(element)
20
- }
21
-
22
- stop() {
23
- if (this.observer) {
24
- this.observer.disconnect()
25
- this.observer = undefined
26
- }
27
- }
28
- }