whepts 1.0.2 → 1.0.3

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,51 @@
1
+ export class TrackManager {
2
+ private stream?: MediaStream
3
+ private observer?: IntersectionObserver
4
+
5
+ constructor(private container: HTMLMediaElement) {}
6
+
7
+ onTrack(evt: RTCTrackEvent): void {
8
+ this.stream = evt.streams[0]
9
+
10
+ // 停止之前的观察器
11
+ this.stopObserver()
12
+
13
+ // 创建新的可见性观察器,自动处理暂停/恢复
14
+ this.observer = new IntersectionObserver(
15
+ ([entry]) => {
16
+ if (entry.isIntersecting)
17
+ this.resume()
18
+ else
19
+ this.pause()
20
+ },
21
+ { threshold: 0.5 },
22
+ )
23
+
24
+ this.observer.observe(this.container)
25
+ }
26
+
27
+ private stopObserver(): void {
28
+ if (this.observer) {
29
+ this.observer.disconnect()
30
+ this.observer = undefined
31
+ }
32
+ }
33
+
34
+ get paused(): boolean {
35
+ return this.container.srcObject === null
36
+ }
37
+
38
+ pause(): void {
39
+ this.container.srcObject = null
40
+ }
41
+
42
+ resume(): void {
43
+ if (this.stream && this.paused)
44
+ this.container.srcObject = this.stream
45
+ }
46
+
47
+ stop(): void {
48
+ this.stopObserver()
49
+ this.stream = undefined
50
+ }
51
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
+ import type { Conf, State } from './types'
1
2
  import { ErrorTypes, WebRTCError } from './errors'
2
- import WebRTCWhep, { Conf } from './whep'
3
+ import WebRTCWhep from './whep'
3
4
 
4
- export { Conf, ErrorTypes, WebRTCError }
5
+ export { Conf, ErrorTypes, State, WebRTCError }
5
6
 
6
7
  export default WebRTCWhep
package/src/types.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { WebRTCError } from './errors'
2
+
3
+ /**
4
+ * Configuration interface for WebRTCWhep.
5
+ */
6
+ export interface Conf {
7
+ /** Absolute URL of the WHEP endpoint */
8
+ url: string
9
+ /** Media player container */
10
+ container: HTMLMediaElement
11
+ /** Username for authentication */
12
+ user?: string
13
+ /** Password for authentication */
14
+ pass?: string
15
+ /** Token for authentication */
16
+ token?: string
17
+ /** ice server list */
18
+ iceServers?: RTCIceServer[]
19
+ /** Called when there's an error */
20
+ onError?: (err: WebRTCError) => void
21
+ }
22
+
23
+ /**
24
+ * State type for WebRTCWhep.
25
+ */
26
+ export type State = 'getting_codecs' | 'running' | 'restarting' | 'closed' | 'failed'
27
+
28
+ /** Extend RTCConfiguration to include experimental properties */
29
+ declare global {
30
+ interface RTCConfiguration {
31
+ sdpSemantics?: 'plan-b' | 'unified-plan'
32
+ }
33
+
34
+ interface RTCIceServer {
35
+ credentialType?: 'password'
36
+ }
37
+ }
package/src/whep.ts CHANGED
@@ -1,95 +1,81 @@
1
- import type { ParsedOffer } from './utils/sdp'
2
- import VisibilityObserver from '~/utils/observer'
1
+ import type { Conf, State } from './types'
2
+ import { CodecDetector } from './core/codec'
3
+ import { ConnectionManager } from './core/connection'
4
+ import { HttpClient } from './core/http'
5
+ import { TrackManager } from './core/track'
3
6
  import { ErrorTypes, WebRTCError } from './errors'
4
7
  import { FlowCheck } from './utils/flow-check'
5
- import { SdpUtils } from './utils/sdp'
6
- import { WebRtcUtils } from './utils/webrtc'
7
-
8
- /**
9
- * Configuration interface for WebRTCWhep.
10
- */
11
- export interface Conf {
12
- /** Absolute URL of the WHEP endpoint */
13
- url: string
14
- /** Media player container */
15
- container: HTMLMediaElement
16
- /** Username for authentication */
17
- user?: string
18
- /** Password for authentication */
19
- pass?: string
20
- /** Token for authentication */
21
- token?: string
22
- /** ice server list */
23
- iceServers?: RTCIceServer[]
24
- /** Called when there's an error */
25
- onError?: (err: WebRTCError) => void
26
- }
27
-
28
- /** Extend RTCConfiguration to include experimental properties */
29
- declare global {
30
- interface RTCConfiguration {
31
- sdpSemantics?: 'plan-b' | 'unified-plan'
32
- }
33
-
34
- interface RTCIceServer {
35
- credentialType?: 'password'
36
- }
37
- }
38
8
 
39
9
  /** WebRTC/WHEP reader. */
40
10
  export default class WebRTCWhep {
41
11
  private retryPause: number = 2000
42
12
  private conf: Conf
43
- private state: 'getting_codecs' | 'running' | 'restarting' | 'closed' | 'failed'
13
+ private state: State
44
14
  private restartTimeout?: NodeJS.Timeout
45
- private pc?: RTCPeerConnection
46
- private offerData?: ParsedOffer
47
15
  private sessionUrl?: string
48
16
  private queuedCandidates: RTCIceCandidate[] = []
49
17
  private nonAdvertisedCodecs: string[] = []
50
- private observer: VisibilityObserver
51
- private stream?: MediaStream
52
18
  private flowCheck: FlowCheck
19
+ private httpClient: HttpClient
20
+ private connectionManager: ConnectionManager
21
+ private trackManager: TrackManager
22
+ private codecDetector: CodecDetector
53
23
 
54
- /**
55
- * Create a WebRTCWhep.
56
- * @param {Conf} conf - Configuration.
57
- */
58
24
  constructor(conf: Conf) {
59
25
  this.conf = conf
60
26
  this.state = 'getting_codecs'
61
- this.observer = new VisibilityObserver()
27
+
62
28
  this.flowCheck = new FlowCheck({
63
29
  interval: 5000,
64
30
  onError: (err: WebRTCError) => this.handleError(err),
65
31
  })
66
- this.getNonAdvertisedCodecs()
32
+
33
+ this.httpClient = new HttpClient(
34
+ this.conf,
35
+ () => this.state,
36
+ err => this.handleError(err),
37
+ )
38
+
39
+ this.connectionManager = new ConnectionManager(
40
+ () => this.state,
41
+ {
42
+ onCandidate: candidate => this.handleCandidate(candidate),
43
+ onTrack: (evt) => {
44
+ this.trackManager.onTrack(evt)
45
+ this.flowCheck.start()
46
+ },
47
+ onError: err => this.handleError(err),
48
+ },
49
+ this.nonAdvertisedCodecs,
50
+ )
51
+
52
+ this.trackManager = new TrackManager(this.conf.container)
53
+
54
+ this.codecDetector = new CodecDetector(
55
+ () => this.state,
56
+ {
57
+ onCodecsDetected: codecs => this.handleCodecsDetected(codecs),
58
+ onError: err => this.handleError(err),
59
+ },
60
+ )
61
+
62
+ this.codecDetector.detect()
67
63
  }
68
64
 
69
- /**
70
- * 媒体是否正常
71
- */
72
65
  get isRunning(): boolean {
73
66
  return this.state === 'running'
74
67
  }
75
68
 
76
- /**
77
- * Close the reader and all its resources.
78
- */
79
69
  close(): void {
80
70
  this.state = 'closed'
81
- this.pc?.close()
82
-
83
- this.observer.stop()
71
+ this.connectionManager.close()
72
+ this.trackManager.stop()
84
73
  this.flowCheck.stop()
85
74
  if (this.restartTimeout) {
86
75
  clearTimeout(this.restartTimeout)
87
76
  }
88
77
  }
89
78
 
90
- /**
91
- * Handle errors.
92
- */
93
79
  private handleError(err: Error | WebRTCError): void {
94
80
  this.flowCheck.stop()
95
81
 
@@ -100,10 +86,7 @@ export default class WebRTCWhep {
100
86
  this.state = 'failed'
101
87
  }
102
88
  else if (this.state === 'running') {
103
- this.pc?.close()
104
- this.pc = undefined
105
-
106
- this.offerData = undefined
89
+ this.connectionManager.close()
107
90
  this.queuedCandidates = []
108
91
 
109
92
  if (this.sessionUrl) {
@@ -134,271 +117,59 @@ export default class WebRTCWhep {
134
117
  }
135
118
  }
136
119
 
137
- /**
138
- * Get non-advertised codecs.
139
- */
140
- private getNonAdvertisedCodecs(): void {
141
- Promise.all(
142
- [
143
- ['pcma/8000/2'],
144
- ['multiopus/48000/6', 'channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2'],
145
- ['L16/48000/2'],
146
- ].map(c => WebRtcUtils.supportsNonAdvertisedCodec(c[0], c[1]).then(r => (r ? c[0] : false))),
147
- )
148
- .then(c => c.filter(e => e !== false))
149
- .then((codecs) => {
150
- if (this.state !== 'getting_codecs') {
151
- throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
152
- }
153
-
154
- this.nonAdvertisedCodecs = codecs as string[]
155
- this.state = 'running'
156
- this.start()
157
- })
158
- .catch(err => this.handleError(err))
120
+ private handleCodecsDetected(codecs: string[]): void {
121
+ this.nonAdvertisedCodecs = codecs
122
+ this.state = 'running'
123
+ this.start()
159
124
  }
160
125
 
161
- /**
162
- * Start the WebRTC session.
163
- */
164
126
  private start(): void {
165
- this.requestICEServers()
166
- .then(iceServers => this.setupPeerConnection(iceServers))
167
- .then(offer => this.sendOffer(offer))
168
- .then(answer => this.setAnswer(answer))
127
+ this.httpClient.requestICEServers()
128
+ .then(iceServers => this.connectionManager.setupPeerConnection(iceServers))
129
+ .then(offer => this.httpClient.sendOffer(offer))
130
+ .then(({ sessionUrl, answer }) => this.handleOfferResponse(sessionUrl, answer))
169
131
  .catch(err => this.handleError(err))
170
132
  }
171
133
 
172
- /**
173
- * Generate an authorization header.
174
- */
175
- private authHeader(): Record<string, string> {
176
- if (this.conf.user && this.conf.user !== '') {
177
- const credentials = btoa(`${this.conf.user}:${this.conf.pass}`)
178
- return { Authorization: `Basic ${credentials}` }
179
- }
180
- if (this.conf.token && this.conf.token !== '') {
181
- return { Authorization: `Bearer ${this.conf.token}` }
182
- }
183
- return {}
184
- }
185
-
186
- /**
187
- * Request ICE servers from the endpoint.
188
- */
189
- private async requestICEServers(): Promise<RTCIceServer[]> {
190
- if (this.conf.iceServers && this.conf.iceServers.length > 0) {
191
- return this.conf.iceServers
192
- }
193
-
194
- return fetch(this.conf.url, {
195
- method: 'OPTIONS',
196
- headers: {
197
- ...this.authHeader(),
198
- },
199
- }).then(res => WebRtcUtils.linkToIceServers(res.headers.get('Link')))
200
- }
201
-
202
- /**
203
- * Setup a peer connection.
204
- */
205
- private async setupPeerConnection(iceServers: RTCIceServer[]): Promise<string> {
206
- if (this.state !== 'running') {
207
- throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
208
- }
209
-
210
- const pc = new RTCPeerConnection({
211
- iceServers,
212
- // https://webrtc.org/getting-started/unified-plan-transition-guide
213
- sdpSemantics: 'unified-plan',
214
- })
215
- this.pc = pc
216
- this.flowCheck.setPeerConnection(pc)
217
-
218
- const direction: RTCRtpTransceiverDirection = 'recvonly'
219
- pc.addTransceiver('video', { direction })
220
- pc.addTransceiver('audio', { direction })
134
+ private handleOfferResponse(sessionUrl: string | undefined, answer: string): Promise<void> {
135
+ if (sessionUrl)
136
+ this.sessionUrl = sessionUrl
221
137
 
222
- pc.onicecandidate = (evt: RTCPeerConnectionIceEvent) => this.onLocalCandidate(evt)
223
- pc.onconnectionstatechange = () => this.onConnectionState()
224
- pc.ontrack = (evt: RTCTrackEvent) => this.onTrack(evt)
225
-
226
- return pc.createOffer().then((offer) => {
227
- if (!offer.sdp) {
228
- throw new WebRTCError(ErrorTypes.SIGNAL_ERROR, 'Failed to create offer SDP')
229
- }
230
-
231
- offer.sdp = SdpUtils.editOffer(offer.sdp, this.nonAdvertisedCodecs)
232
- this.offerData = SdpUtils.parseOffer(offer.sdp)
233
-
234
- return pc.setLocalDescription(offer).then(() => offer.sdp!)
235
- })
236
- }
237
-
238
- /**
239
- * Send an offer to the endpoint.
240
- */
241
- private sendOffer(offer: string): Promise<string> {
242
- if (this.state !== 'running') {
243
- throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
244
- }
245
-
246
- return fetch(this.conf.url, {
247
- method: 'POST',
248
- headers: {
249
- ...this.authHeader(),
250
- 'Content-Type': 'application/sdp',
251
- },
252
- body: offer,
253
- }).then((res) => {
254
- switch (res.status) {
255
- case 201:
256
- break
257
- case 404:
258
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, 'stream not found')
259
- case 406:
260
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, 'stream not supported')
261
- case 400:
262
- return res.json().then((e: { error: string }) => {
263
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, e.error)
264
- })
265
- default:
266
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, `bad status code ${res.status}`)
267
- }
268
-
269
- const location = res.headers.get('Location')
270
- if (location) {
271
- this.sessionUrl = new URL(location, this.conf.url).toString()
272
- }
273
-
274
- return res.text()
275
- })
276
- }
277
-
278
- /**
279
- * Set a remote answer.
280
- */
281
- private setAnswer(answer: string): Promise<void> {
282
- if (this.state !== 'running') {
283
- throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
284
- }
285
-
286
- return this.pc!.setRemoteDescription(
287
- new RTCSessionDescription({
288
- type: 'answer',
289
- sdp: answer,
290
- }),
291
- ).then(() => {
292
- if (this.state !== 'running') {
138
+ return this.connectionManager.setAnswer(answer).then(() => {
139
+ if (this.state !== 'running')
293
140
  return
294
- }
295
141
 
296
142
  if (this.queuedCandidates.length !== 0) {
297
- this.sendLocalCandidates(this.queuedCandidates)
298
- this.queuedCandidates = []
143
+ const offerData = this.connectionManager.getOfferData()
144
+ if (offerData && this.sessionUrl) {
145
+ this.httpClient.sendLocalCandidates(this.sessionUrl, offerData, this.queuedCandidates)
146
+ this.queuedCandidates = []
147
+ }
299
148
  }
300
149
  })
301
150
  }
302
151
 
303
- /**
304
- * Handle local ICE candidates.
305
- */
306
- private onLocalCandidate(evt: RTCPeerConnectionIceEvent): void {
307
- if (this.state !== 'running') {
308
- return
309
- }
310
-
311
- if (evt.candidate) {
312
- if (this.sessionUrl) {
313
- this.sendLocalCandidates([evt.candidate])
152
+ private handleCandidate(candidate: RTCIceCandidate): void {
153
+ if (this.sessionUrl) {
154
+ const offerData = this.connectionManager.getOfferData()
155
+ if (offerData) {
156
+ this.httpClient.sendLocalCandidates(this.sessionUrl, offerData, [candidate])
314
157
  }
315
- else {
316
- this.queuedCandidates.push(evt.candidate)
317
- }
318
- }
319
- }
320
-
321
- /**
322
- * Send local ICE candidates to the endpoint.
323
- */
324
- private sendLocalCandidates(candidates: RTCIceCandidate[]): void {
325
- if (!this.sessionUrl || !this.offerData) {
326
- return
327
- }
328
-
329
- fetch(this.sessionUrl, {
330
- method: 'PATCH',
331
- headers: {
332
- 'Content-Type': 'application/trickle-ice-sdpfrag',
333
- 'If-Match': '*',
334
- },
335
- body: SdpUtils.generateSdpFragment(this.offerData, candidates),
336
- })
337
- .then((res) => {
338
- switch (res.status) {
339
- case 204:
340
- break
341
- case 404:
342
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, 'stream not found')
343
- default:
344
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, `bad status code ${res.status}`)
345
- }
346
- })
347
- .catch(err => this.handleError(err))
348
- }
349
-
350
- /**
351
- * Handle peer connection state changes.
352
- */
353
- private onConnectionState(): void {
354
- if (this.state !== 'running' || !this.pc) {
355
- return
356
158
  }
357
-
358
- // "closed" can arrive before "failed" and without
359
- // the close() method being called at all.
360
- // It happens when the other peer sends a termination
361
- // message like a DTLS CloseNotify.
362
- if (this.pc.connectionState === 'failed' || this.pc.connectionState === 'closed') {
363
- this.handleError(new WebRTCError(ErrorTypes.OTHER_ERROR, 'peer connection closed'))
364
- }
365
- else if (this.pc.connectionState === 'connected') {
366
- this.flowCheck.start()
159
+ else {
160
+ this.queuedCandidates.push(candidate)
367
161
  }
368
162
  }
369
163
 
370
- /**
371
- * Handle incoming tracks.
372
- */
373
- private onTrack(evt: RTCTrackEvent): void {
374
- this.stream = evt.streams[0]
375
- this.observer.start(this.conf.container, (isIntersecting) => {
376
- if (isIntersecting)
377
- this.resume()
378
- else
379
- this.pause()
380
- })
381
- }
382
-
383
- /**
384
- * 流是否为空
385
- */
386
- get paused() {
387
- return this.conf.container.srcObject === null
164
+ get paused(): boolean {
165
+ return this.trackManager.paused
388
166
  }
389
167
 
390
- /**
391
- * 暂停播放
392
- */
393
- pause() {
394
- this.conf.container.srcObject = null
168
+ pause(): void {
169
+ this.trackManager.pause()
395
170
  }
396
171
 
397
- /**
398
- * 恢复播放
399
- */
400
- resume() {
401
- if (this.stream && this.paused)
402
- this.conf.container.srcObject = this.stream
172
+ resume(): void {
173
+ this.trackManager.resume()
403
174
  }
404
175
  }
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
- }