whepts 1.1.5 → 1.1.6

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/README.md CHANGED
@@ -19,8 +19,11 @@ WebRTC WHEP (WebRTC HTTP Egress Protocol) 是一种用于从 WebRTC 服务器获
19
19
  - 自动断流检测与重连
20
20
  - 支持动态更新播放地址(用于故障切换)
21
21
  - 支持多种音视频编解码器
22
- - 支持仅可视区域播放控制
22
+ - 支持非公告编解码器自动检测(G.711 A-law、多声道 Opus、线性 PCM)
23
+ - 自适应流健康监测(初始高频检测,稳定后低频检测)
24
+ - 支持仅可视区域播放控制(IntersectionObserver,50% 阈值)
23
25
  - 在 Chrome 上根据硬件支持 G711 和 H265 编解码器
26
+ - 自动 ICE 候选队列管理
24
27
 
25
28
  ## 安装
26
29
 
@@ -45,6 +48,7 @@ import WebRTCWhep from 'whepts'
45
48
  const config = {
46
49
  url: 'https://your-server:port/index/api/whep?app={app}&stream={stream}', // WHEP 服务器地址
47
50
  container: document.getElementById('video') as HTMLMediaElement, // 视频播放容器
51
+ lazyLoad: true, // 启用懒加载(自动暂停/恢复),默认:true
48
52
  }
49
53
 
50
54
  // 创建播放器实例
@@ -82,13 +86,19 @@ const config = {
82
86
  {
83
87
  urls: ['stun:stun.l.google.com:19302']
84
88
  }
85
- ]
89
+ ],
90
+ lazyLoad: true, // 启用懒加载(自动暂停/恢复),默认:true(可选)
86
91
  }
87
92
 
88
93
  // 创建播放器实例
89
94
  const player = new WebRTCWhep(config)
90
95
 
91
96
  // 监听所有事件
97
+ player.on('codecs:detected', (codecs) => {
98
+ console.log('检测到的非公告编解码器:', codecs)
99
+ // 可能包含: 'pcma/8000/2', 'multiopus/48000/6', 'L16/48000/2'
100
+ })
101
+
92
102
  player.on('state:change', ({ from, to }) => {
93
103
  console.log(`状态: ${from} → ${to}`)
94
104
  })
@@ -185,10 +195,16 @@ WebRTCWhep(conf)
185
195
  - `pass`: 认证密码(可选)
186
196
  - `token`: 认证令牌(可选)
187
197
  - `iceServers`: ICE 服务器配置(可选)
198
+ - `lazyLoad`: 启用懒加载(自动暂停/恢复),默认:true(可选)
188
199
 
189
200
  #### 事件
190
201
 
191
202
  ```typescript
203
+ // 编解码器检测事件
204
+ player.on('codecs:detected', (codecs) => {
205
+ console.log('检测到的非公告编解码器:', codecs)
206
+ })
207
+
192
208
  // 状态变化事件
193
209
  player.on('state:change', ({ from, to }) => {
194
210
  console.log(`状态: ${from} → ${to}`)
@@ -221,6 +237,7 @@ player.on('restart', () => {
221
237
  ```
222
238
 
223
239
  **事件类型:**
240
+ - `codecs:detected`: 检测到浏览器支持的非公告编解码器,包含编解码器列表
224
241
  - `state:change`: 状态变化,包含 `from` 和 `to` 状态
225
242
  - `candidate`: ICE 候选
226
243
  - `track`: 媒体轨道
