whepts 1.0.1 → 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.
package/src/index.ts CHANGED
@@ -1,826 +1,7 @@
1
- import VisibilityObserver from '~/utils/observer'
1
+ import type { Conf, State } from './types'
2
+ import { ErrorTypes, WebRTCError } from './errors'
3
+ import WebRTCWhep from './whep'
2
4
 
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
- }
5
+ export { Conf, ErrorTypes, State, WebRTCError }
22
6
 
23
- /** Extend RTCConfiguration to include experimental properties */
24
- declare global {
25
- interface RTCConfiguration {
26
- sdpSemantics?: 'plan-b' | 'unified-plan'
27
- }
28
-
29
- interface RTCIceServer {
30
- credentialType?: 'password'
31
- }
32
- }
33
-
34
- /** Type for parsed offer data */
35
- interface ParsedOffer {
36
- iceUfrag: string
37
- icePwd: string
38
- medias: string[]
39
- }
40
-
41
- /**
42
- * 错误类型
43
- */
44
- export type ErrorType = string
45
-
46
- /**
47
- * 错误类型常量
48
- */
49
- export const ErrorTypes = {
50
- SIGNAL_ERROR: 'SignalError', // 信令异常
51
- STATE_ERROR: 'StateError', // 状态异常
52
- NETWORK_ERROR: 'NetworkError',
53
- MEDIA_ERROR: 'MediaError',
54
- OTHER_ERROR: 'OtherError',
55
- }
56
-
57
- /**
58
- * 错误
59
- */
60
- export class WebRTCError extends Error {
61
- type: ErrorType
62
-
63
- constructor(type: ErrorType, message: string, options?: ErrorOptions) {
64
- super(message, options)
65
- this.type = type
66
- }
67
- }
68
-
69
- /** WebRTC/WHEP reader. */
70
- export default class WebRTCWhep {
71
- private retryPause: number = 2000
72
- private conf: Conf
73
- private state: 'getting_codecs' | 'running' | 'restarting' | 'closed' | 'failed'
74
- private restartTimeout?: NodeJS.Timeout
75
- private pc?: RTCPeerConnection
76
- private offerData?: ParsedOffer
77
- private sessionUrl?: string
78
- private queuedCandidates: RTCIceCandidate[] = []
79
- private nonAdvertisedCodecs: string[] = []
80
- private container: HTMLMediaElement
81
- private observer: VisibilityObserver
82
- private stream?: MediaStream
83
- /**
84
- * 断连重试参数
85
- */
86
- private checkInterval: number = 5000
87
- private lastBytesReceived: number = 0
88
- private checkTimer?: NodeJS.Timeout
89
-
90
- /**
91
- * Create a WebRTCWhep.
92
- * @param {Conf} conf - Configuration.
93
- */
94
- constructor(conf: Conf) {
95
- this.conf = conf
96
- this.state = 'getting_codecs'
97
- this.container = conf.container
98
- this.observer = new VisibilityObserver()
99
- this.getNonAdvertisedCodecs()
100
- }
101
-
102
- /**
103
- * 媒体是否正常
104
- */
105
- get isRunning(): boolean {
106
- return this.state === 'running'
107
- }
108
-
109
- /**
110
- * Close the reader and all its resources.
111
- */
112
- close(): void {
113
- this.state = 'closed'
114
- this.pc?.close()
115
-
116
- this.observer.stop()
117
- this.stopFlowCheck()
118
- if (this.restartTimeout) {
119
- clearTimeout(this.restartTimeout)
120
- }
121
- }
122
-
123
- /**
124
- * Check if the browser supports a non-advertised codec.
125
- */
126
- private static async supportsNonAdvertisedCodec(codec: string, fmtp?: string): Promise<boolean> {
127
- return new Promise((resolve) => {
128
- const pc = new RTCPeerConnection({ iceServers: [] })
129
- const mediaType = 'audio'
130
- let payloadType = ''
131
-
132
- pc.addTransceiver(mediaType, { direction: 'recvonly' })
133
- pc.createOffer()
134
- .then((offer) => {
135
- if (!offer.sdp) {
136
- throw new WebRTCError(ErrorTypes.SIGNAL_ERROR, 'SDP not present')
137
- }
138
- if (offer.sdp.includes(` ${codec}`)) {
139
- // codec is advertised, there's no need to add it manually
140
- throw new WebRTCError(ErrorTypes.SIGNAL_ERROR, 'already present')
141
- }
142
-
143
- const sections = offer.sdp.split(`m=${mediaType}`)
144
-
145
- const payloadTypes = sections
146
- .slice(1)
147
- .map(s => s.split('\r\n')[0].split(' ').slice(3))
148
- .reduce((prev, cur) => [...prev, ...cur], [])
149
- payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
150
-
151
- const lines = sections[1].split('\r\n')
152
- lines[0] += ` ${payloadType}`
153
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} ${codec}`)
154
- if (fmtp !== undefined) {
155
- lines.splice(lines.length - 1, 0, `a=fmtp:${payloadType} ${fmtp}`)
156
- }
157
- sections[1] = lines.join('\r\n')
158
- offer.sdp = sections.join(`m=${mediaType}`)
159
- return pc.setLocalDescription(offer)
160
- })
161
- .then(() =>
162
- pc.setRemoteDescription(
163
- new RTCSessionDescription({
164
- type: 'answer',
165
- sdp:
166
- `v=0\r\n`
167
- + `o=- 6539324223450680508 0 IN IP4 0.0.0.0\r\n`
168
- + `s=-\r\n`
169
- + `t=0 0\r\n`
170
- + `a=fingerprint:sha-256 0D:9F:78:15:42:B5:4B:E6:E2:94:3E:5B:37:78:E1:4B:54:59:A3:36:3A:E5:05:EB:27:EE:8F:D2:2D:41:29:25\r\n`
171
- + `m=${mediaType} 9 UDP/TLS/RTP/SAVPF ${payloadType}\r\n`
172
- + `c=IN IP4 0.0.0.0\r\n`
173
- + `a=ice-pwd:7c3bf4770007e7432ee4ea4d697db675\r\n`
174
- + `a=ice-ufrag:29e036dc\r\n`
175
- + `a=sendonly\r\n`
176
- + `a=rtcp-mux\r\n`
177
- + `a=rtpmap:${payloadType} ${codec}\r\n${fmtp !== undefined ? `a=fmtp:${payloadType} ${fmtp}\r\n` : ''}`,
178
- }),
179
- ),
180
- )
181
- .then(() => resolve(true))
182
- .catch(() => resolve(false))
183
- .finally(() => pc.close())
184
- })
185
- }
186
-
187
- /**
188
- * Unquote a credential string.
189
- */
190
- private static unquoteCredential(v: string): string {
191
- return JSON.parse(`"${v}"`)
192
- }
193
-
194
- /**
195
- * Convert Link header to iceServers array.
196
- */
197
- private static linkToIceServers(links: string | null): RTCIceServer[] {
198
- if (links) {
199
- return links.split(', ').map((link) => {
200
- const m = link.match(
201
- /^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i,
202
- )
203
-
204
- if (!m) {
205
- throw new WebRTCError(ErrorTypes.SIGNAL_ERROR, 'Invalid ICE server link format')
206
- }
207
-
208
- const ret: RTCIceServer = {
209
- urls: [m[1]],
210
- }
211
-
212
- if (m[3]) {
213
- ret.username = WebRTCWhep.unquoteCredential(m[3])
214
- ret.credential = WebRTCWhep.unquoteCredential(m[4])
215
- ret.credentialType = 'password'
216
- }
217
-
218
- return ret
219
- })
220
- }
221
- return []
222
- }
223
-
224
- /**
225
- * Parse an offer SDP into iceUfrag, icePwd, and medias.
226
- */
227
- private static parseOffer(sdp: string): ParsedOffer {
228
- const ret: ParsedOffer = {
229
- iceUfrag: '',
230
- icePwd: '',
231
- medias: [],
232
- }
233
-
234
- for (const line of sdp.split('\r\n')) {
235
- if (line.startsWith('m=')) {
236
- ret.medias.push(line.slice('m='.length))
237
- }
238
- else if (ret.iceUfrag === '' && line.startsWith('a=ice-ufrag:')) {
239
- ret.iceUfrag = line.slice('a=ice-ufrag:'.length)
240
- }
241
- else if (ret.icePwd === '' && line.startsWith('a=ice-pwd:')) {
242
- ret.icePwd = line.slice('a=ice-pwd:'.length)
243
- }
244
- }
245
-
246
- return ret
247
- }
248
-
249
- /**
250
- * Reserve a payload type.
251
- */
252
- private static reservePayloadType(payloadTypes: string[]): string {
253
- // everything is valid between 30 and 127, except for interval between 64 and 95
254
- // https://chromium.googlesource.com/external/webrtc/+/refs/heads/master/call/payload_type.h#29
255
- for (let i = 30; i <= 127; i++) {
256
- if ((i <= 63 || i >= 96) && !payloadTypes.includes(i.toString())) {
257
- const pl = i.toString()
258
- payloadTypes.push(pl)
259
- return pl
260
- }
261
- }
262
- throw new WebRTCError(ErrorTypes.SIGNAL_ERROR, 'unable to find a free payload type')
263
- }
264
-
265
- /**
266
- * Enable stereo PCMA/PCMU codecs.
267
- */
268
- private static enableStereoPcmau(payloadTypes: string[], section: string): string {
269
- const lines = section.split('\r\n')
270
-
271
- let payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
272
- lines[0] += ` ${payloadType}`
273
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} PCMU/8000/2`)
274
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
275
-
276
- payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
277
- lines[0] += ` ${payloadType}`
278
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} PCMA/8000/2`)
279
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
280
-
281
- return lines.join('\r\n')
282
- }
283
-
284
- /**
285
- * Enable multichannel Opus codec.
286
- */
287
- private static enableMultichannelOpus(payloadTypes: string[], section: string): string {
288
- const lines = section.split('\r\n')
289
-
290
- let payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
291
- lines[0] += ` ${payloadType}`
292
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/3`)
293
- lines.splice(lines.length - 1, 0, `a=fmtp:${payloadType} channel_mapping=0,2,1;num_streams=2;coupled_streams=1`)
294
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
295
-
296
- payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
297
- lines[0] += ` ${payloadType}`
298
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/4`)
299
- lines.splice(lines.length - 1, 0, `a=fmtp:${payloadType} channel_mapping=0,1,2,3;num_streams=2;coupled_streams=2`)
300
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
301
-
302
- payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
303
- lines[0] += ` ${payloadType}`
304
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/5`)
305
- lines.splice(
306
- lines.length - 1,
307
- 0,
308
- `a=fmtp:${payloadType} channel_mapping=0,4,1,2,3;num_streams=3;coupled_streams=2`,
309
- )
310
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
311
-
312
- payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
313
- lines[0] += ` ${payloadType}`
314
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/6`)
315
- lines.splice(
316
- lines.length - 1,
317
- 0,
318
- `a=fmtp:${payloadType} channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2`,
319
- )
320
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
321
-
322
- payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
323
- lines[0] += ` ${payloadType}`
324
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/7`)
325
- lines.splice(
326
- lines.length - 1,
327
- 0,
328
- `a=fmtp:${payloadType} channel_mapping=0,4,1,2,3,5,6;num_streams=4;coupled_streams=4`,
329
- )
330
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
331
-
332
- payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
333
- lines[0] += ` ${payloadType}`
334
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/8`)
335
- lines.splice(
336
- lines.length - 1,
337
- 0,
338
- `a=fmtp:${payloadType} channel_mapping=0,6,1,4,5,2,3,7;num_streams=5;coupled_streams=4`,
339
- )
340
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
341
-
342
- return lines.join('\r\n')
343
- }
344
-
345
- /**
346
- * Enable L16 codec.
347
- */
348
- private static enableL16(payloadTypes: string[], section: string): string {
349
- const lines = section.split('\r\n')
350
-
351
- let payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
352
- lines[0] += ` ${payloadType}`
353
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} L16/8000/2`)
354
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
355
-
356
- payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
357
- lines[0] += ` ${payloadType}`
358
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} L16/16000/2`)
359
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
360
-
361
- payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
362
- lines[0] += ` ${payloadType}`
363
- lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} L16/48000/2`)
364
- lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
365
-
366
- return lines.join('\r\n')
367
- }
368
-
369
- /**
370
- * Enable stereo Opus codec.
371
- */
372
- private static enableStereoOpus(section: string): string {
373
- let opusPayloadFormat = ''
374
- const lines = section.split('\r\n')
375
-
376
- for (let i = 0; i < lines.length; i++) {
377
- if (lines[i].startsWith('a=rtpmap:') && lines[i].toLowerCase().includes('opus/')) {
378
- opusPayloadFormat = lines[i].slice('a=rtpmap:'.length).split(' ')[0]
379
- break
380
- }
381
- }
382
-
383
- if (opusPayloadFormat === '') {
384
- return section
385
- }
386
-
387
- for (let i = 0; i < lines.length; i++) {
388
- if (lines[i].startsWith(`a=fmtp:${opusPayloadFormat} `)) {
389
- if (!lines[i].includes('stereo')) {
390
- lines[i] += ';stereo=1'
391
- }
392
- if (!lines[i].includes('sprop-stereo')) {
393
- lines[i] += ';sprop-stereo=1'
394
- }
395
- }
396
- }
397
-
398
- return lines.join('\r\n')
399
- }
400
-
401
- /**
402
- * Edit an offer SDP to enable non-advertised codecs.
403
- */
404
- private static editOffer(sdp: string, nonAdvertisedCodecs: string[]): string {
405
- const sections = sdp.split('m=')
406
-
407
- const payloadTypes = sections
408
- .slice(1)
409
- .map(s => s.split('\r\n')[0].split(' ').slice(3))
410
- .reduce((prev, cur) => [...prev, ...cur], [])
411
-
412
- for (let i = 1; i < sections.length; i++) {
413
- if (sections[i].startsWith('audio')) {
414
- sections[i] = WebRTCWhep.enableStereoOpus(sections[i])
415
-
416
- if (nonAdvertisedCodecs.includes('pcma/8000/2')) {
417
- sections[i] = WebRTCWhep.enableStereoPcmau(payloadTypes, sections[i])
418
- }
419
- if (nonAdvertisedCodecs.includes('multiopus/48000/6')) {
420
- sections[i] = WebRTCWhep.enableMultichannelOpus(payloadTypes, sections[i])
421
- }
422
- if (nonAdvertisedCodecs.includes('L16/48000/2')) {
423
- sections[i] = WebRTCWhep.enableL16(payloadTypes, sections[i])
424
- }
425
-
426
- break
427
- }
428
- }
429
-
430
- return sections.join('m=')
431
- }
432
-
433
- /**
434
- * Generate an SDP fragment.
435
- */
436
- private static generateSdpFragment(od: ParsedOffer, candidates: RTCIceCandidate[]): string {
437
- const candidatesByMedia: Record<number, RTCIceCandidate[]> = {}
438
- for (const candidate of candidates) {
439
- const mid = candidate.sdpMLineIndex
440
- if (mid) {
441
- if (candidatesByMedia[mid] === undefined) {
442
- candidatesByMedia[mid] = []
443
- }
444
- candidatesByMedia[mid].push(candidate)
445
- }
446
- }
447
-
448
- let frag = `a=ice-ufrag:${od.iceUfrag}\r\n` + `a=ice-pwd:${od.icePwd}\r\n`
449
-
450
- let mid = 0
451
-
452
- for (const media of od.medias) {
453
- if (candidatesByMedia[mid] !== undefined) {
454
- frag += `m=${media}\r\n` + `a=mid:${mid}\r\n`
455
-
456
- for (const candidate of candidatesByMedia[mid]) {
457
- frag += `a=${candidate.candidate}\r\n`
458
- }
459
- }
460
- mid++
461
- }
462
-
463
- return frag
464
- }
465
-
466
- /**
467
- * Handle errors.
468
- */
469
- private handleError(err: Error | WebRTCError): void {
470
- this.stopFlowCheck()
471
-
472
- if (this.state === 'getting_codecs') {
473
- this.state = 'failed'
474
- }
475
- else if (err instanceof WebRTCError && err.type === ErrorTypes.SIGNAL_ERROR) {
476
- this.state = 'failed'
477
- }
478
- else if (this.state === 'running') {
479
- this.pc?.close()
480
- this.pc = undefined
481
-
482
- this.offerData = undefined
483
- this.queuedCandidates = []
484
-
485
- if (this.sessionUrl) {
486
- fetch(this.sessionUrl, {
487
- method: 'DELETE',
488
- })
489
- this.sessionUrl = undefined
490
- }
491
-
492
- this.state = 'restarting'
493
-
494
- this.restartTimeout = setTimeout(() => {
495
- this.restartTimeout = undefined
496
- this.state = 'running'
497
- this.start()
498
- }, this.retryPause)
499
-
500
- err.message = `${err.message}, retrying in some seconds`
501
- }
502
-
503
- if (this.conf.onError) {
504
- if (err instanceof WebRTCError) {
505
- this.conf.onError(err)
506
- }
507
- else {
508
- this.conf.onError(new WebRTCError(ErrorTypes.OTHER_ERROR, err.message))
509
- }
510
- }
511
- }
512
-
513
- /**
514
- * Get non-advertised codecs.
515
- */
516
- private getNonAdvertisedCodecs(): void {
517
- Promise.all(
518
- [
519
- ['pcma/8000/2'],
520
- ['multiopus/48000/6', 'channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2'],
521
- ['L16/48000/2'],
522
- ].map(c => WebRTCWhep.supportsNonAdvertisedCodec(c[0], c[1]).then(r => (r ? c[0] : false))),
523
- )
524
- .then(c => c.filter(e => e !== false))
525
- .then((codecs) => {
526
- if (this.state !== 'getting_codecs') {
527
- throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
528
- }
529
-
530
- this.nonAdvertisedCodecs = codecs
531
- this.state = 'running'
532
- this.start()
533
- })
534
- .catch(err => this.handleError(err))
535
- }
536
-
537
- /**
538
- * Start the WebRTC session.
539
- */
540
- private start(): void {
541
- this.requestICEServers()
542
- .then(iceServers => this.setupPeerConnection(iceServers))
543
- .then(offer => this.sendOffer(offer))
544
- .then(answer => this.setAnswer(answer))
545
- .catch(err => this.handleError(err))
546
- }
547
-
548
- /**
549
- * Generate an authorization header.
550
- */
551
- private authHeader(): Record<string, string> {
552
- if (this.conf.user && this.conf.user !== '') {
553
- const credentials = btoa(`${this.conf.user}:${this.conf.pass}`)
554
- return { Authorization: `Basic ${credentials}` }
555
- }
556
- if (this.conf.token && this.conf.token !== '') {
557
- return { Authorization: `Bearer ${this.conf.token}` }
558
- }
559
- return {}
560
- }
561
-
562
- /**
563
- * Request ICE servers from the endpoint.
564
- */
565
- private async requestICEServers(): Promise<RTCIceServer[]> {
566
- if (this.conf.iceServers && this.conf.iceServers.length > 0) {
567
- return this.conf.iceServers
568
- }
569
-
570
- return fetch(this.conf.url, {
571
- method: 'OPTIONS',
572
- headers: {
573
- ...this.authHeader(),
574
- },
575
- }).then(res => WebRTCWhep.linkToIceServers(res.headers.get('Link')))
576
- }
577
-
578
- /**
579
- * Setup a peer connection.
580
- */
581
- private async setupPeerConnection(iceServers: RTCIceServer[]): Promise<string> {
582
- if (this.state !== 'running') {
583
- throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
584
- }
585
-
586
- const pc = new RTCPeerConnection({
587
- iceServers,
588
- // https://webrtc.org/getting-started/unified-plan-transition-guide
589
- sdpSemantics: 'unified-plan',
590
- })
591
- this.pc = pc
592
-
593
- const direction: RTCRtpTransceiverDirection = 'recvonly'
594
- pc.addTransceiver('video', { direction })
595
- pc.addTransceiver('audio', { direction })
596
-
597
- pc.onicecandidate = (evt: RTCPeerConnectionIceEvent) => this.onLocalCandidate(evt)
598
- pc.onconnectionstatechange = () => this.onConnectionState()
599
- pc.ontrack = (evt: RTCTrackEvent) => this.onTrack(evt)
600
-
601
- return pc.createOffer().then((offer) => {
602
- if (!offer.sdp) {
603
- throw new WebRTCError(ErrorTypes.SIGNAL_ERROR, 'Failed to create offer SDP')
604
- }
605
-
606
- offer.sdp = WebRTCWhep.editOffer(offer.sdp, this.nonAdvertisedCodecs)
607
- this.offerData = WebRTCWhep.parseOffer(offer.sdp)
608
-
609
- return pc.setLocalDescription(offer).then(() => offer.sdp!)
610
- })
611
- }
612
-
613
- /**
614
- * Send an offer to the endpoint.
615
- */
616
- private sendOffer(offer: string): Promise<string> {
617
- if (this.state !== 'running') {
618
- throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
619
- }
620
-
621
- return fetch(this.conf.url, {
622
- method: 'POST',
623
- headers: {
624
- ...this.authHeader(),
625
- 'Content-Type': 'application/sdp',
626
- },
627
- body: offer,
628
- }).then((res) => {
629
- switch (res.status) {
630
- case 201:
631
- break
632
- case 404:
633
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, 'stream not found')
634
- case 406:
635
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, 'stream not supported')
636
- case 400:
637
- return res.json().then((e: { error: string }) => {
638
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, e.error)
639
- })
640
- default:
641
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, `bad status code ${res.status}`)
642
- }
643
-
644
- const location = res.headers.get('Location')
645
- if (location) {
646
- this.sessionUrl = new URL(location, this.conf.url).toString()
647
- }
648
-
649
- return res.text()
650
- })
651
- }
652
-
653
- /**
654
- * Set a remote answer.
655
- */
656
- private setAnswer(answer: string): Promise<void> {
657
- if (this.state !== 'running') {
658
- throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
659
- }
660
-
661
- return this.pc!.setRemoteDescription(
662
- new RTCSessionDescription({
663
- type: 'answer',
664
- sdp: answer,
665
- }),
666
- ).then(() => {
667
- if (this.state !== 'running') {
668
- return
669
- }
670
-
671
- if (this.queuedCandidates.length !== 0) {
672
- this.sendLocalCandidates(this.queuedCandidates)
673
- this.queuedCandidates = []
674
- }
675
- })
676
- }
677
-
678
- /**
679
- * Handle local ICE candidates.
680
- */
681
- private onLocalCandidate(evt: RTCPeerConnectionIceEvent): void {
682
- if (this.state !== 'running') {
683
- return
684
- }
685
-
686
- if (evt.candidate) {
687
- if (this.sessionUrl) {
688
- this.sendLocalCandidates([evt.candidate])
689
- }
690
- else {
691
- this.queuedCandidates.push(evt.candidate)
692
- }
693
- }
694
- }
695
-
696
- /**
697
- * Send local ICE candidates to the endpoint.
698
- */
699
- private sendLocalCandidates(candidates: RTCIceCandidate[]): void {
700
- if (!this.sessionUrl || !this.offerData) {
701
- return
702
- }
703
-
704
- fetch(this.sessionUrl, {
705
- method: 'PATCH',
706
- headers: {
707
- 'Content-Type': 'application/trickle-ice-sdpfrag',
708
- 'If-Match': '*',
709
- },
710
- body: WebRTCWhep.generateSdpFragment(this.offerData, candidates),
711
- })
712
- .then((res) => {
713
- switch (res.status) {
714
- case 204:
715
- break
716
- case 404:
717
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, 'stream not found')
718
- default:
719
- throw new WebRTCError(ErrorTypes.NETWORK_ERROR, `bad status code ${res.status}`)
720
- }
721
- })
722
- .catch(err => this.handleError(err))
723
- }
724
-
725
- /**
726
- * Handle peer connection state changes.
727
- */
728
- private onConnectionState(): void {
729
- if (this.state !== 'running' || !this.pc) {
730
- return
731
- }
732
-
733
- // "closed" can arrive before "failed" and without
734
- // the close() method being called at all.
735
- // It happens when the other peer sends a termination
736
- // message like a DTLS CloseNotify.
737
- if (this.pc.connectionState === 'failed' || this.pc.connectionState === 'closed') {
738
- this.handleError(new WebRTCError(ErrorTypes.OTHER_ERROR, 'peer connection closed'))
739
- }
740
- else if (this.pc.connectionState === 'connected') {
741
- this.startFlowCheck()
742
- }
743
- }
744
-
745
- /**
746
- * 启动断流检测
747
- */
748
- private startFlowCheck(): void {
749
- this.stopFlowCheck()
750
- this.checkTimer = setInterval(() => this.checkFlowState(), this.checkInterval)
751
- }
752
-
753
- /**
754
- * 停止断流检测
755
- */
756
- private stopFlowCheck(): void {
757
- if (this.checkTimer) {
758
- clearInterval(this.checkTimer)
759
- this.checkTimer = undefined
760
- }
761
- }
762
-
763
- /*
764
- * 检测流状态(通过接收字节数判断是否断流)
765
- */
766
- private async checkFlowState(): Promise<void> {
767
- if (!this.pc) {
768
- return
769
- }
770
-
771
- const stats = await this.pc.getStats()
772
- let currentBytes = 0
773
-
774
- // 遍历统计信息,获取视频接收字节数
775
- stats.forEach((stat: RTCStats) => {
776
- const inboundRtpStat = stat as RTCInboundRtpStreamStats
777
- if (stat.type === 'inbound-rtp' && inboundRtpStat.kind === 'video') {
778
- currentBytes = inboundRtpStat.bytesReceived || 0
779
- }
780
- })
781
-
782
- // 断流判定:连接正常但字节数无变化
783
- if (currentBytes === this.lastBytesReceived && this.pc.connectionState === 'connected') {
784
- this.handleError(new WebRTCError(ErrorTypes.NETWORK_ERROR, 'data stream interruption'))
785
- return
786
- }
787
-
788
- // 更新上一次字节数
789
- this.lastBytesReceived = currentBytes
790
- }
791
-
792
- /**
793
- * Handle incoming tracks.
794
- */
795
- private onTrack(evt: RTCTrackEvent): void {
796
- this.stream = evt.streams[0]
797
- this.observer.start(this.container, (isIntersecting) => {
798
- if (isIntersecting)
799
- this.resume()
800
- else
801
- this.pause()
802
- })
803
- }
804
-
805
- /**
806
- * 流是否为空
807
- */
808
- get paused() {
809
- return this.container.srcObject === null
810
- }
811
-
812
- /**
813
- * 暂停播放
814
- */
815
- pause() {
816
- this.container.srcObject = null
817
- }
818
-
819
- /**
820
- * 恢复播放
821
- */
822
- resume() {
823
- if (this.stream && this.paused)
824
- this.container.srcObject = this.stream
825
- }
826
- }
7
+ export default WebRTCWhep