whepts 1.0.3 → 1.1.0

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
@@ -17,6 +17,7 @@ WebRTC WHEP (WebRTC HTTP Egress Protocol) 是一种用于从 WebRTC 服务器获
17
17
  - 支持事件监听和错误处理
18
18
  - 可配置的连接参数
19
19
  - 自动断流检测与重连
20
+ - 支持动态更新播放地址(用于故障切换)
20
21
  - 支持多种音视频编解码器
21
22
  - 支持仅可视区域播放控制
22
23
  - 在 Chrome 上根据硬件支持 G711 和 H265 编解码器
@@ -44,14 +45,24 @@ import WebRTCWhep from 'whepts'
44
45
  const config = {
45
46
  url: 'https://your-server:port/index/api/whep?app={app}&stream={stream}', // WHEP 服务器地址
46
47
  container: document.getElementById('video') as HTMLMediaElement, // 视频播放容器
47
- onError: (error) => {
48
- console.error('播放错误:', error)
49
- }
50
48
  }
51
49
 
52
50
  // 创建播放器实例
53
51
  const player = new WebRTCWhep(config)
54
52
 
53
+ // 监听事件
54
+ player.on('state:change', ({ from, to }) => {
55
+ console.log(`状态变化: ${from} → ${to}`)
56
+ })
57
+
58
+ player.on('error', (error) => {
59
+ console.error('播放错误:', error.message, error.type)
60
+ })
61
+
62
+ player.on('track', (evt) => {
63
+ console.log('接收到媒体轨道:', evt.track.kind)
64
+ })
65
+
55
66
  // 播放器会自动开始播放
56
67
  ```
57
68
 
@@ -71,15 +82,37 @@ const config = {
71
82
  {
72
83
  urls: ['stun:stun.l.google.com:19302']
73
84
  }
74
- ],
75
- onError: (error) => {
76
- console.error('播放错误:', error)
77
- }
85
+ ]
78
86
  }
79
87
 
80
88
  // 创建播放器实例
81
89
  const player = new WebRTCWhep(config)
82
90
 
91
+ // 监听所有事件
92
+ player.on('state:change', ({ from, to }) => {
93
+ console.log(`状态: ${from} → ${to}`)
94
+ })
95
+
96
+ player.on('candidate', (candidate) => {
97
+ console.log('ICE 候选:', candidate.candidate)
98
+ })
99
+
100
+ player.on('track', (evt) => {
101
+ console.log('媒体轨道:', evt.track.kind)
102
+ })
103
+
104
+ player.on('error', (error) => {
105
+ console.error('错误:', error.message, error.type)
106
+ })
107
+
108
+ player.on('close', () => {
109
+ console.log('连接已关闭')
110
+ })
111
+
112
+ player.on('restart', () => {
113
+ console.log('正在重连...')
114
+ })
115
+
83
116
  // 检查流状态
84
117
  console.log('流状态:', player.isRunning)
85
118
 
@@ -90,7 +123,35 @@ player.pause()
90
123
  player.resume()
91
124
 
92
125
  // 关闭播放器
93
- // player.close();
126
+ player.close()
127
+ ```
128
+
129
+ ### 更新播放地址
130
+
131
+ 当播放失败时,可以使用 `updateUrl()` 方法切换到新的播放地址:
132
+
133
+ ```typescript
134
+ import WebRTCWhep from 'whepts'
135
+
136
+ const player = new WebRTCWhep({
137
+ url: 'https://your-server:port/stream.whep',
138
+ container: document.getElementById('video') as HTMLMediaElement,
139
+ })
140
+
141
+ // 监听错误事件
142
+ player.on('error', async (error) => {
143
+ console.error('播放错误:', error.message)
144
+
145
+ // 从服务器获取新的播放地址
146
+ const newUrl = await fetchNewStreamUrl()
147
+
148
+ // 使用新地址重新开始播放
149
+ player.updateUrl(newUrl)
150
+ })
151
+
152
+ // 或者手动更新地址
153
+ const newStreamUrl = 'https://another-server:port/stream.whep'
154
+ player.updateUrl(newStreamUrl)
94
155
  ```