@@ -0,0 +1,15 @@
1
+ import type { EventEmitter } from 'eventemitter3';
2
+ /**
3
+ * PlayController handles media element playback logic including auto-play,
4
+ * error handling, and muted retry.
5
+ */
6
+ export declare class PlayController {
7
+ private container;
8
+ private eventEmitter;
9
+ constructor(container: HTMLMediaElement, eventEmitter: EventEmitter);
10
+ /**
11
+ * Attempt to play the media. Handles auto-play policy by automatically
12
+ * muting and retrying if the first attempt fails.
13
+ */
14
+ play(): Promise<void>;
15
+ }
@@ -1,10 +1,12 @@
1
+ import type { EventEmitter } from 'eventemitter3';
1
2
  export declare class TrackManager {
2
3
  private container;
3
4
  private lazyLoad;
4
5
  private stream?;
5
6
  private observer?;
6
7
  private showStore;
7
- constructor(container: HTMLMediaElement, lazyLoad?: boolean);
8
+ private playController;
9
+ constructor(container: HTMLMediaElement, eventEmitter: EventEmitter, lazyLoad?: boolean);
8
10
  onTrack(evt: RTCTrackEvent): void;
9
11
  get paused(): boolean;
10
12
  pause(): void;
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- import t from"eventemitter3";import{atom as e}from"nanostores";const s={SIGNAL_ERROR:"SignalError",STATE_ERROR:"StateError",REQUEST_ERROR:"RequestError",NOT_FOUND_ERROR:"NotFoundError",CONNECT_ERROR:"ConnectError",MEDIA_ERROR:"MediaError",OTHER_ERROR:"OtherError"};class n extends Error{constructor(t,e,s){super(e,s),this.type=t}}class i{static async supportsNonAdvertisedCodec(t,e){return new Promise(s=>{const n=new RTCPeerConnection({iceServers:[]}),r="audio";let o="";n.addTransceiver(r,{direction:"recvonly"}),n.createOffer().then(s=>{if(!s.sdp)throw new Error("SDP not present");if(s.sdp.includes(` ${t}`))throw new Error("already present");const a=s.sdp.split(`m=${r}`),c=a.slice(1).map(t=>t.split("\r\n")[0].split(" ").slice(3)).reduce((t,e)=>[...t,...e],[]);o=i.reservePayloadType(c);const h=a[1].split("\r\n");return h[0]+=` ${o}`,h.splice(h.length-1,0,`a=rtpmap:${o} ${t}`),void 0!==e&&h.splice(h.length-1,0,`a=fmtp:${o} ${e}`),a[1]=h.join("\r\n"),s.sdp=a.join(`m=${r}`),n.setLocalDescription(s)}).then(()=>n.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=${r} 9 UDP/TLS/RTP/SAVPF ${o}\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:${o} ${t}\r\n${void 0!==e?`a=fmtp:${o} ${e}\r\n`:""}`}))).then(()=>s(!0)).catch(()=>s(!1)).finally(()=>n.close())})}static unquoteCredential(t){return JSON.parse(`"${t}"`)}static linkToIceServers(t){return t?t.split(", ").map(t=>{const e=t.match(/^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i);if(!e)throw new n(s.SIGNAL_ERROR,"Invalid ICE server link format");const r={urls:[e[1]]};return e[3]&&(r.username=i.unquoteCredential(e[3]),r.credential=i.unquoteCredential(e[4]),r.credentialType="password"),r}):[]}static reservePayloadType(t){for(let e=30;e<=127;e++)if((e<=63||e>=96)&&!t.includes(e.toString())){const s=e.toString();return t.push(s),s}throw new Error("unable to find a free payload type")}}class r{constructor(t){this.options=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(t=>i.supportsNonAdvertisedCodec(t[0],t[1]).then(e=>!!e&&t[0]))).then(t=>t.filter(t=>!1!==t)).then(t=>{if("getting_codecs"!==this.options.getState())throw new n(s.STATE_ERROR,"closed");this.options.emitter.emit("codecs:detected",t)}).catch(t=>this.options.emitter.emit("error",t))}}class o{static parseOffer(t){const e={iceUfrag:"",icePwd:"",medias:[]};for(const s of t.split("\r\n"))s.startsWith("m=")?e.medias.push(s.slice(2)):""===e.iceUfrag&&s.startsWith("a=ice-ufrag:")?e.iceUfrag=s.slice(12):""===e.icePwd&&s.startsWith("a=ice-pwd:")&&(e.icePwd=s.slice(10));return e}static reservePayloadType(t){for(let e=30;e<=127;e++)if((e<=63||e>=96)&&!t.includes(e.toString())){const s=e.toString();return t.push(s),s}throw new Error("unable to find a free payload type")}static enableStereoPcmau(t,e){const s=e.split("\r\n");let n=o.reservePayloadType(t);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=o.reservePayloadType(t),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(t,e){const s=e.split("\r\n");let n=o.reservePayloadType(t);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=o.reservePayloadType(t),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=o.reservePayloadType(t),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=o.reservePayloadType(t),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=o.reservePayloadType(t),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=o.reservePayloadType(t),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(t,e){const s=e.split("\r\n");let n=o.reservePayloadType(t);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=o.reservePayloadType(t),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=o.reservePayloadType(t),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(t){let e="";const s=t.split("\r\n");for(let t=0;t<s.length;t++)if(s[t].startsWith("a=rtpmap:")&&s[t].toLowerCase().includes("opus/")){e=s[t].slice(9).split(" ")[0];break}if(""===e)return t;for(let t=0;t<s.length;t++)s[t].startsWith(`a=fmtp:${e} `)&&(s[t].includes("stereo")||(s[t]+=";stereo=1"),s[t].includes("sprop-stereo")||(s[t]+=";sprop-stereo=1"));return s.join("\r\n")}static editOffer(t,e){const s=t.split("m="),n=s.slice(1).map(t=>t.split("\r\n")[0].split(" ").slice(3)).reduce((t,e)=>[...t,...e],[]);for(let t=1;t<s.length;t++)if(s[t].startsWith("audio")){s[t]=o.enableStereoOpus(s[t]),e.includes("pcma/8000/2")&&(s[t]=o.enableStereoPcmau(n,s[t])),e.includes("multiopus/48000/6")&&(s[t]=o.enableMultichannelOpus(n,s[t])),e.includes("L16/48000/2")&&(s[t]=o.enableL16(n,s[t]));break}return s.join("m=")}static generateSdpFragment(t,e){const s={};for(const t of e){const e=t.sdpMLineIndex;e&&(void 0===s[e]&&(s[e]=[]),s[e].push(t))}let n=`a=ice-ufrag:${t.iceUfrag}\r\na=ice-pwd:${t.icePwd}\r\n`,i=0;for(const e of t.medias){if(void 0!==s[i]){n+=`m=${e}\r\na=mid:${i}\r\n`;for(const t of s[i])n+=`a=${t.candidate}\r\n`}i++}return n}}class a{constructor(t){this.options=t}async setupPeerConnection(t){if("running"!==this.options.getState())throw new n(s.STATE_ERROR,"closed");const e=new RTCPeerConnection({iceServers:t,sdpSemantics:"unified-plan"});this.pc=e;const i="recvonly";return e.addTransceiver("video",{direction:i}),e.addTransceiver("audio",{direction:i}),e.onicecandidate=t=>this.onLocalCandidate(t),e.onconnectionstatechange=()=>this.onConnectionState(),e.oniceconnectionstatechange=()=>this.onIceConnectionState(),e.ontrack=t=>this.options.emitter.emit("track",t),e.createOffer().then(t=>{if(!t.sdp)throw new n(s.SIGNAL_ERROR,"Failed to create offer SDP");return t.sdp=o.editOffer(t.sdp,this.options.getNonAdvertisedCodecs()),this.offerData=o.parseOffer(t.sdp),e.setLocalDescription(t).then(()=>t.sdp)})}async setAnswer(t){if("running"!==this.options.getState())throw new n(s.STATE_ERROR,"closed");return this.pc.setRemoteDescription(new RTCSessionDescription({type:"answer",sdp:t}))}getPeerConnection(){return this.pc}getOfferData(){return this.offerData}close(){this.pc?.close(),this.pc=void 0,this.offerData=void 0}onLocalCandidate(t){"running"===this.options.getState()&&t.candidate&&this.options.emitter.emit("candidate",t.candidate)}onConnectionState(){"running"===this.options.getState()&&this.pc&&("failed"!==this.pc.connectionState&&"closed"!==this.pc.connectionState||this.options.emitter.emit("error",new n(s.OTHER_ERROR,"peer connection closed")))}onIceConnectionState(){"running"===this.options.getState()&&this.pc&&"failed"===this.pc.iceConnectionState&&this.pc.restartIce()}}class c{constructor(t){this.options=t}authHeader(){if(this.options.conf.user&&""!==this.options.conf.user){return{Authorization:`Basic ${btoa(`${this.options.conf.user}:${this.options.conf.pass}`)}`}}return this.options.conf.token&&""!==this.options.conf.token?{Authorization:`Bearer ${this.options.conf.token}`}:{}}async requestICEServers(){return this.options.conf.iceServers&&this.options.conf.iceServers.length>0?this.options.conf.iceServers:fetch(this.options.conf.url,{method:"OPTIONS",headers:{...this.authHeader()}}).then(t=>i.linkToIceServers(t.headers.get("Link")))}async sendOffer(t){if("running"!==this.options.getState())throw new n(s.STATE_ERROR,"closed");return fetch(this.options.conf.url,{method:"POST",headers:{...this.authHeader(),"Content-Type":"application/sdp"},body:t}).then(t=>{switch(t.status){case 201:break;case 404:case 406:throw new n(s.NOT_FOUND_ERROR,"stream not found");case 400:return t.json().then(t=>{throw new n(s.REQUEST_ERROR,t.error)});default:throw new n(s.REQUEST_ERROR,`bad status code ${t.status}`)}const e=t.headers.get("Location"),i=e?new URL(e,this.options.conf.url).toString():void 0;return t.text().then(t=>({sessionUrl:i,answer:t}))})}sendLocalCandidates(t,e,i){fetch(t,{method:"PATCH",headers:{"Content-Type":"application/trickle-ice-sdpfrag","If-Match":"*"},body:o.generateSdpFragment(e,i)}).then(t=>{switch(t.status){case 204:break;case 404:throw new n(s.NOT_FOUND_ERROR,"stream not found");default:throw new n(s.REQUEST_ERROR,`bad status code ${t.status}`)}}).catch(t=>this.options.emitter.emit("error",t))}}class h{constructor(t,s=!0){this.container=t,this.lazyLoad=s,this.showStore=e(!1),this.showStore.subscribe(t=>{t?this.resume():this.pause()}),this.lazyLoad?(this.observer=new IntersectionObserver(([t])=>{t.isIntersecting?this.showStore.set(!0):this.showStore.set(!1)},{threshold:.5}),this.observer.observe(this.container)):this.showStore.set(!0)}onTrack(t){this.stream=t.streams[0],this.showStore.get()&&(this.container.srcObject=this.stream)}get paused(){return null===this.container.srcObject}pause(){this.container.srcObject=null}resume(){this.stream&&(this.container.srcObject=this.stream)}stop(){this.stream=void 0}}class l{constructor(t){this.options=t,this.lastBytesReceived=0,this.consecutiveNoProgress=0,this.startTime=0,this.isStable=!1,this.baseInterval=t.interval,this.stableInterval=t.stableInterval||2*t.interval,this.maxNoProgress=t.maxNoProgress||3,this.stabilizationTime=t.stabilizationTime||3e4}setPeerConnection(t){this.pc=t}start(){this.close(),this.startTime=Date.now(),this.isStable=!1,this.consecutiveNoProgress=0,this.scheduleNextCheck()}close(){this.checkTimer&&(clearTimeout(this.checkTimer),this.checkTimer=void 0),this.pc=void 0}scheduleNextCheck(){const t=this.getNextCheckInterval();this.checkTimer=setTimeout(()=>{this.checkFlowState().then(()=>{this.pc&&"connected"===this.pc.connectionState&&this.scheduleNextCheck()})},t)}getNextCheckInterval(){const t=Date.now()-this.startTime;return this.isStable=t>this.stabilizationTime,this.isStable?this.stableInterval:this.baseInterval}async checkFlowState(){if(!this.pc)return;if("connected"!==this.pc.connectionState)return;const t=await this.pc.getStats();let e=0;if(t.forEach(t=>{const s=t;"inbound-rtp"===t.type&&"video"===s.kind&&(e=s.bytesReceived||0)}),e===this.lastBytesReceived){if(this.consecutiveNoProgress++,this.consecutiveNoProgress>=this.maxNoProgress)return this.options.emitter.emit("error",new n(s.CONNECT_ERROR,"data stream interruption")),void(this.consecutiveNoProgress=0)}else this.consecutiveNoProgress=0;this.lastBytesReceived=e}}class p extends t{constructor(t){super(),this.retryPause=2e3,this.stateStore=e("getting_codecs"),this.queuedCandidates=[],this.nonAdvertisedCodecs=[],this.conf=t,this.stateStore.subscribe((t,e)=>{this.emit("state:change",{from:e,to:t})}),this.trackManager=new h(this.conf.container,this.conf.lazyLoad),this.flowCheck=new l({interval:5e3,emitter:this}),this.httpClient=new c({conf:this.conf,getState:()=>this.stateStore.get(),emitter:this}),this.connectionManager=new a({getState:()=>this.stateStore.get(),emitter:this,getNonAdvertisedCodecs:()=>this.nonAdvertisedCodecs}),this.codecDetector=new r({getState:()=>this.stateStore.get(),emitter:this}),this.on("codecs:detected",t=>{this.handleCodecsDetected(t)}),this.on("candidate",t=>this.handleCandidate(t)),this.on("track",t=>{this.trackManager.onTrack(t),this.flowCheck.start()}),this.on("error",t=>this.handleError(t)),this.codecDetector.detect()}get state(){return this.stateStore.get()}close(){this.stateStore.set("closed"),this.connectionManager.close(),this.trackManager.stop(),this.flowCheck.close(),this.restartTimeout&&clearTimeout(this.restartTimeout),this.emit("close")}cleanupSession(){this.restartTimeout&&(clearTimeout(this.restartTimeout),this.restartTimeout=void 0),this.connectionManager.close(),this.flowCheck.close(),this.queuedCandidates=[],this.sessionUrl&&(fetch(this.sessionUrl,{method:"DELETE"}).catch(()=>{}),this.sessionUrl=void 0)}handleError(t){this.flowCheck.close(),"getting_codecs"===this.stateStore.get()||t instanceof n&&[s.SIGNAL_ERROR,s.NOT_FOUND_ERROR,s.REQUEST_ERROR].includes(t.type)?this.stateStore.set("failed"):"running"===this.stateStore.get()&&(this.cleanupSession(),this.stateStore.set("restarting"),this.emit("restart"),this.restartTimeout=setTimeout(()=>{this.restartTimeout=void 0,this.stateStore.set("running"),this.start()},this.retryPause))}handleCodecsDetected(t){this.nonAdvertisedCodecs=t,this.stateStore.set("running"),this.start()}start(){this.httpClient.requestICEServers().then(t=>this.connectionManager.setupPeerConnection(t)).then(t=>{const e=this.connectionManager.getPeerConnection();return e&&this.flowCheck.setPeerConnection(e),t}).then(t=>this.httpClient.sendOffer(t)).then(({sessionUrl:t,answer:e})=>this.handleOfferResponse(t,e)).catch(t=>this.handleError(t))}handleOfferResponse(t,e){return t&&(this.sessionUrl=t),this.connectionManager.setAnswer(e).then(()=>{if("running"===this.stateStore.get()&&0!==this.queuedCandidates.length){const t=this.connectionManager.getOfferData();t&&this.sessionUrl&&(this.httpClient.sendLocalCandidates(this.sessionUrl,t,this.queuedCandidates),this.queuedCandidates=[])}})}handleCandidate(t){if(this.sessionUrl){const e=this.connectionManager.getOfferData();e&&this.httpClient.sendLocalCandidates(this.sessionUrl,e,[t])}else this.queuedCandidates.push(t)}get paused(){return this.trackManager.paused}pause(){this.trackManager.pause()}resume(){this.trackManager.resume()}updateUrl(t){"closed"!==this.state?(this.conf.url=t,this.cleanupSession(),this.stateStore.set("running"),this.start()):this.emit("error",new n(s.OTHER_ERROR,"Cannot update URL: instance is closed"))}}export{s as ErrorTypes,n as WebRTCError,p as default};
1
+ import t from"eventemitter3";import{atom as e}from"nanostores";const s={SIGNAL_ERROR:"SignalError",STATE_ERROR:"StateError",REQUEST_ERROR:"RequestError",NOT_FOUND_ERROR:"NotFoundError",CONNECT_ERROR:"ConnectError",MEDIA_ERROR:"MediaError",OTHER_ERROR:"OtherError"};class n extends Error{constructor(t,e,s){super(e,s),this.type=t}}class i{static async supportsNonAdvertisedCodec(t,e){return new Promise(s=>{const n=new RTCPeerConnection({iceServers:[]}),r="audio";let o="";n.addTransceiver(r,{direction:"recvonly"}),n.createOffer().then(s=>{if(!s.sdp)throw new Error("SDP not present");if(s.sdp.includes(` ${t}`))throw new Error("already present");const a=s.sdp.split(`m=${r}`),c=a.slice(1).map(t=>t.split("\r\n")[0].split(" ").slice(3)).reduce((t,e)=>[...t,...e],[]);o=i.reservePayloadType(c);const h=a[1].split("\r\n");return h[0]+=` ${o}`,h.splice(h.length-1,0,`a=rtpmap:${o} ${t}`),void 0!==e&&h.splice(h.length-1,0,`a=fmtp:${o} ${e}`),a[1]=h.join("\r\n"),s.sdp=a.join(`m=${r}`),n.setLocalDescription(s)}).then(()=>n.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=${r} 9 UDP/TLS/RTP/SAVPF ${o}\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:${o} ${t}\r\n${void 0!==e?`a=fmtp:${o} ${e}\r\n`:""}`}))).then(()=>s(!0)).catch(()=>s(!1)).finally(()=>n.close())})}static unquoteCredential(t){return JSON.parse(`"${t}"`)}static linkToIceServers(t){return t?t.split(", ").map(t=>{const e=t.match(/^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i);if(!e)throw new n(s.SIGNAL_ERROR,"Invalid ICE server link format");const r={urls:[e[1]]};return e[3]&&(r.username=i.unquoteCredential(e[3]),r.credential=i.unquoteCredential(e[4]),r.credentialType="password"),r}):[]}static reservePayloadType(t){for(let e=30;e<=127;e++)if((e<=63||e>=96)&&!t.includes(e.toString())){const s=e.toString();return t.push(s),s}throw new Error("unable to find a free payload type")}}class r{constructor(t){this.options=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(t=>i.supportsNonAdvertisedCodec(t[0],t[1]).then(e=>!!e&&t[0]))).then(t=>t.filter(t=>!1!==t)).then(t=>{const e=this.options.getState();if("getting_codecs"!==e)throw new n(s.STATE_ERROR,`State changed to ${e}`);this.options.emitter.emit("codecs:detected",t)}).catch(t=>this.options.emitter.emit("error",t))}}class o{static parseOffer(t){const e={iceUfrag:"",icePwd:"",medias:[]};for(const s of t.split("\r\n"))s.startsWith("m=")?e.medias.push(s.slice(2)):""===e.iceUfrag&&s.startsWith("a=ice-ufrag:")?e.iceUfrag=s.slice(12):""===e.icePwd&&s.startsWith("a=ice-pwd:")&&(e.icePwd=s.slice(10));return e}static reservePayloadType(t){for(let e=30;e<=127;e++)if((e<=63||e>=96)&&!t.includes(e.toString())){const s=e.toString();return t.push(s),s}throw new Error("unable to find a free payload type")}static enableStereoPcmau(t,e){const s=e.split("\r\n");let n=o.reservePayloadType(t);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=o.reservePayloadType(t),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(t,e){const s=e.split("\r\n");let n=o.reservePayloadType(t);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=o.reservePayloadType(t),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=o.reservePayloadType(t),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=o.reservePayloadType(t),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=o.reservePayloadType(t),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=o.reservePayloadType(t),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(t,e){const s=e.split("\r\n");let n=o.reservePayloadType(t);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=o.reservePayloadType(t),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=o.reservePayloadType(t),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(t){let e="";const s=t.split("\r\n");for(let t=0;t<s.length;t++)if(s[t].startsWith("a=rtpmap:")&&s[t].toLowerCase().includes("opus/")){e=s[t].slice(9).split(" ")[0];break}if(""===e)return t;for(let t=0;t<s.length;t++)s[t].startsWith(`a=fmtp:${e} `)&&(s[t].includes("stereo")||(s[t]+=";stereo=1"),s[t].includes("sprop-stereo")||(s[t]+=";sprop-stereo=1"));return s.join("\r\n")}static editOffer(t,e){const s=t.split("m="),n=s.slice(1).map(t=>t.split("\r\n")[0].split(" ").slice(3)).reduce((t,e)=>[...t,...e],[]);for(let t=1;t<s.length;t++)if(s[t].startsWith("audio")){s[t]=o.enableStereoOpus(s[t]),e.includes("pcma/8000/2")&&(s[t]=o.enableStereoPcmau(n,s[t])),e.includes("multiopus/48000/6")&&(s[t]=o.enableMultichannelOpus(n,s[t])),e.includes("L16/48000/2")&&(s[t]=o.enableL16(n,s[t]));break}return s.join("m=")}static generateSdpFragment(t,e){const s={};for(const t of e){const e=t.sdpMLineIndex;e&&(void 0===s[e]&&(s[e]=[]),s[e].push(t))}let n=`a=ice-ufrag:${t.iceUfrag}\r\na=ice-pwd:${t.icePwd}\r\n`,i=0;for(const e of t.medias){if(void 0!==s[i]){n+=`m=${e}\r\na=mid:${i}\r\n`;for(const t of s[i])n+=`a=${t.candidate}\r\n`}i++}return n}}class a{constructor(t){this.options=t}async setupPeerConnection(t){if("running"!==this.options.getState())throw new n(s.STATE_ERROR,"closed");const e=new RTCPeerConnection({iceServers:t,sdpSemantics:"unified-plan"});this.pc=e;const i="recvonly";return e.addTransceiver("video",{direction:i}),e.addTransceiver("audio",{direction:i}),e.onicecandidate=t=>this.onLocalCandidate(t),e.onconnectionstatechange=()=>this.onConnectionState(),e.oniceconnectionstatechange=()=>this.onIceConnectionState(),e.ontrack=t=>this.options.emitter.emit("track",t),e.createOffer().then(t=>{if(!t.sdp)throw new n(s.SIGNAL_ERROR,"Failed to create offer SDP");return t.sdp=o.editOffer(t.sdp,this.options.getNonAdvertisedCodecs()),this.offerData=o.parseOffer(t.sdp),e.setLocalDescription(t).then(()=>t.sdp)})}async setAnswer(t){if("running"!==this.options.getState())throw new n(s.STATE_ERROR,"closed");return this.pc.setRemoteDescription(new RTCSessionDescription({type:"answer",sdp:t}))}getPeerConnection(){return this.pc}getOfferData(){return this.offerData}close(){this.pc?.close(),this.pc=void 0,this.offerData=void 0}onLocalCandidate(t){"running"===this.options.getState()&&t.candidate&&this.options.emitter.emit("candidate",t.candidate)}onConnectionState(){"running"===this.options.getState()&&this.pc&&("failed"!==this.pc.connectionState&&"closed"!==this.pc.connectionState||this.options.emitter.emit("error",new n(s.OTHER_ERROR,"peer connection closed")))}onIceConnectionState(){"running"===this.options.getState()&&this.pc&&"failed"===this.pc.iceConnectionState&&this.pc.restartIce()}}class c{constructor(t){this.options=t}authHeader(){if(this.options.conf.user&&""!==this.options.conf.user){return{Authorization:`Basic ${btoa(`${this.options.conf.user}:${this.options.conf.pass}`)}`}}return this.options.conf.token&&""!==this.options.conf.token?{Authorization:`Bearer ${this.options.conf.token}`}:{}}async requestICEServers(){return this.options.conf.iceServers&&this.options.conf.iceServers.length>0?this.options.conf.iceServers:fetch(this.options.conf.url,{method:"OPTIONS",headers:{...this.authHeader()}}).then(t=>i.linkToIceServers(t.headers.get("Link")))}async sendOffer(t){if("running"!==this.options.getState())throw new n(s.STATE_ERROR,"closed");return fetch(this.options.conf.url,{method:"POST",headers:{...this.authHeader(),"Content-Type":"application/sdp"},body:t}).then(t=>{switch(t.status){case 201:break;case 404:case 406:throw new n(s.NOT_FOUND_ERROR,"stream not found");case 400:return t.json().then(t=>{throw new n(s.REQUEST_ERROR,t.error)});default:throw new n(s.REQUEST_ERROR,`bad status code ${t.status}`)}const e=t.headers.get("Location"),i=e?new URL(e,this.options.conf.url).toString():void 0;return t.text().then(t=>({sessionUrl:i,answer:t}))})}sendLocalCandidates(t,e,i){fetch(t,{method:"PATCH",headers:{"Content-Type":"application/trickle-ice-sdpfrag","If-Match":"*"},body:o.generateSdpFragment(e,i)}).then(t=>{switch(t.status){case 204:break;case 404:throw new n(s.NOT_FOUND_ERROR,"stream not found");default:throw new n(s.REQUEST_ERROR,`bad status code ${t.status}`)}}).catch(t=>this.options.emitter.emit("error",t))}}class h{constructor(t,e){this.container=t,this.eventEmitter=e}async play(){try{await this.container.play(),this.eventEmitter.emit("play",{success:!0,muted:this.container.muted})}catch(t){if(!this.container.muted){this.container.muted=!0;try{return await this.container.play(),void this.eventEmitter.emit("play",{success:!0,muted:!0})}catch{}}this.eventEmitter.emit("play",{success:!1,reason:t instanceof Error?t.message:"Autoplay failed",muted:this.container.muted})}}}class l{constructor(t,s,n=!0){this.container=t,this.lazyLoad=n,this.showStore=e(!1),this.playController=new h(t,s),this.showStore.subscribe(t=>{t?this.resume():this.pause()}),this.lazyLoad?(this.observer=new IntersectionObserver(([t])=>{t.isIntersecting?this.showStore.set(!0):this.showStore.set(!1)},{threshold:.5}),this.observer.observe(this.container)):this.showStore.set(!0)}onTrack(t){this.stream=t.streams[0],this.showStore.get()&&(this.container.srcObject=this.stream,this.playController.play())}get paused(){return null===this.container.srcObject}pause(){this.container.srcObject=null}resume(){this.stream&&(this.container.srcObject=this.stream)}stop(){this.stream=void 0}}class p{constructor(t){this.options=t,this.lastBytesReceived=0,this.consecutiveNoProgress=0,this.startTime=0,this.isStable=!1,this.baseInterval=t.interval,this.stableInterval=t.stableInterval||2*t.interval,this.maxNoProgress=t.maxNoProgress||3,this.stabilizationTime=t.stabilizationTime||3e4}setPeerConnection(t){this.pc=t}start(){this.close(),this.startTime=Date.now(),this.isStable=!1,this.consecutiveNoProgress=0,this.scheduleNextCheck()}close(){this.checkTimer&&(clearTimeout(this.checkTimer),this.checkTimer=void 0),this.pc=void 0}scheduleNextCheck(){const t=this.getNextCheckInterval();this.checkTimer=setTimeout(()=>{this.checkFlowState().then(()=>{this.pc&&"connected"===this.pc.connectionState&&this.scheduleNextCheck()})},t)}getNextCheckInterval(){const t=Date.now()-this.startTime;return this.isStable=t>this.stabilizationTime,this.isStable?this.stableInterval:this.baseInterval}async checkFlowState(){if(!this.pc)return;if("connected"!==this.pc.connectionState)return;const t=await this.pc.getStats();let e=0;if(t.forEach(t=>{const s=t;"inbound-rtp"===t.type&&"video"===s.kind&&(e=s.bytesReceived||0)}),e===this.lastBytesReceived){if(this.consecutiveNoProgress++,this.consecutiveNoProgress>=this.maxNoProgress)return this.options.emitter.emit("error",new n(s.CONNECT_ERROR,"data stream interruption")),void(this.consecutiveNoProgress=0)}else this.consecutiveNoProgress=0;this.lastBytesReceived=e}}class d extends t{constructor(t){super(),this.retryPause=2e3,this.stateStore=e("getting_codecs"),this.queuedCandidates=[],this.nonAdvertisedCodecs=[],this.conf=t,this.stateStore.subscribe((t,e)=>{this.emit("state:change",{from:e,to:t})}),this.trackManager=new l(this.conf.container,this,this.conf.lazyLoad),this.flowCheck=new p({interval:5e3,emitter:this}),this.httpClient=new c({conf:this.conf,getState:()=>this.stateStore.get(),emitter:this}),this.connectionManager=new a({getState:()=>this.stateStore.get(),emitter:this,getNonAdvertisedCodecs:()=>this.nonAdvertisedCodecs}),this.codecDetector=new r({getState:()=>this.stateStore.get(),emitter:this}),this.on("codecs:detected",t=>{this.handleCodecsDetected(t)}),this.on("candidate",t=>this.handleCandidate(t)),this.on("track",t=>{this.trackManager.onTrack(t),this.flowCheck.start()}),this.on("error",t=>this.handleError(t)),this.codecDetector.detect()}get state(){return this.stateStore.get()}close(){this.stateStore.set("closed"),this.connectionManager.close(),this.trackManager.stop(),this.flowCheck.close(),this.restartTimeout&&clearTimeout(this.restartTimeout),this.emit("close")}cleanupSession(){this.restartTimeout&&(clearTimeout(this.restartTimeout),this.restartTimeout=void 0),this.connectionManager.close(),this.flowCheck.close(),this.queuedCandidates=[],this.sessionUrl&&(fetch(this.sessionUrl,{method:"DELETE"}).catch(()=>{}),this.sessionUrl=void 0)}handleError(t){this.flowCheck.close(),"getting_codecs"===this.stateStore.get()||t instanceof n&&[s.SIGNAL_ERROR,s.NOT_FOUND_ERROR,s.REQUEST_ERROR].includes(t.type)?this.stateStore.set("failed"):"running"===this.stateStore.get()&&(this.cleanupSession(),this.stateStore.set("restarting"),this.emit("restart"),this.restartTimeout=setTimeout(()=>{this.restartTimeout=void 0,this.stateStore.set("running"),this.start()},this.retryPause))}handleCodecsDetected(t){this.nonAdvertisedCodecs=t,this.stateStore.set("running"),this.start()}start(){this.httpClient.requestICEServers().then(t=>this.connectionManager.setupPeerConnection(t)).then(t=>{const e=this.connectionManager.getPeerConnection();return e&&this.flowCheck.setPeerConnection(e),t}).then(t=>this.httpClient.sendOffer(t)).then(({sessionUrl:t,answer:e})=>this.handleOfferResponse(t,e)).catch(t=>this.handleError(t))}handleOfferResponse(t,e){return t&&(this.sessionUrl=t),this.connectionManager.setAnswer(e).then(()=>{if("running"===this.stateStore.get()&&0!==this.queuedCandidates.length){const t=this.connectionManager.getOfferData();t&&this.sessionUrl&&(this.httpClient.sendLocalCandidates(this.sessionUrl,t,this.queuedCandidates),this.queuedCandidates=[])}})}handleCandidate(t){if(this.sessionUrl){const e=this.connectionManager.getOfferData();e&&this.httpClient.sendLocalCandidates(this.sessionUrl,e,[t])}else this.queuedCandidates.push(t)}get paused(){return this.trackManager.paused}pause(){this.trackManager.pause()}resume(){this.trackManager.resume()}updateUrl(t){"closed"!==this.state?(this.conf.url=t,this.cleanupSession(),this.stateStore.set("running"),this.start()):this.emit("error",new n(s.OTHER_ERROR,"Cannot update URL: instance is closed"))}}export{s as ErrorTypes,n as WebRTCError,d as default};
package/dist/types.d.ts CHANGED
@@ -29,6 +29,11 @@ export interface WhepEvents {
29
29
  }) => void;
30
30
  'candidate': (candidate: RTCIceCandidate) => void;
31
31
  'track': (evt: RTCTrackEvent) => void;
32
+ 'play': (payload: {
33
+ success: boolean;
34
+ reason?: string;
35
+ muted: boolean;
36
+ }) => void;
32
37
  'error': (err: WebRTCError) => void;
33
38
  'close': () => void;
34
39
  'restart': () => void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "whepts",
3
3
  "type": "module",
4
- "version": "1.1.5",
4
+ "version": "1.1.6",
5
5
  "packageManager": "pnpm@10.28.2",
6
6
  "description": "基于 mediamtx 的 WebRTC WHEP 播放器,支持 ZLM 和 Mediamtx 的播放地址",
7
7
  "author": "mapleafgo",