95
156
 
96
157
  ## 支持的播放地址格式
@@ -124,10 +185,52 @@ WebRTCWhep(conf)
124
185
  - `pass`: 认证密码(可选)
125
186
  - `token`: 认证令牌(可选)
126
187
  - `iceServers`: ICE 服务器配置(可选)
127
- - `onError`: 错误回调函数(可选)
188
+
189
+ #### 事件
190
+
191
+ ```typescript
192
+ // 状态变化事件
193
+ player.on('state:change', ({ from, to }) => {
194
+ console.log(`状态: ${from} → ${to}`)
195
+ })
196
+
197
+ // ICE 候选事件
198
+ player.on('candidate', (candidate) => {
199
+ console.log('ICE 候选:', candidate.candidate)
200
+ })
201
+
202
+ // 媒体轨道事件
203
+ player.on('track', (evt) => {
204
+ console.log('媒体轨道:', evt.track.kind)
205
+ })
206
+
207
+ // 错误事件
208
+ player.on('error', (error) => {
209
+ console.error('错误:', error.message, error.type)
210
+ })
211
+
212
+ // 连接关闭事件
213
+ player.on('close', () => {
214
+ console.log('连接已关闭')
215
+ })
216
+
217
+ // 重连事件
218
+ player.on('restart', () => {
219
+ console.log('正在重连...')
220
+ })
221
+ ```
222
+
223
+ **事件类型:**
224
+ - `state:change`: 状态变化,包含 `from` 和 `to` 状态
225
+ - `candidate`: ICE 候选
226
+ - `track`: 媒体轨道
227
+ - `error`: 错误
228
+ - `close`: 连接关闭
229
+ - `restart`: 开始重连
128
230
 
129
231
  #### 属性
130
232
 
233
+ - `state`: 当前状态 (`getting_codecs` | `running` | `restarting` | `closed` | `failed`)
131
234
  - `isRunning`: 流是否正在运行
132
235
  - `paused`: 播放器是否已暂停
133
236
 
@@ -136,6 +239,8 @@ WebRTCWhep(conf)
136
239
  - `close()`: 关闭播放器和所有资源
137
240
  - `pause()`: 暂停播放
138
241
  - `resume()`: 恢复播放
242
+ - `updateUrl(url)`: 更新播放地址并重新开始播放(用于播放失败时切换到新的 URL)
243
+ - `on(event, listener)`: 注册事件监听器
139
244
 
140
245
  #### 错误类型
141
246
 
@@ -170,6 +275,8 @@ src/
170
275
 
171
276
  - TypeScript
172
277
  - WebRTC API
278
+ - eventemitter3 - 事件发射器
279
+ - nanostores - 状态管理
173
280
  - 相关构建工具 (Rollup, ESLint)
174
281
 
175
282
  ## 许可证
@@ -1,11 +1,11 @@
1
+ import type EventEmitter from 'eventemitter3';
1
2
  import type { State } from '~/types';
2
- export interface CodecDetectorCallbacks {
3
- onCodecsDetected: (codecs: string[]) => void;
4
- onError: (err: Error) => void;
3
+ export interface CodecDetectorOptions {
4
+ getState: () => State;
5
+ emitter: EventEmitter;
5
6
  }
6
7
  export declare class CodecDetector {
7
- private getState;
8
- private callbacks;
9
- constructor(getState: () => State, callbacks: CodecDetectorCallbacks);
8
+ private options;
9
+ constructor(options: CodecDetectorOptions);
10
10
  detect(): void;
11
11
  }
@@ -1,18 +1,16 @@
1
+ import type EventEmitter from 'eventemitter3';
1
2
  import type { ParsedOffer } from '../utils/sdp';
2
3
  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;
4
+ export interface ConnectionManagerOptions {
5
+ getState: () => State;
6
+ emitter: EventEmitter;
7
+ getNonAdvertisedCodecs: () => string[];
8
8
  }
9
9
  export declare class ConnectionManager {
10
- private getState;
11
- private callbacks;
12
- private nonAdvertisedCodecs;
10
+ private options;
13
11
  private pc?;
14
12
  private offerData?;
15
- constructor(getState: () => State, callbacks: ConnectionManagerCallbacks, nonAdvertisedCodecs?: string[]);
13
+ constructor(options: ConnectionManagerOptions);
16
14
  setupPeerConnection(iceServers: RTCIceServer[]): Promise<string>;
17
15
  setAnswer(answer: string): Promise<void>;
18
16
  getPeerConnection(): RTCPeerConnection | undefined;
@@ -1,11 +1,14 @@
1
+ import type EventEmitter from 'eventemitter3';
1
2
  import type { ParsedOffer } from '../utils/sdp';
2
3
  import type { Conf, State } from '~/types';
3
- import { WebRTCError } from '~/errors';
4
+ export interface HttpClientOptions {
5
+ conf: Conf;
6
+ getState: () => State;
7
+ emitter: EventEmitter;
8
+ }
4
9
  export declare class HttpClient {
5
- private config;
6
- private getState;
7
- private onError;
8
- constructor(config: Conf, getState: () => State, onError: (err: Error | WebRTCError) => void);
10
+ private options;
11
+ constructor(options: HttpClientOptions);
9
12
  private authHeader;
10
13
  requestICEServers(): Promise<RTCIceServer[]>;
11
14
  sendOffer(offer: string): Promise<{
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,5 +1,6 @@
1
1
  import type { Conf, State } from './types';
2
+ import type { WhepEvents } from './whep';
2
3
  import { ErrorTypes, WebRTCError } from './errors';
3
4
  import WebRTCWhep from './whep';
4
- export { Conf, ErrorTypes, State, WebRTCError };
5
+ export { Conf, ErrorTypes, State, WebRTCError, WhepEvents };
5
6
  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{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
+ import e from"eventemitter3";import{atom as t}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(e,t,s){super(t,s),this.type=e}}class r{static async supportsNonAdvertisedCodec(e,t){return new Promise(s=>{const n=new RTCPeerConnection({iceServers:[]}),i="audio";let o="";n.addTransceiver(i,{direction:"recvonly"}),n.createOffer().then(s=>{if(!s.sdp)throw new Error("SDP not present");if(s.sdp.includes(` ${e}`))throw new Error("already present");const a=s.sdp.split(`m=${i}`),c=a.slice(1).map(e=>e.split("\r\n")[0].split(" ").slice(3)).reduce((e,t)=>[...e,...t],[]);o=r.reservePayloadType(c);const h=a[1].split("\r\n");return h[0]+=` ${o}`,h.splice(h.length-1,0,`a=rtpmap:${o} ${e}`),void 0!==t&&h.splice(h.length-1,0,`a=fmtp:${o} ${t}`),a[1]=h.join("\r\n"),s.sdp=a.join(`m=${i}`),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=${i} 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} ${e}\r\n${void 0!==t?`a=fmtp:${o} ${t}\r\n`:""}`}))).then(()=>s(!0)).catch(()=>s(!1)).finally(()=>n.close())})}static unquoteCredential(e){return JSON.parse(`"${e}"`)}static linkToIceServers(e){return e?e.split(", ").map(e=>{const t=e.match(/^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i);if(!t)throw new n(s.SIGNAL_ERROR,"Invalid ICE server link format");const i={urls:[t[1]]};return t[3]&&(i.username=r.unquoteCredential(t[3]),i.credential=r.unquoteCredential(t[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 i{constructor(e){this.options=e}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=>r.supportsNonAdvertisedCodec(e[0],e[1]).then(t=>!!t&&e[0]))).then(e=>e.filter(e=>!1!==e)).then(e=>{if("getting_codecs"!==this.options.getState())throw new n(s.STATE_ERROR,"closed");this.options.emitter.emit("codecs:detected",e)}).catch(e=>this.options.emitter.emit("error",e))}}class o{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=o.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=o.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=o.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=o.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=o.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=o.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=o.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=o.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=o.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=o.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=o.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]=o.enableStereoOpus(s[e]),t.includes("pcma/8000/2")&&(s[e]=o.enableStereoPcmau(n,s[e])),t.includes("multiopus/48000/6")&&(s[e]=o.enableMultichannelOpus(n,s[e])),t.includes("L16/48000/2")&&(s[e]=o.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 a{constructor(e){this.options=e}async setupPeerConnection(e){if("running"!==this.options.getState())throw new n(s.STATE_ERROR,"closed");const t=new RTCPeerConnection({iceServers:e,sdpSemantics:"unified-plan"});this.pc=t;const r="recvonly";return t.addTransceiver("video",{direction:r}),t.addTransceiver("audio",{direction:r}),t.onicecandidate=e=>this.onLocalCandidate(e),t.onconnectionstatechange=()=>this.onConnectionState(),t.oniceconnectionstatechange=()=>this.onIceConnectionState(),t.ontrack=e=>this.options.emitter.emit("track",e),t.createOffer().then(e=>{if(!e.sdp)throw new n(s.SIGNAL_ERROR,"Failed to create offer SDP");return e.sdp=o.editOffer(e.sdp,this.options.getNonAdvertisedCodecs()),this.offerData=o.parseOffer(e.sdp),t.setLocalDescription(e).then(()=>e.sdp)})}async setAnswer(e){if("running"!==this.options.getState())throw new n(s.STATE_ERROR,"closed");return this.pc.setRemoteDescription(new RTCSessionDescription({type:"answer",sdp:e}))}getPeerConnection(){return this.pc}getOfferData(){return this.offerData}close(){this.pc?.close(),this.pc=void 0,this.offerData=void 0}onLocalCandidate(e){"running"===this.options.getState()&&e.candidate&&this.options.emitter.emit("candidate",e.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(e){this.options=e}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(e=>r.linkToIceServers(e.headers.get("Link")))}async sendOffer(e){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:e}).then(e=>{switch(e.status){case 201:break;case 404:case 406:throw new n(s.NOT_FOUND_ERROR,"stream not found");case 400:return e.json().then(e=>{throw new n(s.REQUEST_ERROR,e.error)});default:throw new n(s.REQUEST_ERROR,`bad status code ${e.status}`)}const t=e.headers.get("Location"),r=t?new URL(t,this.options.conf.url).toString():void 0;return e.text().then(e=>({sessionUrl:r,answer:e}))})}sendLocalCandidates(e,t,r){fetch(e,{method:"PATCH",headers:{"Content-Type":"application/trickle-ice-sdpfrag","If-Match":"*"},body:o.generateSdpFragment(t,r)}).then(e=>{switch(e.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 ${e.status}`)}}).catch(e=>this.options.emitter.emit("error",e))}}class h{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 p{constructor(e){this.options=e,this.lastBytesReceived=0,this.consecutiveNoProgress=0,this.startTime=0,this.isStable=!1,this.baseInterval=e.interval,this.stableInterval=e.stableInterval||2*e.interval,this.maxNoProgress=e.maxNoProgress||3,this.stabilizationTime=e.stabilizationTime||3e4}setPeerConnection(e){this.pc=e}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 e=this.getNextCheckInterval();this.checkTimer=setTimeout(()=>{this.checkFlowState().then(()=>{this.pc&&"connected"===this.pc.connectionState&&this.scheduleNextCheck()})},e)}getNextCheckInterval(){const e=Date.now()-this.startTime;return this.isStable=e>this.stabilizationTime,this.isStable?this.stableInterval:this.baseInterval}async checkFlowState(){if(!this.pc)return;if("connected"!==this.pc.connectionState)return;const e=await this.pc.getStats();let t=0;if(e.forEach(e=>{const s=e;"inbound-rtp"===e.type&&"video"===s.kind&&(t=s.bytesReceived||0)}),t===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=t}}class l extends e{constructor(e){super(),this.retryPause=2e3,this.stateStore=t("getting_codecs"),this.queuedCandidates=[],this.nonAdvertisedCodecs=[],this.conf=e,this.stateStore.subscribe((e,t)=>{this.emit("state:change",{from:t,to:e})}),this.trackManager=new h(this.conf.container),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 i({getState:()=>this.stateStore.get(),emitter:this}),this.on("codecs:detected",e=>{this.handleCodecsDetected(e)}),this.on("candidate",e=>this.handleCandidate(e)),this.on("track",e=>{this.trackManager.onTrack(e),this.flowCheck.start()}),this.codecDetector.detect()}get state(){return this.stateStore.get()}get isRunning(){return"running"===this.state}close(){this.stateStore.set("closed"),this.connectionManager.close(),this.trackManager.stop(),this.flowCheck.close(),this.restartTimeout&&clearTimeout(this.restartTimeout),this.emit("close")}cleanupSession(){this.connectionManager.close(),this.flowCheck.close(),this.queuedCandidates=[],this.sessionUrl&&(fetch(this.sessionUrl,{method:"DELETE"}).catch(()=>{}),this.sessionUrl=void 0)}handleError(e){this.flowCheck.close(),"getting_codecs"===this.stateStore.get()||e instanceof n&&[s.SIGNAL_ERROR,s.NOT_FOUND_ERROR,s.REQUEST_ERROR].includes(e.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),e.message=`${e.message}, retrying in some seconds`),e instanceof n?this.emit("error",e):this.emit("error",new n(s.OTHER_ERROR,e.message))}handleCodecsDetected(e){this.nonAdvertisedCodecs=e,this.stateStore.set("running"),this.start()}start(){this.httpClient.requestICEServers().then(e=>this.connectionManager.setupPeerConnection(e)).then(e=>{const t=this.connectionManager.getPeerConnection();return t&&this.flowCheck.setPeerConnection(t),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.stateStore.get()&&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()}updateUrl(e){"closed"!==this.stateStore.get()?(this.conf.url=e,this.restartTimeout&&(clearTimeout(this.restartTimeout),this.restartTimeout=void 0),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,l as default};
package/dist/types.d.ts CHANGED
@@ -15,8 +15,21 @@ export interface Conf {
15
15
  token?: string;
16
16
  /** ice server list */
17
17
  iceServers?: RTCIceServer[];
18
- /** Called when there's an error */
19
- onError?: (err: WebRTCError) => void;
18
+ }
19
+ /**
20
+ * Event types emitted by WebRTCWhep
21
+ */
22
+ export interface WhepEvents {
23
+ 'codecs:detected': (codecs: string[]) => void;
24
+ 'state:change': (payload: {
25
+ from: State;
26
+ to: State;
27
+ }) => void;
28
+ 'candidate': (candidate: RTCIceCandidate) => void;
29
+ 'track': (evt: RTCTrackEvent) => void;
30
+ 'error': (err: WebRTCError) => void;
31
+ 'close': () => void;
32
+ 'restart': () => void;
20
33
  }
21
34
  /**
22
35
  * State type for WebRTCWhep.
@@ -1,27 +1,50 @@
1
- import { WebRTCError } from '~/errors';
2
- export interface FlowCheckParams {
1
+ import type EventEmitter from 'eventemitter3';
2
+ export interface FlowCheckOptions {
3
3
  interval: number;
4
- onError: (err: WebRTCError) => void;
4
+ stableInterval?: number;
5
+ maxNoProgress?: number;
6
+ stabilizationTime?: number;
7
+ emitter: EventEmitter;
5
8
  }
6
9
  /**
7
10
  * Flow checking logic (断流检测)
11
+ *
12
+ * 性能优化:自适应轮询机制
13
+ * - 初始阶段(stabilizationTime):高频检查(interval)
14
+ * - 稳定阶段:降低频率(stableInterval,默认为 interval 的 2 倍)
15
+ * - 使用 setTimeout 而非 setInterval,便于动态调整间隔
16
+ * - 连续多次(maxNoProgress)无进展才判定断流,避免误判
8
17
  */
9
18
  export declare class FlowCheck {
10
- private checkInterval;
19
+ private options;
20
+ private baseInterval;
21
+ private stableInterval;
22
+ private maxNoProgress;
23
+ private stabilizationTime;
11
24
  private lastBytesReceived;
12
25
  private checkTimer?;
13
26
  private pc?;
14
- private onError;
15
- constructor(params: FlowCheckParams);
27
+ private consecutiveNoProgress;
28
+ private startTime;
29
+ private isStable;
30
+ constructor(options: FlowCheckOptions);
16
31
  setPeerConnection(pc: RTCPeerConnection): void;
17
32
  /**
18
33
  * 启动断流检测
19
34
  */
20
35
  start(): void;
21
36
  /**
22
- * 停止断流检测
37
+ * 停止断流检测并清理资源
23
38
  */
24
- stop(): void;
39
+ close(): void;
40
+ /**
41
+ * 调度下一次检查
42
+ */
43
+ private scheduleNextCheck;
44
+ /**
45
+ * 计算下次检查间隔
46
+ */
47
+ private getNextCheckInterval;
25
48
  /**
26
49
  * 检测流状态(通过接收字节数判断是否断流)
27
50
  */
package/dist/whep.d.ts CHANGED
@@ -1,9 +1,11 @@
1
- import type { Conf } from './types';
1
+ import type { Conf, State, WhepEvents } from './types';
2
+ import EventEmitter from 'eventemitter3';
3
+ export { type WhepEvents };
2
4
  /** WebRTC/WHEP reader. */
3
- export default class WebRTCWhep {
5
+ export default class WebRTCWhep extends EventEmitter<WhepEvents> {
4
6
  private retryPause;
5
7
  private conf;
6
- private state;
8
+ private stateStore;
7
9
  private restartTimeout?;
8
10
  private sessionUrl?;
9
11
  private queuedCandidates;
@@ -14,8 +16,10 @@ export default class WebRTCWhep {
14
16
  private trackManager;
15
17
  private codecDetector;
16
18
  constructor(conf: Conf);
19
+ get state(): State;
17
20
  get isRunning(): boolean;
18
21
  close(): void;
22
+ private cleanupSession;
19
23
  private handleError;
20
24
  private handleCodecsDetected;
21
25
  private start;
@@ -24,4 +28,20 @@ export default class WebRTCWhep {
24
28
  get paused(): boolean;
25
29
  pause(): void;
26
30
  resume(): void;
31
+ /**
32
+ * Update the WHEP endpoint URL and restart playback.
33
+ * Useful when the current URL fails and you need to switch to a new URL.
34
+ *
35
+ * @param url - The new WHEP endpoint URL
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * player.on('error', () => {
40
+ * // Get new URL from your server
41
+ * const newUrl = await getNewStreamUrl()
42
+ * player.updateUrl(newUrl)
43
+ * })
44
+ * ```
45
+ */
46
+ updateUrl(url: string): void;
27
47
  }
package/eslint.config.ts CHANGED
@@ -2,10 +2,12 @@ import antfu from '@antfu/eslint-config'
2
2
 
3
3
  export default antfu(
4
4
  {
5
+ type: 'lib',
5
6
  formatters: true,
6
7
  pnpm: true,
7
- },
8
- {
8
+ ignores: [
9
+ '**/*.md',
10
+ ],
9
11
  rules: {
10
12
  'n/prefer-global/process': ['error', 'always'],
11
13
  },
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "whepts",
3
3
  "type": "module",
4
- "version": "1.0.3",
4
+ "version": "1.1.0",
5
+ "packageManager": "pnpm@10.28.2",
5
6
  "description": "基于 mediamtx 的 WebRTC WHEP 播放器,支持 ZLM 和 Mediamtx 的播放地址",
6
7
  "author": "mapleafgo",
7
8
  "license": "MIT",
@@ -25,24 +26,28 @@
25
26
  ],
26
27
  "main": "dist/index.js",
27
28
  "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
+ "dependencies": {
36
+ "eventemitter3": "^5.0.4",
37
+ "nanostores": "^1.1.0"
38
+ },
28
39
  "devDependencies": {
29
40
  "@antfu/eslint-config": "^6.7.3",
30
41
  "@rollup/plugin-commonjs": "^29.0.0",
31
42
  "@rollup/plugin-eslint": "^9.2.0",
32
43
  "@rollup/plugin-terser": "^0.4.4",
33
44
  "@rollup/plugin-typescript": "^12.3.0",
34
- "@types/node": "^25.0.3",
45
+ "@types/node": "^25.0.10",
35
46
  "eslint": "^9.39.2",
36
- "eslint-plugin-format": "^1.1.0",
37
- "rollup": "^4.54.0",
47
+ "eslint-plugin-format": "^1.3.1",
48
+ "rollup": "^4.56.0",
38
49
  "rollup-plugin-delete": "^3.0.2",
39
50
  "tslib": "^2.8.1",
40
51
  "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"
47
52
  }
48
- }
53
+ }
package/rollup.config.ts CHANGED
@@ -15,7 +15,10 @@ export default {
15
15
  sourcemap: !isProduction,
16
16
  },
17
17
  ],
18
- external: [],
18
+ external: [
19
+ 'eventemitter3',
20
+ 'nanostores',
21
+ ],
19
22
  plugins: [
20
23
  del({ targets: 'dist' }),
21
24
  commonjs(),
@@ -1,9 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Skill(superpowers:brainstorming)",
5
- "Skill(superpowers:executing-plans)",
6
- "Bash(npm run build:*)"
7
- ]
8
- }
9
- }
@@ -1,38 +0,0 @@
1
- {
2
- // Disable the default formatter
3
- "prettier.enable": false,
4
- "editor.formatOnSave": false,
5
-
6
- // Auto fix
7
- "editor.codeActionsOnSave": {
8
- "source.fixAll.eslint": "explicit",
9
- "source.organizeImports": "never"
10
- },
11
-
12
- // Silent the stylistic rules in you IDE, but still auto fix them
13
- "eslint.rules.customizations": [
14
- { "rule": "style/*", "severity": "off" },
15
- { "rule": "*-indent", "severity": "off" },
16
- { "rule": "*-spacing", "severity": "off" },
17
- { "rule": "*-spaces", "severity": "off" },
18
- { "rule": "*-order", "severity": "off" },
19
- { "rule": "*-dangle", "severity": "off" },
20
- { "rule": "*-newline", "severity": "off" },
21
- { "rule": "*quotes", "severity": "off" },
22
- { "rule": "*semi", "severity": "off" }
23
- ],
24
-
25
- // The following is optional.
26
- // It's better to put under project setting `.vscode/settings.json`
27
- // to avoid conflicts with working with different eslint configs
28
- // that does not support all formats.
29
- "eslint.validate": [
30
- "javascript",
31
- "typescript",
32
- "html",
33
- "markdown",
34
- "json",
35
- "jsonc",
36
- "yaml"
37
- ]
38
- }