@reactoo/watchtogether-sdk-js 2.7.97 → 2.8.0-beta.1

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,3692 @@
1
+ // Watch together janus webrtc library
2
+
3
+ import adapter from 'webrtc-adapter';
4
+ import emitter from './wt-emitter';
5
+ import {decodeJanusDisplay, generateUUID, maxJitter, median, wait} from "../models/utils";
6
+
7
+ class Room {
8
+
9
+ static noop() {
10
+
11
+ }
12
+
13
+ constructor(debug) {
14
+ this.debug = debug;
15
+ this.sessions = [];
16
+ this.safariVp8 = false;
17
+ this.browser = adapter.browserDetails.browser;
18
+ this.browserDetails = adapter.browserDetails;
19
+ this.webrtcSupported = Room.isWebrtcSupported();
20
+ this.safariVp8TestPromise = Room.testSafariVp8();
21
+ this.safariVp8 = null;
22
+ this.safariVp8TestPromise.then(safariVp8 => {
23
+ this.safariVp8 = safariVp8;
24
+ })
25
+
26
+ this._log = Room.noop;
27
+ if (this.debug) {
28
+ this.#enableDebug();
29
+ }
30
+
31
+ // Let's get it started
32
+ this.whenInitialized = this.initialize();
33
+ }
34
+
35
+ #enableDebug() {
36
+ this._log = console.log.bind(console);
37
+ }
38
+
39
+ initialize() {
40
+ return this.safariVp8TestPromise
41
+ .then(() => this);
42
+ }
43
+
44
+ createSession(constructId = null, type = 'reactooroom', options = {}) {
45
+ return new RoomSession(constructId, type, {debug: this.debug, ...options});
46
+ }
47
+
48
+ static testSafariVp8() {
49
+ return new Promise(resolve => {
50
+ if (adapter.browserDetails.browser === 'safari' &&
51
+ adapter.browserDetails.version >= 605) {
52
+ if (RTCRtpSender && RTCRtpSender.getCapabilities && RTCRtpSender.getCapabilities("video") &&
53
+ RTCRtpSender.getCapabilities("video").codecs && RTCRtpSender.getCapabilities("video").codecs.length) {
54
+ var isVp8 = false;
55
+ for (var i in RTCRtpSender.getCapabilities("video").codecs) {
56
+ var codec = RTCRtpSender.getCapabilities("video").codecs[i];
57
+ if (codec && codec.mimeType && codec.mimeType.toLowerCase() === "video/vp8") {
58
+ isVp8 = true;
59
+ break;
60
+ }
61
+ }
62
+ resolve(isVp8);
63
+ } else {
64
+ // We do it in a very ugly way, as there's no alternative...
65
+ // We create a PeerConnection to see if VP8 is in an offer
66
+ var testpc = new RTCPeerConnection({}, {});
67
+ testpc.createOffer({offerToReceiveVideo: true}).then(function (offer) {
68
+ let result = offer.sdp.indexOf("VP8") !== -1;
69
+ testpc.close();
70
+ testpc = null;
71
+ resolve(result);
72
+ });
73
+ }
74
+ } else resolve(false);
75
+ });
76
+ }
77
+
78
+ static isWebrtcSupported() {
79
+ return window.RTCPeerConnection !== undefined && window.RTCPeerConnection !== null &&
80
+ navigator.mediaDevices !== undefined && navigator.mediaDevices !== null &&
81
+ navigator.mediaDevices.getUserMedia !== undefined && navigator.mediaDevices.getUserMedia !== null;
82
+ }
83
+ }
84
+
85
+ class RoomSession {
86
+
87
+ static noop() {
88
+
89
+ }
90
+
91
+ static randomString(len) {
92
+ var charSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
93
+ var randomString = '';
94
+ for (var i = 0; i < len; i++) {
95
+ var randomPoz = Math.floor(Math.random() * charSet.length);
96
+ randomString += charSet.substring(randomPoz, randomPoz + 1);
97
+ }
98
+ return randomString;
99
+ }
100
+
101
+ static sessionTypes = {
102
+ 'reactooroom': 'janus.plugin.reactooroom',
103
+ }
104
+
105
+ static userRoleSubscriptionRules = {
106
+ participant: {
107
+ "watchparty": ['participant', 'talkback'],
108
+ "studio": ['participant', 'talkback', 'host', 'observer'],
109
+ "commentary": ['participant', 'talkback', 'host'],
110
+ "intercom": ['host', 'talkback', 'observer', 'observerSolo1', 'observerSolo2', 'observerSolo3', 'observerSolo4', 'observerSolo5'],
111
+ "videowall": ['host', 'talkback', 'observer', 'observerSolo1', 'observerSolo2', 'observerSolo3', 'observerSolo4', 'observerSolo5'],
112
+ "videowall-queue": ['host', 'talkback', 'observer', 'observerSolo1', 'observerSolo2', 'observerSolo3', 'observerSolo4', 'observerSolo5'],
113
+ "videowall-queue-video": ['host', 'talkback', 'observer', 'observerSolo1', 'observerSolo2', 'observerSolo3', 'observerSolo4', 'observerSolo5']
114
+ },
115
+ monitor: {
116
+ "watchparty": ['participant', 'host'],
117
+ "studio": ['participant', 'host', 'observer'],
118
+ "commentary": ['participant', 'host'],
119
+ "intercom": ['host', 'participant'],
120
+ "videowall": ['host', 'participant'],
121
+ "videowall-queue": ['host', 'participant'],
122
+ "videowall-queue-video": ['host', 'participant'],
123
+ },
124
+ talkback: {
125
+ "watchparty": ['participant', 'host', 'talkback'],
126
+ "studio": ['participant', 'host', 'observer', 'talkback'],
127
+ "commentary": ['host', 'participant', 'talkback'],
128
+ "intercom": ['host', 'participant', 'talkback'],
129
+ "videowall": ['host', 'participant', 'talkback'],
130
+ "videowall-queue": ['host', 'participant', 'talkback'],
131
+ "videowall-queue-video": ['host', 'participant', 'talkback'],
132
+ },
133
+ observer: {
134
+ "watchparty": ['participant'],
135
+ "studio": ['participant'],
136
+ "commentary": ['participant'],
137
+ "intercom": ['participant'],
138
+ "videowall": ['participant'],
139
+ "videowall-queue": ['participant'],
140
+ "videowall-queue-video": ['participant'],
141
+ },
142
+ observerSolo1: {
143
+ "watchparty": ['participant'],
144
+ "studio": ['participant'],
145
+ "commentary": ['participant'],
146
+ "intercom": ['participant'],
147
+ "videowall": ['participant'],
148
+ "videowall-queue": ['participant'],
149
+ "videowall-queue-video": ['participant'],
150
+ },
151
+ observerSolo2: {
152
+ "watchparty": ['participant'],
153
+ "studio": ['participant'],
154
+ "commentary": ['participant'],
155
+ "intercom": ['participant'],
156
+ "videowall": ['participant'],
157
+ "videowall-queue": ['participant'],
158
+ "videowall-queue-video": ['participant'],
159
+ },
160
+ observerSolo3: {
161
+ "watchparty": ['participant'],
162
+ "studio": ['participant'],
163
+ "commentary": ['participant'],
164
+ "intercom": ['participant'],
165
+ "videowall": ['participant'],
166
+ "videowall-queue": ['participant'],
167
+ "videowall-queue-video": ['participant'],
168
+ },
169
+ observerSolo4: {
170
+ "watchparty": ['participant'],
171
+ "studio": ['participant'],
172
+ "commentary": ['participant'],
173
+ "intercom": ['participant'],
174
+ "videowall": ['participant'],
175
+ "videowall-queue": ['participant'],
176
+ "videowall-queue-video": ['participant'],
177
+ },
178
+ observerSolo5: {
179
+ "watchparty": ['participant'],
180
+ "studio": ['participant'],
181
+ "commentary": ['participant'],
182
+ "intercom": ['participant'],
183
+ "videowall": ['participant'],
184
+ "videowall-queue": ['participant'],
185
+ "videowall-queue-video": ['participant'],
186
+ },
187
+ host: {
188
+ "watchparty": [],
189
+ "studio": [],
190
+ "commentary": [],
191
+ "intercom": [],
192
+ "videowall": [],
193
+ "videowall-queue": [],
194
+ "videowall-queue-video": [],
195
+ },
196
+ companionTV: {},
197
+ companionPhone: {},
198
+ };
199
+
200
+ #publisherHandle = null;
201
+ #subscriberHandle = null;
202
+ #subscriberJoinPromise = null;
203
+
204
+ // Configuration for simulcast quality selection
205
+ #rtcStatsConfig = {
206
+ // Network thresholds
207
+ packetLossThreshold: 3.0, // Percentage of packet loss to trigger downgrade
208
+ jitterBufferThreshold: 100, // ms of jitter buffer delay to trigger downgrade
209
+ roundTripTimeThreshold: 250, // ms of RTT to trigger downgrade
210
+ frameDropRateThreshold: 5, // Percentage of frame drops to trigger downgrade
211
+ freezeLengthThreshold: 0.7, // 70% of freeze time for current monitored period
212
+ // Hysteresis to prevent rapid switching (ms)
213
+ switchCooldown: 15000, // Min time between switches
214
+ switchCooldownForFreeze: 3000,
215
+ historySize: 60000, // Ms of history to keep
216
+ badBandwidthThresholdMultiplier: 0.8, // 80% of required bitrate
217
+ // Stability thresholds
218
+ stableNetworkTime: 10000, // Time in ms to consider network stable
219
+ };
220
+
221
+
222
+ constructor(constructId = null, type = 'reactooroom', options = {}) {
223
+
224
+ Object.assign(this, emitter());
225
+ this.options = {...options};
226
+ this.defaultDataChannelLabel = 'JanusDataChannel'
227
+ this.server = null;
228
+ this.iceServers = null;
229
+ this.token = null;
230
+ this.roomId = null;
231
+ this.pin = null;
232
+ this.userId = null;
233
+ this.initialBitrate = 0;
234
+ this.enableDtx = false;
235
+ this.simulcast = false;
236
+
237
+ this.defaultSimulcastSettings = {
238
+ "default" : {
239
+ mode: "controlled", // controlled, manual, browserControlled
240
+ defaultSubstream: 0, // 2 lowest quality, 0 highest quality
241
+ bitrates: [
242
+ {
243
+ "rid": "l",
244
+ "active": true,
245
+ "maxBitrate": 180000,
246
+ "maxFramerate": 20,
247
+ "scaleResolutionDownBy": 3.3333333333333335,
248
+ "priority": "low"
249
+ },
250
+ {
251
+ "rid": "m",
252
+ "active": true,
253
+ "maxBitrate": 500000,
254
+ "maxFramerate": 25,
255
+ "scaleResolutionDownBy": 1.3333333333333335,
256
+ "priority": "low"
257
+ },
258
+ {
259
+ "rid": "h",
260
+ "active": true,
261
+ "maxBitrate": 2000000,
262
+ "maxFramerate": 30,
263
+ "priority": "low"
264
+ }
265
+ ]
266
+ },
267
+ };
268
+ this.recordingFilename = null;
269
+ this.pluginName = RoomSession.sessionTypes[type];
270
+ this.id = null;
271
+ this.privateId = null;
272
+ this.handleId = null;
273
+ this.constructId = constructId || RoomSession.randomString(16);
274
+ this.sessionId = null;
275
+ this.apisecret = null;
276
+ this.ws = null;
277
+
278
+ // helper flags
279
+
280
+ this.isSupposeToBeConnected = false;
281
+ this.isConnecting = false;
282
+ this.isEstablishingConnection = false;
283
+ this.isDisconnecting = false;
284
+ this.isConnected = false;
285
+ this.isPublished = false;
286
+
287
+ this.isMuted = [];
288
+
289
+ this.requestMuteStatusTimeoutId = null;
290
+ this.requestMuteStatusTimeout = 100;
291
+ this._statsInterval = 3000;
292
+ this._statsIntervalId = null;
293
+ this._sendMessageTimeout = 10000;
294
+ this._keepAlivePeriod = 25000;
295
+ this._longPollTimeout = 60000;
296
+ this._maxev = 10;
297
+ this._keepAliveId = null;
298
+ this._restrictSubscribeToUserIds = []; // all if empty
299
+ this._talkIntercomChannels = ['participants'];
300
+ this._listenIntercomChannels = ['participants'];
301
+ this._roomType = 'watchparty';
302
+ this._isDataChannelOpen = false;
303
+ this._abortController = null;
304
+
305
+ this.userRoleSubscriptionRules = {
306
+ ...RoomSession.userRoleSubscriptionRules,
307
+ ...(this.options.userRoleSubscriptionRules || {})
308
+ }
309
+
310
+ this._log = RoomSession.noop;
311
+ if (this.options.debug) {
312
+ this.#enableDebug();
313
+ }
314
+
315
+ }
316
+
317
+ #httpAPICall = function(url, options) {
318
+ return new Promise((resolve, reject) => {
319
+ const xhr = new XMLHttpRequest();
320
+
321
+ xhr.open(options.verb || 'POST', url);
322
+
323
+ // Set default headers
324
+ xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
325
+ if (options.verb === "POST") {
326
+ xhr.setRequestHeader('Content-Type', 'application/json');
327
+ }
328
+
329
+ // Handle credentials
330
+ if (options.withCredentials) {
331
+ xhr.withCredentials = true;
332
+ }
333
+
334
+ // Set timeout if specified
335
+ if (options.timeout) {
336
+ xhr.timeout = options.timeout;
337
+ }
338
+
339
+ // Setup handlers
340
+ xhr.onload = function() {
341
+ if (xhr.status >= 200 && xhr.status < 300) {
342
+ let response;
343
+ try {
344
+ response = JSON.parse(xhr.responseText);
345
+ } catch(e) {
346
+ if (options.error) {
347
+ options.error('Invalid JSON', xhr.responseText);
348
+ }
349
+ reject('Invalid JSON');
350
+ return;
351
+ }
352
+ if (options.success) {
353
+ options.success(response);
354
+ }
355
+ resolve(response);
356
+ } else {
357
+ let errorText = xhr.statusText || 'HTTP Error';
358
+ if (options.error) {
359
+ options.error(errorText, xhr.responseText);
360
+ }
361
+ reject(errorText);
362
+ }
363
+ };
364
+
365
+ xhr.onerror = function() {
366
+ let errorText = 'Network error';
367
+ if (options.error) {
368
+ options.error(errorText, 'Is the server down?');
369
+ }
370
+ reject(errorText);
371
+ };
372
+
373
+ xhr.ontimeout = function() {
374
+ let errorText = 'Request timed out';
375
+ if (options.error) {
376
+ options.error(errorText, 'Is the server down?');
377
+ }
378
+ reject(errorText);
379
+ };
380
+
381
+ // Send request
382
+ try {
383
+ if (options.body) {
384
+ xhr.send(JSON.stringify(options.body));
385
+ } else {
386
+ xhr.send();
387
+ }
388
+ } catch(e) {
389
+ if (options.error) {
390
+ options.error('Error sending request', e);
391
+ }
392
+ reject(e);
393
+ }
394
+ });
395
+ }
396
+
397
+ #cacheAttendee(attendee) {
398
+ if(this.#subscriberHandle) {
399
+ this.#subscriberHandle.webrtcStuff.userIdToDisplay[attendee.id] = attendee.display;
400
+ }
401
+ };
402
+
403
+ #removeAttendeeFromCache(id) {
404
+ if(this.#subscriberHandle) {
405
+ delete this.#subscriberHandle.webrtcStuff.userIdToDisplay[id];
406
+ }
407
+ }
408
+
409
+ #getCachedAttendeeIds() {
410
+ return Object.keys(this.#subscriberHandle?.webrtcStuff?.userIdToDisplay || {});
411
+ }
412
+
413
+ #getDisplayById(id) {
414
+ return this.#subscriberHandle.webrtcStuff.userIdToDisplay[id];
415
+ }
416
+
417
+ #emitLocalFeedUpdate({feedRemoval = false, addingTrack = false, removingTrack = false, track = null, source = null, mid = null} = {}) {
418
+
419
+ if(!this.id) {
420
+ // we're not connected probably at all
421
+ return;
422
+ }
423
+
424
+ const eventName = !feedRemoval ? 'addLocalParticipant' : 'removeLocalParticipant';
425
+ const streamMap = this.#getStreamMapForFeed(this.id);
426
+
427
+ const sourceTrackIds = streamMap[source] ?? [];
428
+ const sourceRelatedTracks = this.#publisherHandle?.webrtcStuff?.tracks?.filter(t => sourceTrackIds.includes(t.id)) ?? [];
429
+
430
+ const tracks = this.#publisherHandle?.webrtcStuff?.tracks ?? [];
431
+
432
+ this.emit(eventName, {
433
+ tid: generateUUID(),
434
+ id: this.id,
435
+ constructId: this.constructId,
436
+ userId: decodeJanusDisplay(this.userId)?.userId,
437
+ fullUserId: this.display,
438
+ role: decodeJanusDisplay(this.display)?.role,
439
+ track: track,
440
+ source: source,
441
+ adding: addingTrack,
442
+ removing: removingTrack,
443
+ streamMap: streamMap,
444
+ sourceRelatedTracks: sourceRelatedTracks,
445
+ tracks: tracks.map(t=>t),
446
+ hasAudioTrack: !!tracks.find(track => track.kind === 'audio'),
447
+ hasVideoTrack: !!tracks.find(track => track.kind === 'video'),
448
+ });
449
+ }
450
+
451
+ #emitRemoteFeedUpdate(mid, {feedRemoval = false, addingTrack = false, removingTrack = false, track = null, source = null, attendee = null} = {}) {
452
+
453
+ const transceiverData = this.#getTransceiverDataByMid(mid);
454
+
455
+ if(transceiverData) {
456
+
457
+ const id = transceiverData.feed_id;
458
+ const display = transceiverData.feed_display;
459
+ const parsedDisplay = decodeJanusDisplay(display);
460
+ let description = {};
461
+ try {
462
+ description = JSON.parse(transceiverData.feed_description);
463
+ } catch (e) {}
464
+
465
+ if(!this.#shouldEmitFeedUpdate(parsedDisplay)) {
466
+ this._log('Not emitting feed update for', display, 'because of subscription rules');
467
+ // we don't want to emit this event
468
+ return;
469
+ }
470
+
471
+ const source = description.source;
472
+ const role = parsedDisplay?.role;
473
+ const userId = parsedDisplay?.userId;
474
+ const eventName = this.#getParticipantEventName(!feedRemoval, role);
475
+ const streamMap = this.#getStreamMapForFeed(id);
476
+
477
+ const sourceTrackIds = streamMap[source] ?? [];
478
+ const sourceRelatedTracks = this.#subscriberHandle?.webrtcStuff?.tracks?.filter(t => sourceTrackIds.includes(t.id)) ?? [];
479
+
480
+ const trackIds = Object.keys(streamMap).reduce((acc, key) => {
481
+ streamMap[key].forEach(trackId => {
482
+ if (!acc.includes(trackId)) {
483
+ acc.push(trackId);
484
+ }
485
+ });
486
+ return acc;
487
+ }, []);
488
+
489
+ const tracks = this.#subscriberHandle?.webrtcStuff?.tracks?.filter(t => trackIds.includes(t.id)) ?? [];
490
+
491
+ this.emit(eventName, {
492
+ tid: generateUUID(),
493
+ id,
494
+ userId,
495
+ role,
496
+ fullUserId: display,
497
+ constructId: this.constructId,
498
+ track: track,
499
+ source: source,
500
+ adding: addingTrack,
501
+ removing: removingTrack,
502
+ streamMap,
503
+ sourceRelatedTracks,
504
+ tracks,
505
+ hasAudioTrack: !!tracks.find(track => track.kind === 'audio'),
506
+ hasVideoTrack: !!tracks.find(track => track.kind === 'video'),
507
+
508
+ });
509
+
510
+ } else if(attendee) {
511
+
512
+ const id = attendee.id;
513
+ const display = this.#getDisplayById(id);
514
+ const parsedDisplay = decodeJanusDisplay(display);
515
+
516
+ if(!this.#shouldEmitFeedUpdate(parsedDisplay)) {
517
+ this._log('Not emitting feed update for', display, 'because of subscription rules');
518
+ // we don't want to emit this event
519
+ return;
520
+ }
521
+
522
+ const role = parsedDisplay?.role;
523
+ const userId = parsedDisplay?.userId;
524
+ const streamMap = this.#getStreamMapForFeed(id);
525
+ const eventName = this.#getParticipantEventName(!feedRemoval, role);
526
+
527
+ this.emit(eventName, {
528
+ tid: generateUUID(),
529
+ id,
530
+ userId,
531
+ role,
532
+ fullUserId: display,
533
+ constructId: this.constructId,
534
+ track: null,
535
+ source: null,
536
+ adding: false,
537
+ removing: false,
538
+ streamMap,
539
+ sourceRelatedTracks: [],
540
+ tracks: [],
541
+ hasAudioTrack: false,
542
+ hasVideoTrack: false,
543
+ });
544
+ }
545
+
546
+ // ask for current mute status to each participant
547
+
548
+ clearTimeout(this.requestMuteStatusTimeoutId);
549
+ this.requestMuteStatusTimeoutId = setTimeout(() => {
550
+ if(this.isConnected) {
551
+ this.emit('requestMuteStatus')
552
+ }
553
+ }, this.requestMuteStatusTimeout)
554
+
555
+ }
556
+
557
+ #emitRemoteTrackMuted(mid, {track, muted} = {}) {
558
+
559
+ const transceiverData = this.#getTransceiverDataByMid(mid);
560
+
561
+ if(transceiverData) {
562
+
563
+ const id = transceiverData.feed_id;
564
+ const display = transceiverData.feed_display;
565
+ const parsedDisplay = decodeJanusDisplay(display);
566
+ let description = {};
567
+ try {
568
+ description = JSON.parse(transceiverData.feed_description);
569
+ } catch (e) {}
570
+
571
+ const source = description.source;
572
+ const role = parsedDisplay?.role;
573
+ const userId = parsedDisplay?.userId;
574
+ const streamMap = this.#getStreamMapForFeed(id);
575
+
576
+ this.emit('remoteTrackMuted', {
577
+ tid: generateUUID(),
578
+ id,
579
+ userId,
580
+ role,
581
+ mid,
582
+ fullUserId: display,
583
+ constructId: this.constructId,
584
+ streamMap,
585
+ source,
586
+ kind: track.kind,
587
+ track:track,
588
+ muted
589
+ });
590
+ }
591
+ }
592
+
593
+ #getStreamMapForFeed(id) {
594
+ if (id === this.id) {
595
+ return this.#publisherHandle?.webrtcStuff?.streamMap?.[id] || {};
596
+ }
597
+ else {
598
+ return this.#subscriberHandle?.webrtcStuff?.streamMap?.[id] || {};
599
+ }
600
+ }
601
+
602
+ #isSubscribedToMid(id, mid) {
603
+ if(this.#subscriberHandle) {
604
+ return !!this.#subscriberHandle.webrtcStuff.subscribeMap?.[id]?.[mid]
605
+ }
606
+ return false;
607
+ }
608
+
609
+ #addToSubscribeMap(id, mid) {
610
+ if(this.#subscriberHandle) {
611
+ this.#subscriberHandle.webrtcStuff.subscribeMap[id] = this.#subscriberHandle.webrtcStuff.subscribeMap[id] || {};
612
+ this.#subscriberHandle.webrtcStuff.subscribeMap[id][mid] = true;
613
+ }
614
+ }
615
+
616
+ #removeFromSubscribeMap(id, mid) {
617
+ if(this.#subscriberHandle) {
618
+ if(id && !mid) {
619
+ delete this.#subscriberHandle.webrtcStuff.subscribeMap[id];
620
+ }
621
+ else if(id && mid && this.#subscriberHandle.webrtcStuff.subscribeMap[id]) {
622
+ delete this.#subscriberHandle.webrtcStuff.subscribeMap[id][mid];
623
+ }
624
+ }
625
+ }
626
+
627
+ // will subscribe to all relevant participants
628
+
629
+ async #updateSubscriptions() {
630
+
631
+ // no subscriber handle, we create one and subscribe to relevant participants
632
+
633
+ if(!this.#subscriberJoinPromise) {
634
+ await this.#joinAsSubscriber(this.roomId, this.pin, this.userId);
635
+ }
636
+
637
+ else {
638
+
639
+ // we have a subscriber handle, we need to check if we need to update subscriptions
640
+ // if subscriber handle is not ready yet, we wait for it to be ready
641
+ // if it fails, we try to create a new one
642
+ // if it succeeds, we check if we need to update subscriptions
643
+
644
+ try {
645
+ await this.#subscriberJoinPromise;
646
+ } catch (e) {
647
+ await this.#joinAsSubscriber(this.roomId, this.pin, this.userId);
648
+ return;
649
+ }
650
+
651
+ const handle = this.#subscriberHandle;
652
+ const subscribedTo = handle.webrtcStuff.subscribeMap || {};
653
+ const publishers = handle.webrtcStuff.availablePublishers;
654
+ const subscribe = [];
655
+ const unsubscribe = [];
656
+ const flatSourceMap = [];
657
+
658
+ for (let index in publishers) {
659
+ if(publishers[index]["dummy"])
660
+ continue;
661
+
662
+ let userId = publishers[index]["display"];
663
+ let id = publishers[index]["id"];
664
+ let streams = publishers[index]["streams"] || [];
665
+ let description = {};
666
+
667
+ try {
668
+ const dString = streams.find(s => !!s.description)?.description;
669
+ if(dString) {
670
+ description = JSON.parse(dString);
671
+ }
672
+ } catch (e) {}
673
+
674
+ this._log('Remote publisher: ', id, userId, streams, description);
675
+
676
+ for (let i in streams) {
677
+ const track = streams[i];
678
+ const mid = track.mid; // source.mid
679
+ flatSourceMap.push({id: id, mid: mid});
680
+
681
+ const isSubscribed = this.#isSubscribedToMid(id, mid);
682
+
683
+ if(track.disabled) {
684
+ if(isSubscribed) {
685
+ unsubscribe.push({feed: id, mid: mid});
686
+ this.#removeFromSubscribeMap(id, mid);
687
+ }
688
+ continue;
689
+ }
690
+
691
+ if(!this.#shouldSubscribeParticipant(id, userId, description)) {
692
+ if(isSubscribed) {
693
+ unsubscribe.push({feed: id, mid: mid});
694
+ this.#removeFromSubscribeMap(id, mid);
695
+ }
696
+ continue;
697
+ }
698
+
699
+ if(!isSubscribed) {
700
+ subscribe.push({feed: id, mid: mid});
701
+ this.#addToSubscribeMap(id, mid);
702
+ }
703
+ }
704
+ }
705
+
706
+ // check if we're subscribed to any mid that is no longer available in sources
707
+
708
+ Object.keys(subscribedTo).forEach(id => {
709
+ const mids = Object.keys(subscribedTo[id]);
710
+ for(let mid of mids) {
711
+ if(!flatSourceMap.find(s => s.id === id && s.mid === mid)) {
712
+ unsubscribe.push({feed: id, mid});
713
+ this.#removeFromSubscribeMap(id, mid);
714
+ }
715
+ }
716
+ })
717
+
718
+ if(subscribe.length || unsubscribe.length) {
719
+ await this.sendMessage(handle.handleId, {
720
+ body: {
721
+ "request": "update",
722
+ ...(subscribe.length ? {subscribe}: {}),
723
+ ...(unsubscribe.length ? {unsubscribe}: {})
724
+ }
725
+ });
726
+ }
727
+ }
728
+ }
729
+
730
+ #updatePublishersStreamMap() {
731
+ if(this.#subscriberHandle) {
732
+ let handle = this.#subscriberHandle;
733
+ handle.webrtcStuff.streamMap = {};
734
+ handle.webrtcStuff.transceiverMap.forEach(tItem => {
735
+ if(tItem.type === 'data') {
736
+ return;
737
+ }
738
+ if(tItem.active === false) {
739
+ return;
740
+ }
741
+ const id = tItem.feed_id;
742
+ const source = JSON.parse(tItem.feed_description)?.source;
743
+
744
+ if(!handle.webrtcStuff.streamMap[id]) {
745
+ handle.webrtcStuff.streamMap[id] = {};
746
+ }
747
+
748
+ if(!handle.webrtcStuff.streamMap[id][source]) {
749
+ handle.webrtcStuff.streamMap[id][source] = [];
750
+ }
751
+ let trackId = handle.webrtcStuff.pc.getTransceivers().find(t => t.mid === tItem.mid)?.receiver?.track?.id;
752
+
753
+ if(trackId) {
754
+ handle.webrtcStuff.streamMap[id][source].push(trackId);
755
+ }
756
+ })
757
+ }
758
+ }
759
+
760
+ #updateAvailablePublishersTrackData(newPublishers, removedPublisher) {
761
+ if(this.#subscriberHandle) {
762
+ for(let publisher of newPublishers) {
763
+ const publisherIndex = this.#subscriberHandle.webrtcStuff.availablePublishers.findIndex(p => p.id === publisher.id);
764
+
765
+ if(publisherIndex > -1) {
766
+ this.#subscriberHandle.webrtcStuff.availablePublishers.splice(publisherIndex, 1, publisher);
767
+ }
768
+ else {
769
+ this.#subscriberHandle.webrtcStuff.availablePublishers.push(publisher);
770
+ }
771
+
772
+ }
773
+ if(removedPublisher !== undefined) {
774
+ this.#subscriberHandle.webrtcStuff.availablePublishers = this.#subscriberHandle.webrtcStuff.availablePublishers.filter(p => p.id !== removedPublisher);
775
+ }
776
+ }
777
+ }
778
+
779
+ #getTransceiverDataByMid(mid) {
780
+ return this.#subscriberHandle.webrtcStuff.transceiverMap.find(t => t.mid === mid);
781
+ }
782
+
783
+ // this is done
784
+ // feed_id === id of participant
785
+
786
+ #updateTransceiverMap(handleId, streams = []) {
787
+ this._log('Updating current transceiver map', 'Is me: ' + handleId === this.#publisherHandle.handleId, handleId, streams);
788
+ let handle = this.#getHandle(handleId);
789
+ if (!handle) {
790
+ this.emit('error', {
791
+ type: 'warning',
792
+ id: 1,
793
+ message: 'id non-existent',
794
+ data: [handleId, 'updateTransceiverMap']
795
+ });
796
+ return;
797
+ }
798
+ let config = handle.webrtcStuff;
799
+
800
+ // we need to add new transceivers, update existing transceivers, remove transceivers that are not in streams anymore
801
+ const mids = []
802
+ for(let stream of streams) {
803
+ mids.push(stream.mid);
804
+ const index = config.transceiverMap.findIndex(t => t.mid === stream.mid);
805
+ if(index > -1) {
806
+ config.transceiverMap[index] = {...config.transceiverMap[index], ...stream};
807
+ }
808
+ else {
809
+ config.transceiverMap.push(stream);
810
+ }
811
+ }
812
+ config.transceiverMap = config.transceiverMap.filter(t => mids.includes(t.mid));
813
+ }
814
+
815
+ #getParticipantEventName(adding = true,participantRole) {
816
+ if(adding) {
817
+ switch (participantRole) {
818
+ case 'participant':
819
+ return 'addRemoteParticipant';
820
+ case 'talkback':
821
+ return 'addRemoteTalkback';
822
+ case 'monitor':
823
+ return 'addRemoteTalkback';
824
+ case 'observer':
825
+ case 'observerSolo1':
826
+ case 'observerSolo2':
827
+ case 'observerSolo3':
828
+ case 'observerSolo4':
829
+ case 'observerSolo5':
830
+ return 'addRemoteObserver';
831
+ case 'host':
832
+ return 'addRemoteInstructor';
833
+ case 'companionTV':
834
+ return 'addRemoteCompanionTV';
835
+ case 'companionPhone':
836
+ return 'addRemoteCompanionPhone';
837
+ default:
838
+ return 'addRemoteParticipant';
839
+ }
840
+ }
841
+ else {
842
+ switch (participantRole) {
843
+ case 'participant':
844
+ return 'removeRemoteParticipant';
845
+ case 'talkback':
846
+ return 'removeRemoteTalkback';
847
+ case 'monitor':
848
+ return 'removeRemoteTalkback';
849
+ case 'observer':
850
+ case 'observerSolo1':
851
+ case 'observerSolo2':
852
+ case 'observerSolo3':
853
+ case 'observerSolo4':
854
+ case 'observerSolo5':
855
+ return 'removeRemoteObserver';
856
+ case 'host':
857
+ return 'removeRemoteInstructor';
858
+ case 'companionTV':
859
+ return 'removeRemoteCompanionTV';
860
+ case 'companionPhone':
861
+ return 'removeRemoteCompanionPhone';
862
+ default:
863
+ return 'removeRemoteParticipant';
864
+ }
865
+ }
866
+ }
867
+
868
+ async sendMessage(handleId, message = {body: 'Example Body'}, dontWait = false, dontResolveOnAck = false, retry = 0) {
869
+ return this.#send({
870
+ "janus": "message",
871
+ "handle_id": handleId,
872
+ ...message
873
+ }, dontWait, dontResolveOnAck, retry)
874
+ .then(json => {
875
+ if (json && json["janus"] === "success") {
876
+ let plugindata = json["plugindata"] || {};
877
+ let data = plugindata["data"];
878
+ return Promise.resolve(data);
879
+ }
880
+ return Promise.resolve();
881
+ })
882
+ .catch(json => {
883
+ if (json && json["error"]) {
884
+ return Promise.reject({type: 'warning', id: 2, message: 'sendMessage failed', data: json["error"]})
885
+ } else {
886
+ return Promise.reject({type: 'warning', id: 3, message: 'sendMessage failed', data: json});
887
+ }
888
+ })
889
+ }
890
+
891
+ async #sendHTTP(request = {}, ignoreResponse = false, dontResolveOnAck = false, retry = 0) {
892
+ let transaction = RoomSession.randomString(12);
893
+ let requestData = {
894
+ ...request,
895
+ transaction,
896
+ token: this.token,
897
+ ...((this.sessionId && {'session_id': this.sessionId}) || {}),
898
+ ...((this.apisecret && {'apisecret': this.apisecret}) || {})
899
+ };
900
+ this._log(requestData);
901
+ return this.#httpAPICall(this.server, {body:requestData})
902
+ .then(json => {
903
+ if(json['janus'] !== 'success' && json['janus'] !== 'ack') {
904
+ // not a success not an ack ... should be error
905
+ return Promise.reject(json["error"])
906
+ }
907
+ return json;
908
+ })
909
+ }
910
+
911
+ async #sendWebsockets(request = {}, ignoreResponse = false, dontResolveOnAck = false, retry = 0) {
912
+ let transaction = RoomSession.randomString(12);
913
+ let requestData = {
914
+ ...request,
915
+ transaction,
916
+ token: this.token,
917
+ ...((this.sessionId && {'session_id': this.sessionId}) || {}),
918
+ ...((this.apisecret && {'apisecret': this.apisecret}) || {})
919
+ };
920
+ this._log(requestData);
921
+ const op = () => new Promise((resolve, reject) => {
922
+ let messageTimeoutId = null;
923
+ let abortResponse = () => {
924
+ this._abortController.signal.removeEventListener('abort', abortResponse);
925
+ clearTimeout(messageTimeoutId);
926
+ this.ws.removeEventListener('message', parseResponse);
927
+ reject({type: 'warning', id: 4, message: 'connection cancelled'})
928
+ };
929
+
930
+ let parseResponse = (event) => {
931
+ let json = JSON.parse(event.data);
932
+ let r_transaction = json['transaction'];
933
+ if (r_transaction === transaction && (!dontResolveOnAck || json['janus'] !== 'ack')) {
934
+ clearTimeout(messageTimeoutId);
935
+ this._abortController.signal.removeEventListener('abort', abortResponse);
936
+ this.ws.removeEventListener('message', parseResponse);
937
+ if (json['janus'] === 'error') {
938
+ if (json?.error?.code == 403) {
939
+ this.disconnect();
940
+ }
941
+ reject({type: 'error', id: 5, message: 'send failed', data: json, requestData});
942
+ } else {
943
+ resolve(json);
944
+ }
945
+ }
946
+ };
947
+
948
+ if (ignoreResponse) {
949
+ if (this.ws && this.ws.readyState === 1) {
950
+ this.ws.send(JSON.stringify(requestData));
951
+ }
952
+ resolve();
953
+ } else {
954
+ if (this.ws && this.ws.readyState === 1) {
955
+ this.ws.addEventListener('message', parseResponse);
956
+ messageTimeoutId = setTimeout(() => {
957
+ this.ws.removeEventListener('message', parseResponse);
958
+ this._abortController.signal.removeEventListener('abort', abortResponse);
959
+ reject({type: 'warning', id: 6, message: 'send timeout', data: requestData});
960
+ }, this._sendMessageTimeout);
961
+ this._abortController.signal.addEventListener('abort', abortResponse);
962
+ this.ws.send(JSON.stringify(requestData));
963
+ } else {
964
+ reject({type: 'warning', id: 7, message: 'No connection to WebSockets', data: requestData});
965
+ }
966
+ }
967
+ })
968
+
969
+ return op().catch(e => {
970
+ if (e.id === 17) {
971
+ return Promise.reject(e);
972
+ }
973
+ else if(e.id === 29 && retry > 0) {
974
+ return wait(this._sendMessageTimeout).then(() => this.#send(request, ignoreResponse, dontResolveOnAck, retry - 1));
975
+ }
976
+ else if(retry > 0) {
977
+ return this.#send(request, ignoreResponse, dontResolveOnAck, retry - 1);
978
+ }
979
+ else {
980
+ return Promise.reject(e);
981
+ }
982
+ })
983
+ }
984
+
985
+ async #send(request = {}, ignoreResponse = false, dontResolveOnAck = false, retry = 0) {
986
+ if(this.useWebsockets) {
987
+ return this.#sendWebsockets(request, ignoreResponse, dontResolveOnAck, retry);
988
+ }
989
+ else {
990
+ return this.#sendHTTP(request, ignoreResponse, dontResolveOnAck, retry)
991
+ }
992
+ }
993
+
994
+ #longPoll() {
995
+
996
+ if(!this.isSupposeToBeConnected) {
997
+ return;
998
+ }
999
+
1000
+ let longpoll = this.server + "/" + this.sessionId + "?rid=" + new Date().getTime();
1001
+ if(this._maxev)
1002
+ longpoll = longpoll + "&maxev=" + this._maxev;
1003
+ if(this.token)
1004
+ longpoll = longpoll + "&token=" + encodeURIComponent(this.token);
1005
+ if(this.apisecret)
1006
+ longpoll = longpoll + "&apisecret=" + encodeURIComponent(this.apisecret);
1007
+
1008
+ this.#httpAPICall(longpoll, {
1009
+ verb: 'GET',
1010
+ timeout: this._longPollTimeout
1011
+ })
1012
+ .then(this.#handleWsEvents.bind(this))
1013
+ .catch(() => {
1014
+ this.#longPoll();
1015
+ })
1016
+ }
1017
+
1018
+ #connectionClosed() {
1019
+
1020
+ if (!this.isConnected || this.isConnecting || this.isDisconnecting) {
1021
+ return;
1022
+ }
1023
+
1024
+ this.#reconnect()
1025
+ .catch(e => {
1026
+ if(e.type !== 'warning') {
1027
+ this.disconnect();
1028
+ }
1029
+ this.emit('error', e)
1030
+ });
1031
+ }
1032
+
1033
+ #wipeListeners() {
1034
+ if (this.ws) {
1035
+ this.ws.removeEventListener('close', this.__connectionClosedBoundFn);
1036
+ this.ws.removeEventListener('message', this.__handleWsEventsBoundFn);
1037
+ }
1038
+ }
1039
+
1040
+ #startKeepAlive() {
1041
+ this.#send({"janus": "keepalive"})
1042
+ .then(json => {
1043
+ if (json["janus"] !== 'ack') {
1044
+ this.emit('error', {
1045
+ type: 'warning',
1046
+ id: 8,
1047
+ message: 'keepalive response suspicious',
1048
+ data: json["janus"]
1049
+ });
1050
+ }
1051
+ })
1052
+ .catch((e) => {
1053
+ this.emit('error', {type: 'warning', id: 9, message: 'keepalive dead', data: e});
1054
+ this.#connectionClosed();
1055
+ });
1056
+
1057
+ this._keepAliveId = setTimeout(() => {
1058
+ this.#startKeepAlive();
1059
+ }, this._keepAlivePeriod);
1060
+ }
1061
+
1062
+ #stopKeepAlive() {
1063
+ clearTimeout(this._keepAliveId);
1064
+ }
1065
+
1066
+ #handleWsEvents(event, skipPoll = false) {
1067
+
1068
+ // we have a response from long poll, that means we need to run it again
1069
+ if(!this.useWebsockets && !skipPoll) {
1070
+ this.#longPoll();
1071
+ }
1072
+
1073
+ if(!this.useWebsockets && skipPoll) {
1074
+ // we need to fire those events into the wild
1075
+ this.emit('longPollEvent', event);
1076
+ }
1077
+
1078
+ if(Array.isArray(event)) {
1079
+ event.forEach(ev => this.#handleWsEvents({data:ev}, true));
1080
+ return;
1081
+ }
1082
+
1083
+ let json = typeof event.data === 'string'
1084
+ ? JSON.parse(event.data)
1085
+ : event.data;
1086
+
1087
+ var sender = json["sender"];
1088
+ var type = json["janus"];
1089
+
1090
+ let handle = this.#getHandle(sender);
1091
+ if (!handle) {
1092
+ return;
1093
+ }
1094
+
1095
+ if (type === "trickle") {
1096
+ let candidate = json["candidate"];
1097
+ let config = handle.webrtcStuff;
1098
+ if (config.pc && config.remoteSdp) {
1099
+
1100
+ if (!candidate || candidate.completed === true) {
1101
+ config.pc.addIceCandidate(null).catch((e) => {
1102
+ this._log('Error adding null candidate', e);
1103
+ });
1104
+ } else {
1105
+ config.pc.addIceCandidate(candidate).catch((e) => {
1106
+ this._log('Error adding candidate', e);
1107
+ });
1108
+ }
1109
+ } else {
1110
+ if (!config.candidates) {
1111
+ config.candidates = [];
1112
+ }
1113
+ config.candidates.push(candidate);
1114
+ }
1115
+ } else if (type === "webrtcup") {
1116
+ //none universal
1117
+ } else if (type === "hangup") {
1118
+ this._log('hangup on', handle.handleId, handle.handleId === this.#publisherHandle.handleId, json);
1119
+ if(this.#getHandle(handle.handleId)) {
1120
+ this.#cleanupHandle(handle.handleId, true).catch(() => {});
1121
+ }
1122
+ } else if (type === "detached") {
1123
+ this._log('detached on', handle.handleId, handle.handleId === this.#publisherHandle.handleId, json);
1124
+ if(this.#getHandle(handle.handleId)) {
1125
+ this.#destroyHandle(handle.handleId).catch(() => {});
1126
+ }
1127
+ } else if (type === "media") {
1128
+ this._log('Media event:', handle.handleId, json["type"], json["receiving"], json["mid"]);
1129
+ } else if (type === "slowlink") {
1130
+ this._log('Slowlink', handle.handleId, json["uplink"], json["lost"], json["mid"]);
1131
+ } else if (type === "event") {
1132
+ //none universal
1133
+ } else if (type === 'timeout') {
1134
+ this._log('WebSockets Gateway timeout', json);
1135
+ this.ws.close(3504, "Gateway timeout");
1136
+ } else if (type === 'success' || type === 'error') {
1137
+ // we're capturing those elsewhere
1138
+ } else {
1139
+ this._log(`Unknown event: ${type} on session: ${this.sessionId}`);
1140
+ }
1141
+
1142
+
1143
+ // LOCAL
1144
+
1145
+ if (sender === this.#publisherHandle.handleId) {
1146
+ if (type === "event") {
1147
+
1148
+ var plugindata = json["plugindata"] || {};
1149
+ var msg = plugindata["data"] || {};
1150
+ var jsep = json["jsep"];
1151
+ let result = msg["result"] || null; // related to streaming plugin
1152
+ let event = msg["videoroom"] || null;
1153
+ let attendees = msg["attendees"] ?? [];
1154
+ let list = msg["publishers"] || [];
1155
+ let leaving = msg["leaving"];
1156
+ let kicked = msg["kicked"];
1157
+ let substream = msg["substream"];
1158
+ let temporal = msg["temporal"] ?? msg["temporal_layer"];
1159
+ let spatial = msg["spatial_layer"];
1160
+ let joining = msg["joining"];
1161
+ let unpublished = msg["unpublished"];
1162
+ let error = msg["error"];
1163
+
1164
+ if (event === "joined") {
1165
+
1166
+ this.id = msg["id"];
1167
+ this.privateId = msg["private_id"];
1168
+ this.isConnected = true;
1169
+
1170
+ this._log('We have successfully joined Room',msg);
1171
+ this.emit('joined', true, this.constructId);
1172
+ // initial events
1173
+
1174
+ attendees.forEach((attendee) => {
1175
+ this.#cacheAttendee(attendee);
1176
+ this.#emitRemoteFeedUpdate(null, {attendee});
1177
+ })
1178
+
1179
+ list.forEach((publisher) => {
1180
+ this.#cacheAttendee(publisher);
1181
+ this.#emitRemoteFeedUpdate(null, {attendee: publisher});
1182
+ });
1183
+
1184
+ // end of initial events
1185
+ this.#emitLocalFeedUpdate();
1186
+ this.#updateAvailablePublishersTrackData(list);
1187
+ this.#updateSubscriptions()
1188
+ .catch((e) => {
1189
+ this._log(e);
1190
+ });
1191
+
1192
+ }
1193
+ else if (event === "event") {
1194
+
1195
+ if(substream !== undefined && substream !== null) {
1196
+ this._log('Substream event:', substream, sender);
1197
+ }
1198
+
1199
+ if(temporal !== undefined && temporal !== null) {
1200
+ this._log('Temporal event:', temporal);
1201
+ }
1202
+
1203
+ if (msg["streams"] !== undefined && msg["streams"] !== null) {
1204
+ this._log('Got my own streams back', msg["streams"]);
1205
+ this.#updateTransceiverMap(handle.handleId, msg["streams"]);
1206
+ }
1207
+
1208
+ this.#updateAvailablePublishersTrackData(list);
1209
+ this.#updateSubscriptions()
1210
+ .catch((e) => {
1211
+ this._log(e);
1212
+ });
1213
+
1214
+
1215
+ if (leaving === 'ok') {
1216
+ this._log('leaving', this.#publisherHandle.handleId, 'this is us');
1217
+
1218
+ this.#emitLocalFeedUpdate({feedRemoval: true});
1219
+
1220
+ if (msg['reason'] === 'kicked') {
1221
+ this.emit('kicked');
1222
+ this.disconnect().catch(() => {});
1223
+ }
1224
+ } else if (leaving) {
1225
+
1226
+ this._log('leaving', leaving);
1227
+ this.#updateAvailablePublishersTrackData([], leaving);
1228
+ this.#updateSubscriptions()
1229
+ .catch((e) => {
1230
+ this._log(e);
1231
+ });
1232
+
1233
+ this.#emitRemoteFeedUpdate(null, {feedRemoval: true, attendee: {id:leaving}});
1234
+ this.#removeAttendeeFromCache(leaving);
1235
+ }
1236
+
1237
+ if (unpublished === 'ok') {
1238
+ this._log('unpublished', this.#publisherHandle.handleId, 'this is us');
1239
+ this.#cleanupHandle(this.#publisherHandle.handleId, true).catch(() => {});
1240
+
1241
+ } else if (unpublished) {
1242
+ this._log('unpublished', unpublished);
1243
+ this.#updateAvailablePublishersTrackData([], unpublished);
1244
+ this.#updateSubscriptions()
1245
+ .catch((e) => {
1246
+ this._log(e);
1247
+ });
1248
+ }
1249
+
1250
+ if(kicked === 'ok') {
1251
+ // this case shouldn't exist
1252
+ } else if(kicked) {
1253
+ this._log('kicked', kicked);
1254
+ this.#updateAvailablePublishersTrackData([], kicked);
1255
+ this.#updateSubscriptions()
1256
+ .catch((e) => {
1257
+ this._log(e);
1258
+ });
1259
+ }
1260
+
1261
+ if(joining) {
1262
+ this.#cacheAttendee(joining);
1263
+ this.#emitRemoteFeedUpdate(null, {attendee: joining});
1264
+ }
1265
+
1266
+ if (error) {
1267
+ this.emit('error', {
1268
+ type: 'error',
1269
+ id: 10,
1270
+ message: 'local participant error',
1271
+ data: [sender, msg]
1272
+ });
1273
+ }
1274
+ }
1275
+
1276
+ if (jsep !== undefined && jsep !== null) {
1277
+ this.#webrtcPeer(this.#publisherHandle.handleId, jsep)
1278
+ .catch(err => {
1279
+ this.emit('error', err);
1280
+ });
1281
+ }
1282
+ }
1283
+ else if (type === "webrtcup") {
1284
+
1285
+ if(this.simulcast) {
1286
+ return;
1287
+ }
1288
+
1289
+ this._log('Configuring bitrate: ' + this.initialBitrate);
1290
+ if (this.initialBitrate > 0) {
1291
+ this.sendMessage(this.#publisherHandle.handleId, {
1292
+ "body": {
1293
+ "request": "configure",
1294
+ "bitrate": this.initialBitrate
1295
+ }
1296
+ }).catch(() => null)
1297
+ }
1298
+ }
1299
+ }
1300
+
1301
+ //REMOTE
1302
+
1303
+ else {
1304
+
1305
+ let plugindata = json["plugindata"] || {};
1306
+ let msg = plugindata["data"] || {};
1307
+ let jsep = json["jsep"];
1308
+ let event = msg["videoroom"];
1309
+ let error = msg["error"];
1310
+ let substream = msg["substream"];
1311
+ let temporal = msg["temporal"] ?? msg["temporal_layer"];
1312
+ let spatial = msg["spatial_layer"];
1313
+ let mid = msg["mid"];
1314
+
1315
+ if(substream !== undefined && substream !== null) {
1316
+ this._log('Substream: ', sender, mid, substream);
1317
+ this.#setSelectedSubstream(sender, mid, substream);
1318
+ this.requestKeyFrame(sender, mid);
1319
+ }
1320
+
1321
+ if(temporal !== undefined && temporal !== null) {
1322
+ this._log('Temporal: ', temporal);
1323
+ this.#setSelectedTemporal(sender, mid, temporal);
1324
+ }
1325
+
1326
+ if(type === "webrtcup") {
1327
+ this.requestKeyFrame(handle.handleId);
1328
+ }
1329
+
1330
+ if (event === "updated") {
1331
+ this._log('Remote has updated tracks', msg);
1332
+ }
1333
+
1334
+ if (event === "attached") {
1335
+ this._log('Subscriber handle successfuly created', msg);
1336
+ }
1337
+
1338
+ if(msg["streams"]) {
1339
+ this.#updateTransceiverMap(handle.handleId, msg["streams"]);
1340
+ }
1341
+
1342
+ if (error) {
1343
+ this.emit('error', {type: 'warning', id: 11, message: 'remote participant error', data: [sender, msg]});
1344
+ }
1345
+
1346
+ if (jsep) {
1347
+ this.#publishRemote(handle.handleId, jsep)
1348
+ .catch(err => {
1349
+ this.emit('error', err);
1350
+ });
1351
+ }
1352
+ }
1353
+ }
1354
+
1355
+ #handleDataChannelEvents(handleId, type, data) {
1356
+
1357
+ let handle = this.#getHandle(handleId);
1358
+
1359
+ if (type === 'state') {
1360
+ this._log(` - Data channel status - `, `UID: ${handleId}`, `STATUS: ${JSON.stringify(data)}`)
1361
+ if (handle) {
1362
+ let config = handle.webrtcStuff;
1363
+ config.dataChannelOpen = this.defaultDataChannelLabel === data?.label && data?.state === 'open';
1364
+ }
1365
+
1366
+ if (handleId === this.#publisherHandle.handleId && this.defaultDataChannelLabel === data?.label) {
1367
+ this._isDataChannelOpen = data?.state === 'open';
1368
+ this.emit('dataChannel', data?.state === 'open');
1369
+ }
1370
+ }
1371
+
1372
+ if (type === 'error') {
1373
+
1374
+ this.emit('error', {
1375
+ type: 'warning',
1376
+ id: 12,
1377
+ message: 'data event warning',
1378
+ data: [handleId, data]
1379
+ });
1380
+
1381
+ if (handle) {
1382
+ let config = handle.webrtcStuff;
1383
+ if(this.defaultDataChannelLabel === data.label) {
1384
+ config.dataChannelOpen = false;
1385
+ }
1386
+ }
1387
+ if (handleId === this.#publisherHandle.handleId && this.defaultDataChannelLabel === data.label) {
1388
+ this._isDataChannelOpen = false;
1389
+ this.emit('dataChannel', false);
1390
+ }
1391
+ }
1392
+
1393
+ if (handleId === this.#publisherHandle.handleId && type === 'message') {
1394
+
1395
+ let d = null;
1396
+
1397
+ try {
1398
+ d = JSON.parse(data)
1399
+ } catch (e) {
1400
+ this.emit('error', {type: 'warning', id: 13, message: 'data message parse error', data: [handleId, e]});
1401
+ return;
1402
+ }
1403
+ this.emit('data', d);
1404
+ }
1405
+
1406
+ }
1407
+
1408
+ async #createHandle() {
1409
+ return this.#send({
1410
+ "janus": "attach",
1411
+ "plugin": this.pluginName,
1412
+ }).then(json => {
1413
+ let handleId = json.data["id"];
1414
+ return {
1415
+ handleId,
1416
+ webrtcStuff: {
1417
+ mySdp: null,
1418
+ mediaConstraints: null,
1419
+ pc: null,
1420
+ dataChannelOpen: false,
1421
+ dataChannel: null,
1422
+ dtmfSender: null,
1423
+ trickle: true,
1424
+ iceDone: false,
1425
+ isIceRestarting: false,
1426
+ tracks: [],
1427
+ stream: new MediaStream(), // for publisher handle only
1428
+ mids: {},
1429
+ userIdToDisplay: {}, // userId to display
1430
+ transceiverMap: [], // got from msg["streams"]
1431
+ streamMap: {}, // id to sources to mids?
1432
+ availablePublishers: [],
1433
+ subscribeMap: [], // subscribed to [id][mid]
1434
+ stats: {},
1435
+ currentLayers : new Map(),
1436
+ qualityHistory : new Map(),
1437
+ lastSwitchTime : new Map(),
1438
+ }
1439
+ };
1440
+ })
1441
+ }
1442
+
1443
+ async #destroyHandle(handleId, detachRequest = true) {
1444
+ if(handleId) {
1445
+ let handle = this.#getHandle(handleId);
1446
+ if (handle) {
1447
+
1448
+ await this.#cleanupHandle(handleId);
1449
+
1450
+ if(detachRequest) {
1451
+ await this.#send({
1452
+ "janus": "detach",
1453
+ "handle_id": handleId
1454
+ }, true)
1455
+ }
1456
+
1457
+ if (handleId === this.#publisherHandle?.handleId) {
1458
+ this.#publisherHandle = null;
1459
+ }
1460
+ else if (handleId === this.#subscriberHandle?.handleId) {
1461
+ this.#subscriberHandle = null;
1462
+ }
1463
+ }
1464
+ }
1465
+ }
1466
+
1467
+ async #cleanupHandle(handleId, hangupRequest = false) {
1468
+
1469
+ let handle = this.#getHandle(handleId);
1470
+
1471
+ if(hangupRequest) {
1472
+ await this.#send({"janus": "hangup", "handle_id": handleId,}, true)
1473
+ }
1474
+
1475
+ try {
1476
+ if (handle.webrtcStuff.stream) {
1477
+ if(!this.isConnecting) {
1478
+ handle.webrtcStuff.stream?.getTracks().forEach(track => track.stop());
1479
+ }
1480
+ else {
1481
+ handle.webrtcStuff.stream?.getTracks().forEach(track => track.onended = null);
1482
+ }
1483
+ }
1484
+ if(handle.webrtcStuff?.tracks?.length > 0) {
1485
+ handle.webrtcStuff.tracks.forEach(track => {
1486
+ if (track) {
1487
+ track.stop();
1488
+ track.onended = null;
1489
+ }
1490
+ });
1491
+ }
1492
+ } catch (e) {
1493
+ // Do nothing
1494
+ }
1495
+
1496
+ if(handle.webrtcStuff.stream) {
1497
+ handle.webrtcStuff.stream.onremovetrack = null;
1498
+ handle.webrtcStuff.stream = null;
1499
+ }
1500
+
1501
+ if (handle.webrtcStuff.dataChannel) {
1502
+ Object.keys(handle.webrtcStuff.dataChannel).forEach(label => {
1503
+ handle.webrtcStuff.dataChannel[label].onmessage = null;
1504
+ handle.webrtcStuff.dataChannel[label].onopen = null;
1505
+ handle.webrtcStuff.dataChannel[label].onclose = null;
1506
+ handle.webrtcStuff.dataChannel[label].onerror = null;
1507
+ })
1508
+ }
1509
+ if (handle.webrtcStuff.pc) {
1510
+ handle.webrtcStuff.pc.onicecandidate = null;
1511
+ handle.webrtcStuff.pc.ontrack = null;
1512
+ handle.webrtcStuff.pc.ondatachannel = null;
1513
+ handle.webrtcStuff.pc.onconnectionstatechange = null;
1514
+ handle.webrtcStuff.pc.oniceconnectionstatechange = null;
1515
+ }
1516
+ try {
1517
+ handle.webrtcStuff.pc.close();
1518
+ } catch (e) {
1519
+
1520
+ }
1521
+ handle.webrtcStuff = {
1522
+ mySdp: null,
1523
+ mediaConstraints: null,
1524
+ pc: null,
1525
+ dataChannelOpen: false,
1526
+ dataChannel: null,
1527
+ dtmfSender: null,
1528
+ trickle: true,
1529
+ iceDone: false,
1530
+ isIceRestarting: false,
1531
+ tracks: [],
1532
+ stream: new MediaStream(), // for publisher handle only
1533
+ mids: {},
1534
+ userIdToDisplay: {}, // userId to display
1535
+ transceiverMap: [], // got from msg["streams"]
1536
+ streamMap: {}, // id to sources to mids?
1537
+ availablePublishers: [],
1538
+ subscribeMap: [], // subscribed to [id][mid]
1539
+ stats: {},
1540
+ currentLayers : new Map(),
1541
+ qualityHistory : new Map(),
1542
+ lastSwitchTime : new Map(),
1543
+ }
1544
+
1545
+ if(this.#publisherHandle && handleId === this.#publisherHandle.handleId) {
1546
+ this.#emitLocalFeedUpdate({feedRemoval: true});
1547
+ }
1548
+ else if(this.#subscriberHandle && handleId === this.#subscriberHandle.handleId) {
1549
+ [...this.#getCachedAttendeeIds()].forEach(id => {
1550
+ this.#emitRemoteFeedUpdate(null, {feedRemoval: true, attendee: {id}});
1551
+ this.#removeAttendeeFromCache(id);
1552
+ })
1553
+ }
1554
+
1555
+ }
1556
+
1557
+ async #joinAsPublisher(roomId, pin, userId, display) {
1558
+ return this.sendMessage(this.#publisherHandle.handleId, {
1559
+ body: {
1560
+ "request": "join", "room": roomId, "pin": pin, "ptype": "publisher", "display": display, ...(this.webrtcVersion > 1000 ? {id: userId} : {})
1561
+ }
1562
+ }, false, true)
1563
+ }
1564
+
1565
+ async #joinAsSubscriber(roomId, pin, userId) {
1566
+
1567
+ const publishers = this.#subscriberHandle.webrtcStuff.availablePublishers;
1568
+
1569
+ if(!publishers.length) {
1570
+ return Promise.reject('no streams to subscribe to');
1571
+ }
1572
+
1573
+ if(this.#subscriberJoinPromise) {
1574
+ return this.#subscriberJoinPromise;
1575
+ }
1576
+
1577
+ let res_streams = [];
1578
+ for (let f in publishers) {
1579
+
1580
+ if(publishers[f]["dummy"])
1581
+ continue;
1582
+
1583
+ let userId = publishers[f]["display"];
1584
+ let streams = publishers[f]["streams"] || [];
1585
+ let id = publishers[f]["id"];
1586
+ let description = {};
1587
+
1588
+ try {
1589
+ const dString = streams.find(s => !!s.description)?.description;
1590
+ if(dString) {
1591
+ description = JSON.parse(dString);
1592
+ }
1593
+ } catch (e) {}
1594
+
1595
+ this._log('Remote publisher: ', id, userId, streams, description);
1596
+
1597
+ for (let i in streams) {
1598
+ const track = streams[i];
1599
+ const mid = track.mid; // source.mid
1600
+ if(track.disabled) {
1601
+ continue;
1602
+ }
1603
+
1604
+ if(!this.#shouldSubscribeParticipant(id, userId, description)) {
1605
+ continue;
1606
+ }
1607
+
1608
+ if(!this.#isSubscribedToMid(id, mid)) {
1609
+ res_streams.push({
1610
+ feed: id, // This is mandatory
1611
+ mid: mid
1612
+ });
1613
+ this.#addToSubscribeMap(id, mid);
1614
+ }
1615
+ }
1616
+ }
1617
+
1618
+ if(!res_streams.length) {
1619
+ return Promise.reject('no streams to subscribe to');
1620
+ }
1621
+
1622
+ this.#subscriberJoinPromise = this.sendMessage(this.#subscriberHandle.handleId, {
1623
+ body: {
1624
+ "request": "join",
1625
+ "room": roomId,
1626
+ "ptype": "subscriber",
1627
+ "use_msid": true,
1628
+ "private_id": this.privateId,
1629
+ streams: res_streams,
1630
+ ...(this.webrtcVersion > 1000 ? {id: userId} : {}),
1631
+ pin: pin
1632
+ }
1633
+ }, false, true)
1634
+ .catch((e) => {
1635
+ this._log(e);
1636
+ this.#subscriberJoinPromise = null;
1637
+ return Promise.reject(e);
1638
+ })
1639
+
1640
+ return this.#subscriberJoinPromise;
1641
+ }
1642
+
1643
+ async #leaveRoom(dontWait = false) {
1644
+ return this.isConnected
1645
+ ? this.sendMessage(this.#publisherHandle.handleId, {body: {"request": "leave"}}, dontWait)
1646
+ .finally(() => {
1647
+ this.isConnected = false;
1648
+ this.emit('joined', false);
1649
+ })
1650
+ : Promise.resolve();
1651
+ }
1652
+
1653
+ async #webSocketConnection(reclaim = false) {
1654
+
1655
+ if(this.isEstablishingConnection) {
1656
+ return Promise.reject({type: 'warning', id: 14, message: 'connection already in progress'});
1657
+ }
1658
+
1659
+ this.isEstablishingConnection = true;
1660
+
1661
+ if (this.ws) {
1662
+ this.#wipeListeners();
1663
+ if (this.ws.readyState === 1) {
1664
+ this.ws.close();
1665
+ }
1666
+ }
1667
+
1668
+ this.#stopKeepAlive();
1669
+
1670
+ return new Promise((resolve, reject) => {
1671
+
1672
+ this.__connectionClosedBoundFn = this.#connectionClosed.bind(this);
1673
+ this.__handleWsEventsBoundFn = this.#handleWsEvents.bind(this);
1674
+
1675
+ let abortConnect = () => {
1676
+ this._abortController.signal.removeEventListener('abort', abortConnect);
1677
+ this.ws.removeEventListener('close', this.__connectionClosedBoundFn);
1678
+ this.ws.removeEventListener('message', this.__handleWsEventsBoundFn);
1679
+ this.ws.onopen = null;
1680
+ this.ws.onerror = null;
1681
+ reject({type: 'warning', id: 15, message: 'Connection cancelled'});
1682
+ };
1683
+
1684
+ this.ws = new WebSocket(this.server, 'janus-protocol');
1685
+ this.ws.addEventListener('close', this.__connectionClosedBoundFn);
1686
+ this.ws.addEventListener('message', this.__handleWsEventsBoundFn);
1687
+
1688
+ this.ws.onopen = () => {
1689
+ this._abortController.signal.removeEventListener('abort', abortConnect);
1690
+ if(!reclaim) {
1691
+ this.#send({"janus": "create"})
1692
+ .then(json => {
1693
+ this.sessionId = json["session_id"] ? json["session_id"] : json.data["id"];
1694
+ this.#startKeepAlive();
1695
+ resolve(this);
1696
+ })
1697
+ .catch(error => {
1698
+ reject({type: error?.type === 'warning' ? 'warning' : 'error', id: 16, message: 'connection error', data: error})
1699
+ })
1700
+ .finally(() => {
1701
+ this.isEstablishingConnection = false;
1702
+ });
1703
+ }
1704
+ else {
1705
+ this.#send({"janus": "claim"})
1706
+ .then(json => {
1707
+ this.sessionId = json["session_id"] ? json["session_id"] : json.data["id"];
1708
+ this.#startKeepAlive();
1709
+ resolve(json);
1710
+ })
1711
+ .catch(error => {
1712
+ reject({type: 'error', id: 17, message: 'reconnection error', data: error})
1713
+ })
1714
+ .finally(() => {
1715
+ this.isEstablishingConnection = false;
1716
+ });
1717
+ }
1718
+ };
1719
+
1720
+ this.ws.onerror = (e) => {
1721
+ this._abortController.signal.removeEventListener('abort', abortConnect);
1722
+ reject({type: 'error', id: 18, message: 'ws connection error', data: e});
1723
+ }
1724
+
1725
+ this._abortController.signal.addEventListener('abort', abortConnect);
1726
+
1727
+ });
1728
+ }
1729
+
1730
+ async #httpConnection(reclaim = false) {
1731
+
1732
+ if(this.isEstablishingConnection) {
1733
+ this.emit('error', {type: 'warning', id: 19, message: 'connection already in progress'});
1734
+ return;
1735
+ }
1736
+
1737
+ this.isEstablishingConnection = true;
1738
+
1739
+ // usable when switching from ws to http
1740
+ if (this.ws) {
1741
+ this.#wipeListeners();
1742
+ if (this.ws.readyState === 1) {
1743
+ this.ws.close();
1744
+ }
1745
+ }
1746
+
1747
+ this.#stopKeepAlive();
1748
+
1749
+ if(!reclaim) {
1750
+ return this.#send({"janus": "create"})
1751
+ .then(json => {
1752
+ this.sessionId = json["session_id"] ? json["session_id"] : json.data["id"];
1753
+ this.#startKeepAlive();
1754
+ this.#longPoll();
1755
+ return this;
1756
+ })
1757
+ .catch(error => {
1758
+ return Promise.reject({type: error?.type === 'warning' ? 'warning' : 'error', id: 20, message: 'connection error', data: error})
1759
+ })
1760
+ .finally(() => {
1761
+ this.isEstablishingConnection = false;
1762
+ });
1763
+ }
1764
+ else {
1765
+ return this.#send({"janus": "claim"})
1766
+ .then(json => {
1767
+ this.sessionId = json["session_id"] ? json["session_id"] : json.data["id"];
1768
+ this.#startKeepAlive();
1769
+ this.#longPoll();
1770
+ return this;
1771
+ })
1772
+ .catch(error => {
1773
+ return Promise.reject({type: 'error', id: 21, message: 'reconnection error', data: error})
1774
+ })
1775
+ .finally(() => {
1776
+ this.isEstablishingConnection = false;
1777
+ });
1778
+ }
1779
+ }
1780
+
1781
+ async #waitForConnectEvent() {
1782
+ return new Promise((resolve, reject) => {
1783
+ let timeoutId = null;
1784
+ if(this.isConnected) {
1785
+ return resolve();
1786
+ }
1787
+ const cleanup = () => {
1788
+ clearTimeout(timeoutId);
1789
+ this.off('joined', _resolve);
1790
+ this._abortController.signal.removeEventListener('abort', _rejectAbort);
1791
+ }
1792
+ let _resolve = () => {
1793
+ cleanup();
1794
+ resolve();
1795
+ }
1796
+ let _rejectAbort = () => {
1797
+ cleanup();
1798
+ reject({type: 'warning', id: 43, message: 'Connection cancelled'})
1799
+ }
1800
+ let _rejectTimeout = () => {
1801
+ cleanup();
1802
+ reject({type: 'warning', id: 44, message: 'Connection timeout'})
1803
+ }
1804
+ this.once('joined', _resolve);
1805
+ timeoutId = setTimeout(_rejectTimeout, 10000)
1806
+ this._abortController.signal.addEventListener('abort', _rejectAbort);
1807
+ })
1808
+ }
1809
+
1810
+ async #reconnect() {
1811
+
1812
+ if (this.isConnecting) {
1813
+ return Promise.reject({type: 'warning', id: 22, message: 'connection already in progress'});
1814
+ }
1815
+
1816
+ this.isConnecting = true;
1817
+
1818
+ if(this.useWebsockets) {
1819
+ try {
1820
+ await this.#webSocketConnection(true)
1821
+ }
1822
+ finally {
1823
+ this.isConnecting = false;
1824
+ }
1825
+ }
1826
+ else {
1827
+ try {
1828
+ await this.#httpConnection(true)
1829
+ }
1830
+ finally {
1831
+ this.isConnecting = false;
1832
+ }
1833
+ }
1834
+ }
1835
+
1836
+ async connect(
1837
+ roomId,
1838
+ pin,
1839
+ server,
1840
+ protocol,
1841
+ iceServers,
1842
+ token,
1843
+ display,
1844
+ userId,
1845
+ webrtcVersion = 0,
1846
+ initialBitrate = 0,
1847
+ recordingFilename,
1848
+ simulcast = false,
1849
+ simulcastSettings = this.defaultSimulcastSettings,
1850
+ enableDtx = false
1851
+ ) {
1852
+
1853
+ this.isSupposeToBeConnected = true;
1854
+
1855
+ if (this.isConnecting) {
1856
+ this.emit('error', {type: 'warning', id: 23, message: 'connection already in progress'});
1857
+ return
1858
+ }
1859
+
1860
+ if(this.isConnected) {
1861
+ await this.disconnect();
1862
+ }
1863
+
1864
+ this._abortController = new AbortController();
1865
+
1866
+ this.sessionId = null;
1867
+ this.server = server;
1868
+ this.protocol = protocol;
1869
+ this.iceServers = iceServers;
1870
+ this.token = token;
1871
+ this.roomId = roomId;
1872
+ this.pin = pin;
1873
+ this.id = null;
1874
+ this.display = display;
1875
+ this.userId = userId;
1876
+ this.webrtcVersion = webrtcVersion;
1877
+ this.initialBitrate = initialBitrate;
1878
+ this.recordingFilename = recordingFilename;
1879
+ this.enableDtx = enableDtx;
1880
+ this.simulcast = simulcast;
1881
+ this.simulcastSettings = structuredClone(simulcastSettings);
1882
+ this.useWebsockets = this.protocol === 'ws' || this.protocol === 'wss';
1883
+
1884
+ // fixing wrong order
1885
+ Object.keys(this.simulcastSettings).forEach(source => {
1886
+ const wrongOrder = this.simulcastSettings[source].bitrates[0].rid === 'l';
1887
+ if(wrongOrder) {
1888
+ this.simulcastSettings[source].bitrates = this.simulcastSettings[source].bitrates.reverse();
1889
+ this.simulcastSettings[source].defaultSubstream = 2 - this.simulcastSettings[source].defaultSubstream
1890
+ }
1891
+ })
1892
+
1893
+ this.isConnecting = true;
1894
+ this.emit('joining', true);
1895
+
1896
+ try {
1897
+
1898
+ if(this.useWebsockets) {
1899
+ await this.#webSocketConnection(false);
1900
+ }
1901
+ else {
1902
+ await this.#httpConnection(false);
1903
+ }
1904
+
1905
+ this.#publisherHandle = await this.#createHandle();
1906
+ this.handleId = this.#publisherHandle.handleId;
1907
+
1908
+ this.#subscriberHandle = await this.#createHandle();
1909
+
1910
+ await this.#joinAsPublisher(this.roomId, this.pin, this.userId, this.display);
1911
+ await this.#waitForConnectEvent();
1912
+
1913
+ } catch (error) {
1914
+ this.emit('error', error);
1915
+ } finally {
1916
+ this.isConnecting = false;
1917
+ this.emit('joining', false);
1918
+ }
1919
+
1920
+ this.#enableStatsWatch();
1921
+ }
1922
+
1923
+ async disconnect() {
1924
+
1925
+ this.isSupposeToBeConnected = false;
1926
+
1927
+ if (this.isDisconnecting) {
1928
+ return
1929
+ }
1930
+
1931
+ this.#disableStatsWatch();
1932
+
1933
+ this._abortController?.abort?.();
1934
+ this.isDisconnecting = true;
1935
+ this.#stopKeepAlive();
1936
+ let isConnected = this.isConnected;
1937
+
1938
+ if(this.#publisherHandle) {
1939
+ this.#emitLocalFeedUpdate({feedRemoval: true});
1940
+ }
1941
+
1942
+ if(this.#subscriberHandle) {
1943
+ [...this.#getCachedAttendeeIds()].forEach(id => {
1944
+ this.#emitRemoteFeedUpdate(null, {feedRemoval: true, attendee: {id}});
1945
+ this.#removeAttendeeFromCache(id);
1946
+ })
1947
+ }
1948
+
1949
+ await this.#destroyHandle(this.#publisherHandle?.handleId, false);
1950
+ await this.#destroyHandle(this.#subscriberHandle?.handleId, false);
1951
+
1952
+ this.#wipeListeners();
1953
+ if (this.ws && this.ws.readyState === 1) {
1954
+ await this.#send({"janus": "destroy"}, true);
1955
+ this.ws.close();
1956
+ }
1957
+
1958
+ this.#subscriberJoinPromise = null;
1959
+ this.sessionId = null;
1960
+ this.isPublished = false;
1961
+ this.isConnected = false;
1962
+ this.isDisconnecting = false;
1963
+ this.emit('publishing', false);
1964
+ this.emit('published', false);
1965
+ this.emit('joining', false);
1966
+ this.emit('joined', false);
1967
+ this.emit('disconnect', isConnected);
1968
+
1969
+ }
1970
+
1971
+ async destroy() {
1972
+ return this.disconnect()
1973
+ .then(() => {
1974
+ this.clear();
1975
+ return true;
1976
+ });
1977
+ }
1978
+
1979
+ #enableDebug() {
1980
+ this._log = console.log.bind(console);
1981
+ }
1982
+
1983
+ #getHandle(handleId, rfid = null, userId = null, fullUserId = null) {
1984
+
1985
+ if(handleId === this.#subscriberHandle?.handleId) {
1986
+ return this.#subscriberHandle;
1987
+ }
1988
+
1989
+ if(handleId === this.#publisherHandle?.handleId) {
1990
+ return this.#publisherHandle;
1991
+ }
1992
+
1993
+ return null;
1994
+ }
1995
+
1996
+
1997
+ #findSimulcastConfig(source, settings) {
1998
+ return Object.keys(settings).reduce((acc, key) => {
1999
+ if(settings[source]) {
2000
+ return settings[source];
2001
+ } else if(source.indexOf(key.match(/\*(.*?)\*/)?.[1]) > -1) {
2002
+ return settings[key];
2003
+ } else return acc;
2004
+ }, settings['default']);
2005
+ }
2006
+
2007
+ #sendTrickleCandidate(handleId, candidate) {
2008
+ return this.#send({
2009
+ "janus": "trickle",
2010
+ "candidate": candidate,
2011
+ "handle_id": handleId
2012
+ }, false, false, 5)
2013
+ }
2014
+
2015
+ #webrtc(handleId, enableOntrack = false, enableOnnegotiationneeded = false) {
2016
+
2017
+ let handle = this.#getHandle(handleId);
2018
+ if (!handle) {
2019
+ this.emit('error', {
2020
+ type: 'error',
2021
+ id: 24,
2022
+ message: 'id non-existent',
2023
+ data: [handleId, 'create rtc connection']
2024
+ });
2025
+ return;
2026
+ }
2027
+
2028
+ let config = handle.webrtcStuff;
2029
+ if (!config.pc) {
2030
+ let pc_config = {"iceServers": this.iceServers, "iceTransportPolicy": 'all', "bundlePolicy": undefined};
2031
+
2032
+ pc_config["sdpSemantics"] = "unified-plan";
2033
+
2034
+ let pc_constraints = {};
2035
+
2036
+ if (adapter.browserDetails.browser === "edge") {
2037
+ // This is Edge, enable BUNDLE explicitly
2038
+ pc_config.bundlePolicy = "max-bundle";
2039
+ }
2040
+
2041
+ // pc_config.bundlePolicy = 'balanced';
2042
+ // pc_config.iceTransportPolicy = 'relay';
2043
+ // pc_config.rtcpMuxPolicy = "negotiate";
2044
+
2045
+ this._log('new RTCPeerConnection', pc_config, pc_constraints);
2046
+
2047
+ config.pc = new RTCPeerConnection(pc_config, pc_constraints);
2048
+
2049
+ if(enableOnnegotiationneeded) {
2050
+ config.pc.onnegotiationneeded = async () => {
2051
+ try {
2052
+ this._log('Starting negotiation from onnegotiationneeded');
2053
+
2054
+ // Create an offer
2055
+ const jsep = await this.#createAO('offer', handle.handleId, false);
2056
+
2057
+ if (!jsep) {
2058
+ return null;
2059
+ }
2060
+
2061
+ //HOTFIX: Temporary fix for Safari 13
2062
+ if (jsep.sdp && jsep.sdp.indexOf("\r\na=ice-ufrag") === -1) {
2063
+ jsep.sdp = jsep.sdp.replace("\na=ice-ufrag", "\r\na=ice-ufrag");
2064
+ }
2065
+
2066
+ let descriptions = [];
2067
+ let transceivers = config.pc.getTransceivers();
2068
+ Object.keys(config.streamMap[this.id]).forEach(source => {
2069
+ const simulcastConfigForSource = this.#findSimulcastConfig(source, this.simulcastSettings);
2070
+ config.streamMap[this.id][source].forEach(trackId => {
2071
+ let t = transceivers.find(transceiver => transceiver.sender.track && transceiver.sender.track.id === trackId)
2072
+ if(t) {
2073
+ descriptions.push({mid: t.mid, description: JSON.stringify({source, simulcastBitrates: simulcastConfigForSource?.bitrates, intercomGroups: this._talkIntercomChannels})});
2074
+ }
2075
+ })
2076
+ });
2077
+
2078
+ // Send the offer to the server
2079
+ await this.sendMessage(handle.handleId, {
2080
+ body: {
2081
+ "request": "configure",
2082
+ "audio": !!(config.stream && config.stream.getAudioTracks().length > 0),
2083
+ "video": !!(config.stream && config.stream.getVideoTracks().length > 0),
2084
+ "data": true,
2085
+ descriptions: descriptions,
2086
+ ...(this.recordingFilename ? {filename: this.recordingFilename} : {}),
2087
+ },
2088
+ jsep
2089
+ });
2090
+
2091
+ // The answer will be processed by #handleWsEvents, so we don't need to do anything else here
2092
+ } catch (error) {
2093
+ this._log('Error during negotiation:', error);
2094
+ }
2095
+ };
2096
+ }
2097
+
2098
+ config.pc.onconnectionstatechange = () => {
2099
+
2100
+ if (config.pc.connectionState === 'failed') {
2101
+ this._log('onconnectionstatechange: connectionState === failed');
2102
+ this.#iceRestart(handleId);
2103
+ }
2104
+ this.emit('connectionState', [handleId, handleId === this.#publisherHandle?.handleId, config.pc.connectionState]);
2105
+ };
2106
+
2107
+ config.pc.oniceconnectionstatechange = () => {
2108
+ if (config.pc.iceConnectionState === 'failed') {
2109
+ this._log('oniceconnectionstatechange: iceConnectionState === failed');
2110
+ this.#iceRestart(handleId);
2111
+ }
2112
+ this.emit('iceState', [handleId, handleId === this.#publisherHandle?.handleId, config.pc.iceConnectionState]);
2113
+ };
2114
+ config.pc.onicecandidate = (event) => {
2115
+ if (event.candidate == null || (adapter.browserDetails.browser === 'edge' && event.candidate.candidate.indexOf('endOfCandidates') > 0)) {
2116
+ config.iceDone = true;
2117
+ this.#sendTrickleCandidate(handleId, {"completed": true})
2118
+ .catch(e => {
2119
+ this.emit('error', e);
2120
+ });
2121
+ } else {
2122
+ // JSON.stringify doesn't work on some WebRTC objects anymore
2123
+ // See https://code.google.com/p/chromium/issues/detail?id=467366
2124
+ var candidate = {
2125
+ "candidate": event.candidate.candidate,
2126
+ "sdpMid": event.candidate.sdpMid,
2127
+ "sdpMLineIndex": event.candidate.sdpMLineIndex
2128
+ };
2129
+
2130
+ this.#sendTrickleCandidate(handleId, candidate)
2131
+ .catch(e => {
2132
+ this.emit('error', e);
2133
+ });
2134
+ }
2135
+ };
2136
+
2137
+ if (enableOntrack) {
2138
+ config.pc.ontrack = (event) => {
2139
+
2140
+ if(!event.streams)
2141
+ return;
2142
+
2143
+ if(!event.streams?.[0]?.onremovetrack) {
2144
+
2145
+ event.streams[0].onremovetrack = (ev) => {
2146
+
2147
+ this._log('Remote track removed', ev);
2148
+
2149
+ const trackIndex = config?.tracks?.findIndex(t => t.id === ev.track.id);
2150
+ if(trackIndex > -1) {
2151
+ config.tracks.splice(trackIndex, 1);
2152
+ }
2153
+
2154
+ // check if handle still exists
2155
+ if(!this.#getHandle(handle.handleId)) {
2156
+ return;
2157
+ }
2158
+
2159
+ this.#updatePublishersStreamMap();
2160
+
2161
+ let transceiver = config.pc?.getTransceivers()?.find(
2162
+ t => t.receiver.track === ev.track);
2163
+
2164
+ let mid = transceiver?.mid || ev.track.id;
2165
+
2166
+ // transceiver may not exist already
2167
+ if(mid === ev.target.id && config.mids?.[event.track.id]) {
2168
+ mid = config.mids[event.track.id];
2169
+ }
2170
+
2171
+ this.#emitRemoteFeedUpdate(mid, {
2172
+ removingTrack: true,
2173
+ track: ev.track,
2174
+ });
2175
+
2176
+ delete config.mids[ev.track.id];
2177
+
2178
+ };
2179
+ }
2180
+
2181
+ if (event.track) {
2182
+
2183
+ this._log('Remote track added', event.track, event.streams[0]);
2184
+
2185
+ config.tracks.push(event.track);
2186
+
2187
+ this.#updatePublishersStreamMap();
2188
+
2189
+ let mid = event.transceiver ? event.transceiver.mid : event.track.id;
2190
+ config.mids[event.track.id] = event.transceiver.mid
2191
+
2192
+ if(event.track.kind === 'video') {
2193
+ this.requestKeyFrame(handle.handleId, mid);
2194
+ }
2195
+
2196
+ this.#emitRemoteFeedUpdate(mid, {
2197
+ addingTrack: true,
2198
+ track: event.track
2199
+ });
2200
+
2201
+ if (event.track.onended)
2202
+ return;
2203
+
2204
+ event.track.onended = (ev) => {
2205
+ this._log('Remote track ended', ev);
2206
+
2207
+ const trackIndex = config?.tracks?.findIndex(t => t.id === ev.target.id);
2208
+
2209
+ if(trackIndex > -1) {
2210
+ config.tracks.splice(trackIndex, 1);
2211
+ }
2212
+
2213
+ // check if handle still exists
2214
+ if(!this.#getHandle(handle.handleId)) {
2215
+ return;
2216
+ }
2217
+
2218
+ this.#updatePublishersStreamMap();
2219
+
2220
+ let transceiver = config.pc?.getTransceivers()?.find(t => t.receiver.track === ev.target);
2221
+ let mid = transceiver?.mid || ev.target.id;
2222
+
2223
+ // transceiver may not exist already
2224
+ if(mid === ev.target.id && config.mids?.[event.track.id]) {
2225
+ mid = config.mids[event.track.id];
2226
+ }
2227
+
2228
+ this.#emitRemoteFeedUpdate(mid, {
2229
+ removingTrack: true,
2230
+ track: ev.target
2231
+ });
2232
+
2233
+ delete config.mids[event.track.id];
2234
+
2235
+ };
2236
+
2237
+ event.track.onmute = (ev) => {
2238
+ this._log('Remote track muted');
2239
+
2240
+ let transceiver = config.pc.getTransceivers().find(
2241
+ t => t.receiver.track === ev.target);
2242
+ let mid = transceiver.mid || ev.target.id;
2243
+
2244
+ this.#emitRemoteTrackMuted(mid, {track:ev.target, muted: true});
2245
+ };
2246
+
2247
+ event.track.onunmute = (ev) => {
2248
+ this._log('Remote track unmuted');
2249
+
2250
+ let transceiver = config.pc.getTransceivers().find(
2251
+ t => t.receiver.track === ev.target);
2252
+ let mid = transceiver.mid || ev.target.id;
2253
+
2254
+ this.#emitRemoteTrackMuted(mid, {track:ev.target, muted: false});
2255
+ };
2256
+ }
2257
+ };
2258
+ }
2259
+ }
2260
+
2261
+ if (!config.dataChannel || !config.dataChannelOpen) {
2262
+
2263
+ config.dataChannel = {};
2264
+
2265
+ var onDataChannelMessage = (event) => {
2266
+ this.#handleDataChannelEvents(handleId, 'message', event.data);
2267
+ };
2268
+ var onDataChannelStateChange = (event) => {
2269
+ let label = event.target.label;
2270
+ let protocol = event.target.protocol;
2271
+ let state = config.dataChannel[label] ? config.dataChannel[label].readyState : "null";
2272
+ this.#handleDataChannelEvents(handleId, 'state', {state, label} );
2273
+ };
2274
+ var onDataChannelError = (error) => {
2275
+ this.#handleDataChannelEvents(handleId, 'error', {label: error?.channel?.label, error});
2276
+ };
2277
+
2278
+ const createDataChannel = (label, protocol, incoming) => {
2279
+ let options = {ordered: true};
2280
+ if(!incoming) {
2281
+ if(protocol) {
2282
+ options = {...options, protocol}
2283
+ }
2284
+ config.dataChannel[label] = config.pc.createDataChannel(label, options);
2285
+ }
2286
+ else {
2287
+ config.dataChannel[label] = incoming;
2288
+ }
2289
+
2290
+ config.dataChannel[label].onmessage = onDataChannelMessage;
2291
+ config.dataChannel[label].onopen = onDataChannelStateChange;
2292
+ config.dataChannel[label].onclose = onDataChannelStateChange;
2293
+ config.dataChannel[label].onerror = onDataChannelError;
2294
+ }
2295
+
2296
+ createDataChannel(this.defaultDataChannelLabel, null, null);
2297
+
2298
+ config.pc.ondatachannel = function (event) {
2299
+ createDataChannel(event.channel.label, event.channel.protocol, event.channel)
2300
+ };
2301
+ }
2302
+ }
2303
+
2304
+ #webrtcPeer(handleId, jsep) {
2305
+
2306
+ let handle = this.#getHandle(handleId);
2307
+ if (!handle) {
2308
+ return Promise.reject({type: 'warning', id: 25, message: 'id non-existent', data: [handleId, 'rtc peer']});
2309
+ }
2310
+
2311
+ var config = handle.webrtcStuff;
2312
+
2313
+ if (jsep !== undefined && jsep !== null) {
2314
+ if (config.pc === null) {
2315
+ this._log("No PeerConnection: if this is an answer, use createAnswer and not #webrtcPeer");
2316
+ return Promise.resolve(null);
2317
+ }
2318
+
2319
+ return config.pc.setRemoteDescription(jsep)
2320
+ .then(() => {
2321
+ config.remoteSdp = jsep.sdp;
2322
+ // Any trickle candidate we cached?
2323
+ if (config.candidates && config.candidates.length > 0) {
2324
+ for (var i = 0; i < config.candidates.length; i++) {
2325
+ var candidate = config.candidates[i];
2326
+ if (!candidate || candidate.completed === true) {
2327
+ config.pc.addIceCandidate(null).catch((e) => {
2328
+ this._log('Error adding null candidate', e);
2329
+ });;
2330
+ } else {
2331
+ config.pc.addIceCandidate(candidate).catch((e) => {
2332
+ this._log('Error adding candidate', e);
2333
+ });
2334
+ }
2335
+ }
2336
+ config.candidates = [];
2337
+ }
2338
+ // Done
2339
+ return true;
2340
+ })
2341
+ .catch((e) => {
2342
+ return Promise.reject({type: 'warning', id: 26, message: 'rtc peer', data: [handleId, e]});
2343
+ });
2344
+ } else {
2345
+ return Promise.reject({type: 'warning', id: 27, message: 'rtc peer', data: [handleId, 'invalid jsep']});
2346
+ }
2347
+ }
2348
+
2349
+ #iceRestart(handleId) {
2350
+
2351
+ let handle = this.#getHandle(handleId);
2352
+ if (!handle) {
2353
+ return;
2354
+ }
2355
+ var config = handle.webrtcStuff;
2356
+
2357
+ // Already restarting;
2358
+ if (config.isIceRestarting) {
2359
+ return;
2360
+ }
2361
+
2362
+ config.isIceRestarting = true;
2363
+
2364
+ // removing this so we can cache ice candidates again
2365
+ config.remoteSdp = null;
2366
+
2367
+ if (this.#publisherHandle.handleId === handleId) {
2368
+ this._log('Performing local ICE restart');
2369
+ let hasAudio = !!(config.stream && config.stream.getAudioTracks().length > 0);
2370
+ let hasVideo = !!(config.stream && config.stream.getVideoTracks().length > 0);
2371
+ this.#createAO('offer', handleId, true )
2372
+ .then((jsep) => {
2373
+ if (!jsep) {
2374
+ return null;
2375
+ }
2376
+ return this.sendMessage(handleId, {
2377
+ body: {"request": "configure", "keyframe": true, "audio": hasAudio, "video": hasVideo, "data": true, ...(this.recordingFilename ? {filename: this.recordingFilename} : {})},
2378
+ jsep
2379
+ }, false, false, 5);
2380
+ })
2381
+ .then(r => {
2382
+ config.isIceRestarting = false;
2383
+ this._log('ICE restart success');
2384
+ })
2385
+ .catch((e) => {
2386
+ config.isIceRestarting = false;
2387
+ this.emit('error', {type: 'error', id: 28, message: 'iceRestart failed', data: e});
2388
+ });
2389
+ } else {
2390
+ this._log('Performing remote ICE restart', handleId);
2391
+ return this.sendMessage(handleId, {
2392
+ body: {"request": "configure", "restart": true}
2393
+ }, false, false, 5).then(() => {
2394
+ }).then(() => {
2395
+ config.isIceRestarting = false;
2396
+ this._log('ICE restart success');
2397
+ }).catch(() => {
2398
+ config.isIceRestarting = false;
2399
+ });
2400
+ }
2401
+ }
2402
+
2403
+ #setupTransceivers(handleId, [audioSend, audioRecv, videoSend, videoRecv, audioTransceiver = null, videoTransceiver = null]) {
2404
+ //TODO: this should be refactored to use handle's trackMap so we dont have to pass any parameters
2405
+
2406
+ let handle = this.#getHandle(handleId);
2407
+ if (!handle) {
2408
+ return null;
2409
+ }
2410
+ let config = handle.webrtcStuff;
2411
+
2412
+ const setTransceiver = (transceiver, send, recv, kind = 'audio') => {
2413
+ if (!send && !recv) {
2414
+ // disabled: have we removed it?
2415
+ if (transceiver) {
2416
+ if (transceiver.setDirection) {
2417
+ transceiver.setDirection("inactive");
2418
+ } else {
2419
+ transceiver.direction = "inactive";
2420
+ }
2421
+ }
2422
+ } else {
2423
+ if (send && recv) {
2424
+ if (transceiver) {
2425
+ if (transceiver.setDirection) {
2426
+ transceiver.setDirection("sendrecv");
2427
+ } else {
2428
+ transceiver.direction = "sendrecv";
2429
+ }
2430
+ }
2431
+ } else if (send && !recv) {
2432
+ if (transceiver) {
2433
+ if (transceiver.setDirection) {
2434
+ transceiver.setDirection("sendonly");
2435
+ } else {
2436
+ transceiver.direction = "sendonly";
2437
+ }
2438
+ }
2439
+ } else if (!send && recv) {
2440
+ if (transceiver) {
2441
+ if (transceiver.setDirection) {
2442
+ transceiver.setDirection("recvonly");
2443
+ } else {
2444
+ transceiver.direction = "recvonly";
2445
+ }
2446
+ } else {
2447
+ // In theory, this is the only case where we might not have a transceiver yet
2448
+ config.pc.addTransceiver(kind, {direction: "recvonly"});
2449
+ }
2450
+ }
2451
+ }
2452
+ }
2453
+
2454
+ // if we're passing any transceivers, we work only on them, doesn't matter if one of them is null
2455
+ if(audioTransceiver || videoTransceiver) {
2456
+ if(audioTransceiver) {
2457
+ setTransceiver(audioTransceiver, audioSend, audioRecv, 'audio');
2458
+ }
2459
+ if(videoTransceiver) {
2460
+ setTransceiver(videoTransceiver, videoSend, videoRecv, 'video');
2461
+ }
2462
+ }
2463
+ // else we work on all transceivers
2464
+ else {
2465
+ let transceivers = config.pc.getTransceivers();
2466
+ if (transceivers && transceivers.length > 0) {
2467
+ for (let i in transceivers) {
2468
+ let t = transceivers[i];
2469
+ if (
2470
+ (t.sender && t.sender.track && t.sender.track.kind === "audio") ||
2471
+ (t.receiver && t.receiver.track && t.receiver.track.kind === "audio")
2472
+ ) {
2473
+ setTransceiver(t, audioSend, audioRecv, 'audio');
2474
+ }
2475
+ if (
2476
+ (t.sender && t.sender.track && t.sender.track.kind === "video") ||
2477
+ (t.receiver && t.receiver.track && t.receiver.track.kind === "video")
2478
+ ) {
2479
+ setTransceiver(t, videoSend, videoRecv, 'video');
2480
+ }
2481
+ }
2482
+ }
2483
+ }
2484
+ }
2485
+
2486
+ #createAO(type = 'offer', handleId, iceRestart = false, ) {
2487
+
2488
+ let handle = this.#getHandle(handleId);
2489
+ if (!handle) {
2490
+ return Promise.reject({
2491
+ type: 'warning',
2492
+ id: 29,
2493
+ message: 'id non-existent',
2494
+ data: [handleId, 'createAO', type]
2495
+ });
2496
+ }
2497
+
2498
+ let methodName = null;
2499
+ if (type === 'offer') {
2500
+ methodName = 'createOffer'
2501
+ } else {
2502
+ methodName = 'createAnswer'
2503
+ }
2504
+
2505
+ let config = handle.webrtcStuff;
2506
+ let mediaConstraints = {};
2507
+
2508
+ if (iceRestart) {
2509
+ mediaConstraints["iceRestart"] = true;
2510
+ }
2511
+
2512
+ return config.pc[methodName](mediaConstraints)
2513
+ .then( (response) => {
2514
+
2515
+ // if type offer and its me and we want dtx we mungle the sdp
2516
+ if(handleId === this.#publisherHandle.handleId && type === 'offer' && this.enableDtx) {
2517
+ // enable DTX
2518
+ response.sdp = response.sdp.replace("useinbandfec=1", "useinbandfec=1;usedtx=1")
2519
+ }
2520
+
2521
+ config.mySdp = response.sdp;
2522
+ let _p = config.pc.setLocalDescription(response)
2523
+ .catch((e) => {
2524
+ return Promise.reject({
2525
+ type: 'warning',
2526
+ id: 30,
2527
+ message: 'setLocalDescription',
2528
+ data: [handleId, e]
2529
+ })
2530
+ });
2531
+ config.mediaConstraints = mediaConstraints;
2532
+ if (!config.iceDone && !config.trickle) {
2533
+ // Don't do anything until we have all candidates
2534
+ return Promise.resolve(null);
2535
+ }
2536
+
2537
+ // JSON.stringify doesn't work on some WebRTC objects anymore
2538
+ // See https://code.google.com/p/chromium/issues/detail?id=467366
2539
+ var jsep = {
2540
+ "type": response.type,
2541
+ "sdp": response.sdp
2542
+ };
2543
+
2544
+ if(response.e2ee)
2545
+ jsep.e2ee = true;
2546
+ if(response.rid_order === "hml" || response.rid_order === "lmh")
2547
+ jsep.rid_order = response.rid_order;
2548
+ if(response.force_relay)
2549
+ jsep.force_relay = true;
2550
+
2551
+ return _p.then(() => jsep)
2552
+ }, (e) => {
2553
+ return Promise.reject({type: 'warning', id: 31, message: methodName, data: [handleId, e]})
2554
+ });
2555
+
2556
+ }
2557
+
2558
+ #publishRemote(handleId, jsep) {
2559
+ let handle = this.#getHandle(handleId);
2560
+ if (!handle) {
2561
+ return Promise.reject({
2562
+ type: 'warning',
2563
+ id: 32,
2564
+ message: 'id non-existent',
2565
+ data: [handleId, 'publish remote participant']
2566
+ })
2567
+ }
2568
+
2569
+ this.#webrtc(handleId, true, false);
2570
+
2571
+ let config = handle.webrtcStuff;
2572
+
2573
+ if (jsep) {
2574
+ return config.pc.setRemoteDescription(jsep)
2575
+ .then(() => {
2576
+ config.remoteSdp = jsep.sdp;
2577
+ // Any trickle candidate we cached?
2578
+ if (config.candidates && config.candidates.length > 0) {
2579
+ for (var i = 0; i < config.candidates.length; i++) {
2580
+ var candidate = config.candidates[i];
2581
+ if (!candidate || candidate.completed === true) {
2582
+ // end-of-candidates
2583
+ config.pc.addIceCandidate(null).catch(e => {
2584
+ this._log('Error adding null candidate', e);
2585
+ });
2586
+ } else {
2587
+ // New candidate
2588
+ config.pc.addIceCandidate(candidate).catch(e => {
2589
+ this._log('Error adding candidate', e);
2590
+ });
2591
+ }
2592
+ }
2593
+ config.candidates = [];
2594
+ }
2595
+
2596
+ this.#setupTransceivers(handleId, [false, true, false, true]);
2597
+
2598
+ // Create the answer now
2599
+ return this.#createAO('answer', handleId, false)
2600
+ .then(_jsep => {
2601
+ if (!_jsep) {
2602
+ this.emit('error', {
2603
+ type: 'warning',
2604
+ id: 33,
2605
+ message: 'publish remote participant',
2606
+ data: [handleId, 'no jsep']
2607
+ });
2608
+ return Promise.resolve();
2609
+ }
2610
+ return this.sendMessage(handleId, {
2611
+ "body": {"request": "start", ...(this.roomId && {"room": this.roomId}), ...(this.pin && {pin: this.pin})},
2612
+ "jsep": _jsep
2613
+ });
2614
+ })
2615
+ }, (e) => Promise.reject({
2616
+ type: 'warning',
2617
+ id: 34,
2618
+ message: 'setRemoteDescription',
2619
+ data: [handleId, e]
2620
+ }));
2621
+
2622
+ } else {
2623
+ return Promise.resolve();
2624
+ }
2625
+
2626
+ }
2627
+
2628
+ #republishOnTrackEnded(source) {
2629
+ let handle = this.#publisherHandle;
2630
+ if (!handle) {
2631
+ return;
2632
+ }
2633
+ let config = handle.webrtcStuff;
2634
+ if (!config.stream) {
2635
+ return;
2636
+ }
2637
+ let sourceTrackIds = (config.streamMap[this.id][source] || []);
2638
+ let remainingTracks = [];
2639
+ for(let i = 0; i < sourceTrackIds.length; i++) {
2640
+ let foundTrack = config.tracks.find(t => t.id === sourceTrackIds[i]);
2641
+ if(foundTrack) {
2642
+ remainingTracks.push(foundTrack);
2643
+ }
2644
+ }
2645
+ if (remainingTracks.length) {
2646
+ let stream = new MediaStream();
2647
+ remainingTracks.forEach(track => stream.addTrack(track));
2648
+ return this.publishLocal(stream, source);
2649
+ }
2650
+ else {
2651
+ return this.publishLocal(null, source);
2652
+ }
2653
+ };
2654
+
2655
+ async publishLocal(stream = null, source = 'camera0') {
2656
+
2657
+ if(!this.isConnected || this.isDisconnecting) {
2658
+ return {
2659
+ type: 'warning',
2660
+ id: 35,
2661
+ message: 'Either not connected or disconnecting',
2662
+ }
2663
+ }
2664
+
2665
+ if(stream?.getVideoTracks()?.length > 1) {
2666
+ return {
2667
+ type: 'warning',
2668
+ id: 36,
2669
+ message: 'multiple video tracks not supported',
2670
+ data: null
2671
+ }
2672
+ }
2673
+
2674
+ if(stream?.getAudioTracks()?.length > 1) {
2675
+ return {
2676
+ type: 'warning',
2677
+ id: 37,
2678
+ message: 'multiple audio tracks not supported',
2679
+ data: null
2680
+ }
2681
+ }
2682
+
2683
+ let handle = this.#publisherHandle;
2684
+ if (!handle) {
2685
+ return {
2686
+ type: 'error',
2687
+ id: 38,
2688
+ message: 'no local handle, connect before publishing',
2689
+ data: null
2690
+ }
2691
+ }
2692
+
2693
+ this.emit('publishing', true);
2694
+
2695
+ this.#webrtc(handle.handleId, false, true);
2696
+
2697
+ let config = handle.webrtcStuff;
2698
+
2699
+ if (!config.stream) {
2700
+ config.stream = new MediaStream();
2701
+ }
2702
+
2703
+ let transceivers = config.pc.getTransceivers();
2704
+ let existingTracks = [...(config.streamMap?.[this.id]?.[source] || [])];
2705
+
2706
+ if(stream?.getTracks().length) {
2707
+ if(!config.streamMap[this.id]) {
2708
+ config.streamMap[this.id] = {};
2709
+ }
2710
+ config.streamMap[this.id][source] = stream?.getTracks()?.map(track => track.id) || [];
2711
+ } else {
2712
+ if(config.streamMap[this.id]) {
2713
+ delete config.streamMap[this.id][source];
2714
+ }
2715
+ }
2716
+
2717
+ // remove old audio track related to this source
2718
+ let oldAudioStreamIndex = config?.tracks?.findIndex(track => track.kind === 'audio' && existingTracks.includes(track.id));
2719
+ let oldAudioStream = config?.tracks[oldAudioStreamIndex];
2720
+ if (oldAudioStream) {
2721
+ try {
2722
+ oldAudioStream.stop();
2723
+
2724
+ } catch (e) {
2725
+ this._log(e);
2726
+ }
2727
+ config.stream.removeTrack(oldAudioStream);
2728
+ config?.tracks.splice(oldAudioStreamIndex, 1);
2729
+ }
2730
+
2731
+ // remove old video track related to this source
2732
+ let oldVideoStreamIndex = config?.tracks?.findIndex(track => track.kind === 'video' && existingTracks.includes(track.id));
2733
+ let oldVideoStream = config?.tracks[oldVideoStreamIndex];
2734
+ if (oldVideoStream) {
2735
+ try {
2736
+ oldVideoStream.stop();
2737
+ } catch (e) {
2738
+ this._log(e);
2739
+ }
2740
+
2741
+ config.stream.removeTrack(oldVideoStream);
2742
+ config?.tracks.splice(oldVideoStreamIndex, 1);
2743
+ }
2744
+
2745
+ const simulcastConfigForSource = this.#findSimulcastConfig(source, this.simulcastSettings);
2746
+ let audioTrackReplacePromise = Promise.resolve();
2747
+ let videoTrackReplacePromise = Promise.resolve();
2748
+
2749
+ let audioTransceiver = null;
2750
+ let videoTransceiver = null;
2751
+ let replaceAudio = stream?.getAudioTracks()?.length;
2752
+ let replaceVideo = stream?.getVideoTracks()?.length;
2753
+
2754
+ for(const transceiver of transceivers) {
2755
+ if(['sendonly', 'sendrecv'].includes(transceiver.currentDirection) && transceiver.sender?.track?.kind === 'audio' && existingTracks.includes(transceiver.sender?.track?.id)) {
2756
+ audioTransceiver = transceiver;
2757
+ }
2758
+ else if(['sendonly', 'sendrecv'].includes(transceiver.currentDirection) && transceiver.sender?.track?.kind === 'video' && existingTracks.includes(transceiver.sender?.track?.id)) {
2759
+ videoTransceiver = transceiver;
2760
+ }
2761
+
2762
+ // Reusing existing transceivers
2763
+ // TODO: if we start using different codecs for different sources, we need to check for that here
2764
+
2765
+ else if(transceiver.currentDirection === 'inactive' && transceiver.sender?.getParameters()?.codecs?.find(c => c.mimeType.indexOf('audio') > -1) && replaceAudio && !audioTransceiver) {
2766
+ audioTransceiver = transceiver;
2767
+ }
2768
+ else if(transceiver.currentDirection === 'inactive' && transceiver.sender?.getParameters()?.codecs?.find(c => c.mimeType.indexOf('video') > -1) && replaceVideo && !videoTransceiver) {
2769
+ videoTransceiver = transceiver;
2770
+ }
2771
+ }
2772
+
2773
+ if (replaceAudio) {
2774
+ config.stream.addTrack(stream.getAudioTracks()[0]);
2775
+ config.tracks.push(stream.getAudioTracks()[0]);
2776
+ if (audioTransceiver && audioTransceiver.sender) {
2777
+ audioTrackReplacePromise = audioTransceiver.sender.replaceTrack(stream.getAudioTracks()[0]);
2778
+ } else {
2779
+ config.pc.addTrack(stream.getAudioTracks()[0], config.stream);
2780
+ }
2781
+ }
2782
+ else {
2783
+ if (audioTransceiver && audioTransceiver.sender) {
2784
+ audioTrackReplacePromise = audioTransceiver.sender.replaceTrack(null);
2785
+ }
2786
+ }
2787
+
2788
+ if (replaceVideo) {
2789
+ config.stream.addTrack(stream.getVideoTracks()[0]);
2790
+ config.tracks.push(stream.getVideoTracks()[0]);
2791
+ if (videoTransceiver && videoTransceiver.sender) {
2792
+ videoTrackReplacePromise = videoTransceiver.sender.replaceTrack(stream.getVideoTracks()[0]);
2793
+ } else {
2794
+ if(!this.simulcast) {
2795
+ config.pc.addTrack(stream.getVideoTracks()[0], config.stream);
2796
+ }
2797
+ else {
2798
+ config.pc.addTransceiver(stream.getVideoTracks()[0], {
2799
+ direction: 'sendonly',
2800
+ streams: [config.stream],
2801
+ sendEncodings: structuredClone(simulcastConfigForSource?.bitrates)
2802
+ })
2803
+ }
2804
+ }
2805
+ }
2806
+ else {
2807
+ if (videoTransceiver && videoTransceiver.sender) {
2808
+ videoTrackReplacePromise = videoTransceiver.sender.replaceTrack(null);
2809
+ }
2810
+ }
2811
+
2812
+ // we possibly created new transceivers, so we need to get them again
2813
+
2814
+ transceivers = config.pc.getTransceivers();
2815
+ existingTracks = [...(config.streamMap[this.id][source] || [])];
2816
+ if(!audioTransceiver) {
2817
+ audioTransceiver = transceivers.find(transceiver => transceiver.sender.track && transceiver.sender.track.kind === 'audio' && existingTracks.includes(transceiver.sender.track.id))
2818
+ }
2819
+ if(!videoTransceiver) {
2820
+ videoTransceiver = transceivers.find(transceiver => transceiver.sender.track && transceiver.sender.track.kind === 'video' && existingTracks.includes(transceiver.sender.track.id))
2821
+ }
2822
+
2823
+ let hasAudio = !!(stream && stream.getAudioTracks().length > 0);
2824
+ let hasVideo = !!(stream && stream.getVideoTracks().length > 0);
2825
+
2826
+ this.#setupTransceivers(this.#publisherHandle.handleId, [hasAudio, false, hasVideo, false, audioTransceiver, videoTransceiver]);
2827
+
2828
+ const emitEvents = () => {
2829
+ this.isPublished = true;
2830
+
2831
+ if(!config.stream.onremovetrack) {
2832
+ config.stream.onremovetrack = (ev) => {};
2833
+ }
2834
+
2835
+ let republishTimeoutId = null;
2836
+ let tracks = config.stream.getTracks();
2837
+
2838
+ // this event is emitted when we publish a new source but more importantly if one of the sources goes away
2839
+ this.#emitLocalFeedUpdate({
2840
+ source
2841
+ });
2842
+
2843
+ if(tracks.length) {
2844
+ tracks.forEach(track => {
2845
+
2846
+ // used as a flag to not emit tracks that been already emitted
2847
+ if(!track.onended) {
2848
+
2849
+ this.#emitLocalFeedUpdate({
2850
+ addingTrack: true,
2851
+ track,
2852
+ source
2853
+ })
2854
+
2855
+ track.onended = (ev) => {
2856
+ config.stream.removeTrack(track);
2857
+ config.tracks = config.tracks.filter(t => t.id !== track.id);
2858
+ this.#emitLocalFeedUpdate({
2859
+ removingTrack: true,
2860
+ track,
2861
+ source
2862
+ })
2863
+ clearTimeout(republishTimeoutId);
2864
+ republishTimeoutId = setTimeout(() => {
2865
+ this.#republishOnTrackEnded(source);
2866
+ }, 100);
2867
+
2868
+ }
2869
+ }
2870
+ })
2871
+ }
2872
+
2873
+ this.isMuted = [];
2874
+ for(const source of Object.keys(config.streamMap[this.id])) {
2875
+ const audioTrack = config.stream?.getAudioTracks()?.find(track => config.streamMap[this.id][source].includes(track.id));
2876
+ const videoTrack = config.stream?.getVideoTracks()?.find(track => config.streamMap[this.id][source].includes(track.id));
2877
+ this.isMuted.push({type: 'audio', value: !audioTrack || !audioTrack.enabled, source, mid: transceivers.find(transceiver => transceiver.sender?.track?.kind === 'audio' && transceiver.sender?.track?.id === audioTrack?.id)?.mid});
2878
+ this.isMuted.push({type: 'video', value: !videoTrack || !videoTrack.enabled, source, mid: transceivers.find(transceiver => transceiver.sender?.track?.kind === 'video' && transceiver.sender?.track?.id === videoTrack?.id)?.mid});
2879
+
2880
+ this.emit('localHasVideo', !!videoTrack, source);
2881
+ this.emit('localHasAudio', !!audioTrack, source);
2882
+ }
2883
+ for(const val of this.isMuted) {
2884
+ this.emit('localMuted', {...val});
2885
+ }
2886
+ this.emit('published', true);
2887
+ this.emit('publishing', false);
2888
+
2889
+ };
2890
+
2891
+ try {
2892
+
2893
+ await Promise.all([audioTrackReplacePromise, videoTrackReplacePromise]);
2894
+
2895
+ if(this._isDataChannelOpen !== true) {
2896
+ await new Promise((resolve, reject) => {
2897
+ let dataChannelTimeoutId = null;
2898
+ let _cleanup = () => {
2899
+ clearTimeout(dataChannelTimeoutId);
2900
+ this._abortController.signal.removeEventListener('abort', _rejectAbort);
2901
+ this.off('dataChannel', _resolve, this);
2902
+ }
2903
+ let _resolve = (val) => {
2904
+ if (val) {
2905
+ _cleanup();
2906
+ resolve(this);
2907
+ }
2908
+ };
2909
+ let _rejectTimeout = () => {
2910
+ _cleanup();
2911
+ reject({type: 'error', id: 39, message: 'Data channel did not open', data: null});
2912
+ }
2913
+ let _rejectAbort = () => {
2914
+ _cleanup();
2915
+ reject({type: 'warning', id: 40, message: 'Connection cancelled'})
2916
+ }
2917
+ dataChannelTimeoutId = setTimeout(_rejectTimeout, 10000);
2918
+ this._abortController.signal.addEventListener('abort', _rejectAbort);
2919
+ this.on('dataChannel', _resolve, this);
2920
+ });
2921
+ }
2922
+
2923
+ emitEvents();
2924
+
2925
+ }
2926
+ catch(e) {
2927
+ this.emit('publishing', false);
2928
+ }
2929
+
2930
+
2931
+ }
2932
+
2933
+ async unpublishLocal(dontWait = false) {
2934
+ return this.isPublished
2935
+ ? this.sendMessage(this.#publisherHandle.handleId, {body: {"request": "unpublish"}}, dontWait)
2936
+ .finally(r => {
2937
+ this.isPublished = false;
2938
+ this.emit('published', false);
2939
+ return r
2940
+ })
2941
+ : Promise.resolve()
2942
+ }
2943
+
2944
+ toggleAudio(value = null, source = 'camera0', mid) {
2945
+ let handle = this.#publisherHandle;
2946
+ if (!handle) {
2947
+ this.emit('error', {
2948
+ type: 'warning',
2949
+ id: 41,
2950
+ message: 'no local id, connect first', data: null
2951
+ });
2952
+ return;
2953
+ }
2954
+ let config = handle.webrtcStuff;
2955
+ let transceivers = config.pc?.getTransceivers();
2956
+ let transceiver = null;
2957
+ if(source) {
2958
+ transceiver = transceivers?.find(t => t.sender && t.sender.track && t.receiver.track.kind === "audio" && (config.streamMap[this.id][source] || []).includes(t.sender.track.id));
2959
+ }
2960
+ else {
2961
+ transceiver = transceivers?.find(t => t.sender && t.sender.track && t.receiver.track.kind === "audio" && (mid ? t.mid === mid : true));
2962
+ }
2963
+ if (transceiver) {
2964
+ transceiver.sender.track.enabled = value !== null ? !!value : !transceiver.sender.track.enabled;
2965
+ }
2966
+
2967
+ this.isMuted = [];
2968
+ for(const source of Object.keys(config.streamMap[this.id])) {
2969
+ const audioTrack = config.stream?.getAudioTracks()?.find(track => config.streamMap[this.id][source].includes(track.id));
2970
+ const audioTransceiver = transceivers?.find(transceiver => transceiver.sender.track && transceiver.sender.track.kind === 'audio' && transceiver.sender.track.id === audioTrack?.id);
2971
+ const videoTrack = config.stream?.getVideoTracks()?.find(track => config.streamMap[this.id][source].includes(track.id));
2972
+ const videoTransceiver = transceivers?.find(transceiver => transceiver.sender.track && transceiver.sender.track.kind === 'video' && transceiver.sender.track.id === videoTrack?.id);
2973
+ this.isMuted.push({type: 'audio', value: !audioTrack || !audioTransceiver || !audioTransceiver?.sender?.track?.enabled , source, mid: audioTransceiver?.mid});
2974
+ this.isMuted.push({type: 'video', value: !videoTrack || !videoTransceiver || !videoTransceiver?.sender?.track?.enabled, source, mid: videoTransceiver?.mid});
2975
+ }
2976
+ for(let val of this.isMuted) {
2977
+ this.emit('localMuted', {...val});
2978
+ }
2979
+ }
2980
+
2981
+ toggleVideo(value = null, source = 'camera0', mid) {
2982
+ let handle = this.#publisherHandle;
2983
+ if (!handle) {
2984
+ this.emit('error', {
2985
+ type: 'warning',
2986
+ id: 42,
2987
+ message: 'no local id, connect first', data: null
2988
+ });
2989
+ return;
2990
+ }
2991
+ let config = handle.webrtcStuff;
2992
+ let transceivers = config.pc?.getTransceivers();
2993
+ let transceiver = null;
2994
+ if(source) {
2995
+ transceiver = transceivers?.find(t => t.sender && t.sender.track && t.receiver.track.kind === "video" && (config.streamMap[this.id][source] || []).includes(t.sender.track.id));
2996
+ }
2997
+ else {
2998
+ transceiver = transceivers?.find(t => t.sender && t.sender.track && t.receiver.track.kind === "video" && (mid ? t.mid === mid : true));
2999
+ }
3000
+ if (transceiver) {
3001
+ transceiver.sender.track.enabled = value !== null ? !!value : !transceiver.sender.track.enabled;
3002
+ }
3003
+ this.isMuted = [];
3004
+ for(const source of Object.keys(config.streamMap[this.id])) {
3005
+ const audioTrack = config.stream?.getAudioTracks()?.find(track => config.streamMap[this.id][source].includes(track.id));
3006
+ const audioTransceiver = transceivers?.find(transceiver => transceiver.sender.track && transceiver.sender.track.kind === 'audio' && transceiver.sender.track.id === audioTrack?.id);
3007
+ const videoTrack = config.stream?.getVideoTracks()?.find(track => config.streamMap[this.id][source].includes(track.id));
3008
+ const videoTransceiver = transceivers?.find(transceiver => transceiver.sender.track && transceiver.sender.track.kind === 'video' && transceiver.sender.track.id === videoTrack?.id);
3009
+ this.isMuted.push({type: 'audio', value: !audioTrack || !audioTransceiver || !audioTransceiver?.sender?.track?.enabled , source, mid: audioTransceiver?.mid});
3010
+ this.isMuted.push({type: 'video', value: !videoTrack || !videoTransceiver || !videoTransceiver?.sender?.track?.enabled, source, mid: videoTransceiver?.mid});
3011
+ }
3012
+ for(let val of this.isMuted) {
3013
+ this.emit('localMuted', {...val});
3014
+ }
3015
+ }
3016
+
3017
+ requestKeyFrame(handleId, mid) {
3018
+ this.sendMessage(handleId, {
3019
+ "body": {
3020
+ "request": "configure",
3021
+ "keyframe": true,
3022
+ ...(mid !== undefined ? {streams: [{mid,keyframe:true}]} : {})
3023
+ }
3024
+ }).catch(() => null)
3025
+ }
3026
+
3027
+ setRoomType(type = 'watchparty') {
3028
+ this._roomType = type;
3029
+ return this._roomType;
3030
+ }
3031
+
3032
+ #setSelectedSubstream(sender, mid, substream) {
3033
+
3034
+ const handle = this.#getHandle(sender);
3035
+
3036
+ if(!handle) {
3037
+ return;
3038
+ }
3039
+
3040
+ handle.webrtcStuff.lastSwitchTime.set(mid, Date.now());
3041
+
3042
+ if(!handle.webrtcStuff.currentLayers.has(mid)) {
3043
+ handle.webrtcStuff.currentLayers.set(mid, {mid, substream: substream, temporal: -1});
3044
+ }
3045
+ else {
3046
+ handle.webrtcStuff.currentLayers.get(mid).substream = substream;
3047
+ }
3048
+ }
3049
+
3050
+ #setSelectedTemporal(sender, mid, temporal) {
3051
+
3052
+ const handle = this.#getHandle(sender);
3053
+
3054
+ if(!handle) {
3055
+ return;
3056
+ }
3057
+
3058
+ handle.webrtcStuff.lastSwitchTime.set(mid, Date.now());
3059
+
3060
+ if(!handle.webrtcStuff.currentLayers.has(mid)) {
3061
+ handle.webrtcStuff.currentLayers.set(mid, {mid, substream: -1, temporal: temporal});
3062
+ }
3063
+ else {
3064
+ handle.webrtcStuff.currentLayers.get(mid).temporal = temporal;
3065
+ }
3066
+ }
3067
+
3068
+ #shouldEmitFeedUpdate(parsedDisplay = {}) {
3069
+ const myUserRole = decodeJanusDisplay(this.display)?.role || 'participant';
3070
+ const remoteUserRole = parsedDisplay?.role || 'participant';
3071
+ const remoteUserId = parsedDisplay?.userId;
3072
+ const shouldSubscribeToUserRole = this.userRoleSubscriptionRules[myUserRole][(this._roomType || 'watchparty')].indexOf(remoteUserRole) > -1
3073
+ const shouldSubscribeToUserId = this._restrictSubscribeToUserIds.length === 0 || this._restrictSubscribeToUserIds.indexOf(remoteUserId) > -1;
3074
+ return shouldSubscribeToUserRole && shouldSubscribeToUserId;
3075
+ }
3076
+
3077
+ #shouldSubscribeParticipant(id, userId, description) {
3078
+
3079
+ const myUserRole = decodeJanusDisplay(this.display)?.role || 'participant';
3080
+ const remoteUserRole = decodeJanusDisplay(userId)?.role || 'participant';
3081
+ const remoteUserId = decodeJanusDisplay(userId)?.userId;
3082
+ const remoteIntercomGroups = description?.intercomGroups || [];
3083
+ const shouldSubscribeToUserRole = this.userRoleSubscriptionRules[myUserRole][(this._roomType || 'watchparty')].indexOf(remoteUserRole) > -1
3084
+ const shouldSubscribeToUserId = this._restrictSubscribeToUserIds.length === 0 || this._restrictSubscribeToUserIds.indexOf(remoteUserId) > -1;
3085
+ const shouldSubscribeToIntercomGroups = remoteIntercomGroups.some(g => this._listenIntercomChannels.indexOf(g) > -1);
3086
+
3087
+ return shouldSubscribeToUserRole && shouldSubscribeToUserId && shouldSubscribeToIntercomGroups;
3088
+ }
3089
+
3090
+ getUserTalkIntercomChannels(userId) {
3091
+ let talkIntercomChannels = []
3092
+ let transceiver = this.#subscriberHandle.webrtcStuff.transceiverMap.find(t => t.display && decodeJanusDisplay(t.display)?.userId === userId && t.description);
3093
+
3094
+ if(!transceiver) {
3095
+ return talkIntercomChannels;
3096
+ }
3097
+
3098
+ try {
3099
+ let description = JSON.parse(transceiver.description);
3100
+ if(description.intercomGroups) {
3101
+ description.intercomGroups.forEach(group => {
3102
+ if(!talkIntercomChannels.includes(group)) {
3103
+ talkIntercomChannels.push(group);
3104
+ }
3105
+ });
3106
+ }
3107
+ } catch (e) {}
3108
+
3109
+ return talkIntercomChannels;
3110
+ }
3111
+
3112
+ async setTalkIntercomChannels(groups = ['participants']) {
3113
+
3114
+ if(typeof groups !== 'object' || !("length" in groups)) {
3115
+ this._log('setTalkIntercomChannels: groups must be an array');
3116
+ groups = [groups];
3117
+ }
3118
+
3119
+ this._talkIntercomChannels = structuredClone(groups);
3120
+ let handle = this.#getHandle(this.handleId);
3121
+ if (!handle) {
3122
+ return Promise.resolve();
3123
+ }
3124
+ let config = handle.webrtcStuff;
3125
+ let descriptions = [];
3126
+ let transceivers = config.pc.getTransceivers();
3127
+ Object.keys(config.streamMap[this.id]).forEach(source => {
3128
+ const simulcastConfigForSource = this.#findSimulcastConfig(source, this.simulcastSettings);
3129
+ config.streamMap[this.id][source].forEach(trackId => {
3130
+ let t = transceivers.find(transceiver => transceiver.sender.track && transceiver.sender.track.id === trackId)
3131
+ if(t) {
3132
+ descriptions.push({mid: t.mid, description: JSON.stringify({source, simulcastBitrates: simulcastConfigForSource?.bitrates, intercomGroups: this._talkIntercomChannels})});
3133
+ }
3134
+ })
3135
+ });
3136
+
3137
+ return await this.sendMessage(this.handleId, {
3138
+ body: {
3139
+ "request": "configure",
3140
+ descriptions: descriptions,
3141
+ }
3142
+ })
3143
+ }
3144
+
3145
+ async setListenIntercomChannels(groups = ['participants']) {
3146
+
3147
+ if(typeof groups !== 'object' || !("length" in groups)) {
3148
+ this._log('setListenIntercomChannels: groups must be an array');
3149
+ groups = [groups];
3150
+ }
3151
+
3152
+ this._listenIntercomChannels = structuredClone(groups);
3153
+
3154
+ if(!this.isConnected) {
3155
+ return
3156
+ }
3157
+
3158
+ return await this.#updateSubscriptions();
3159
+ }
3160
+
3161
+ async setRestrictSubscribeToUserIds(userIds = []) {
3162
+
3163
+ if(typeof userIds !== 'object' || !("length" in userIds)) {
3164
+ this._log('setRestrictSubscribeToUserIds: userIds must be an array');
3165
+ userIds = [userIds];
3166
+ }
3167
+
3168
+ this._restrictSubscribeToUserIds = structuredClone(userIds);
3169
+
3170
+ if(!this.isConnected) {
3171
+ return
3172
+ }
3173
+
3174
+ return await this.#updateSubscriptions();
3175
+ }
3176
+
3177
+ async selectSubStream(id, substream = 2, temporal = 2, source, mid) {
3178
+
3179
+ this._log('Select substream called for id:', id, 'Source, mid:', source, mid, 'Substream:', substream, 'Temporal:', temporal);
3180
+
3181
+ let config = this.#subscriberHandle.webrtcStuff;
3182
+ return new Promise((resolve, reject) => {
3183
+ let messageTimeoutId;
3184
+
3185
+ if(!config.streamMap[id]) {
3186
+ reject('no user found');
3187
+ }
3188
+
3189
+ let clearListeners = () => {
3190
+ clearTimeout(messageTimeoutId);
3191
+ this._abortController.signal.removeEventListener('abort', abortResponse);
3192
+ this.off('longPollEvent', parseResponse);
3193
+ this.ws?.removeEventListener('message', parseResponse);
3194
+ };
3195
+
3196
+ let abortResponse = () => {
3197
+ clearListeners();
3198
+ reject('aborted');
3199
+ };
3200
+
3201
+ let parseResponse = (event) => {
3202
+ let json = typeof event.data === 'string'
3203
+ ? JSON.parse(event.data)
3204
+ : event.data;
3205
+
3206
+ var sender = json["sender"];
3207
+ if(sender === this.#subscriberHandle.handleId) {
3208
+ let plugindata = json["plugindata"] || {};
3209
+ let msg = plugindata["data"] || {};
3210
+ let substream = msg["substream"];
3211
+ if(substream !== undefined && substream !== null && (mid !== undefined ? msg["mid"] === mid : true)) {
3212
+ clearListeners();
3213
+ resolve({substream, sender});
3214
+ }
3215
+ }
3216
+ }
3217
+
3218
+ if(source !== undefined || mid !== undefined) {
3219
+ if(mid === undefined) {
3220
+ let transceivers = config.pc.getTransceivers();
3221
+ for(let trackId of config.streamMap[id][source]) {
3222
+ let transceiver = transceivers.find(transceiver => transceiver.receiver.track && transceiver.receiver.track.kind === 'video' && transceiver.receiver.track.id === trackId)
3223
+ if(transceiver) {
3224
+ mid = transceiver.mid;
3225
+ break;
3226
+ }
3227
+ }
3228
+ }
3229
+ if(mid !== undefined) {
3230
+
3231
+ this.on('longPollEvent', parseResponse);
3232
+ this.ws?.addEventListener('message', parseResponse);
3233
+ this._abortController.signal.addEventListener('abort', abortResponse);
3234
+ messageTimeoutId = setTimeout(() => {
3235
+ clearListeners();
3236
+ reject('timeout');
3237
+ }, 10000);
3238
+
3239
+ this.sendMessage(this.#subscriberHandle.handleId, {
3240
+ "body": {
3241
+ "request": "configure",
3242
+ "streams": [
3243
+ {
3244
+ mid, substream: parseInt(substream), temporal: parseInt(temporal)
3245
+ }
3246
+ ]
3247
+ }
3248
+ })
3249
+ } else {
3250
+ reject('no mid found');
3251
+ }
3252
+ }
3253
+ else {
3254
+ reject('no source or mid');
3255
+ }
3256
+ });
3257
+ }
3258
+
3259
+ async #getStats(type = 'video') {
3260
+
3261
+ let handle = this.#subscriberHandle;
3262
+ if(handle) {
3263
+
3264
+ const tracks = handle?.webrtcStuff?.tracks.filter(track => track.kind === type);
3265
+ const transceivers = handle?.webrtcStuff?.pc?.getTransceivers();
3266
+ return await tracks.reduce(async (prevPromise, track) => {
3267
+
3268
+ const results = await prevPromise;
3269
+ const mid = transceivers.find(t =>
3270
+ t.receiver?.track?.id === track.id || t.sender?.track?.id === track.id
3271
+ )?.mid;
3272
+ const transceiverData = this.#getTransceiverDataByMid(mid);
3273
+
3274
+ const id = transceiverData?.feed_id ?? null;
3275
+ const display = transceiverData?.feed_display ?? null;
3276
+ const description = transceiverData.feed_description ?? null;
3277
+
3278
+ return handle.webrtcStuff.pc.getStats(track)
3279
+ .then(stats => {
3280
+
3281
+ const parsedStats = {
3282
+ inboundRtpStats: null,
3283
+ remoteOutboundRtpStats: null,
3284
+ candidatePairStats: null,
3285
+ packetLoss: 0,
3286
+ jitter: 0,
3287
+ rtt: 0,
3288
+ currentBandwidth: 0,
3289
+ framesDecoded: 0,
3290
+ framesDropped: 0,
3291
+ framesReceived: 0,
3292
+ frameRate: 0,
3293
+ freezeCount: 0,
3294
+ totalFreezesDuration: 0,
3295
+ };
3296
+
3297
+ let selectedCandidatePairId = null;
3298
+
3299
+ // First pass to identify the selected candidate pair ID from transport
3300
+ stats.forEach(stat => {
3301
+ if (stat.type === 'transport' && stat.selectedCandidatePairId) {
3302
+ selectedCandidatePairId = stat.selectedCandidatePairId;
3303
+ }
3304
+ });
3305
+
3306
+ // Second pass to process all stats
3307
+ stats.forEach(stat => {
3308
+ // Inbound RTP stats processing
3309
+ if (stat.type === 'inbound-rtp' && !stat.isRemote) {
3310
+ parsedStats.inboundRtpStats = stat;
3311
+
3312
+ // Consistent unit conversion - convert seconds to milliseconds for jitter
3313
+ parsedStats.jitter = stat.jitter ? Math.round(stat.jitter * 1000) : 0;
3314
+
3315
+ parsedStats.framesDecoded = stat.framesDecoded || 0;
3316
+ parsedStats.framesDropped = stat.framesDropped || 0;
3317
+ parsedStats.framesReceived = stat.framesReceived || 0;
3318
+ parsedStats.frameRate = stat.framesPerSecond || 0;
3319
+ parsedStats.freezeCount = stat.freezeCount || 0;
3320
+ parsedStats.totalFreezesDuration = stat.totalFreezesDuration || 0;
3321
+
3322
+ // Robust packet loss calculation
3323
+ if (stat.packetsLost !== undefined && stat.packetsReceived !== undefined) {
3324
+ const totalPackets = stat.packetsLost + stat.packetsReceived;
3325
+ if (totalPackets > 0) {
3326
+ // Calculate as percentage and round to 2 decimal places for accuracy
3327
+ parsedStats.packetLoss = Number(((stat.packetsLost / totalPackets) * 100).toFixed(2));
3328
+ } else {
3329
+ parsedStats.packetLoss = 0;
3330
+ }
3331
+ } else {
3332
+ parsedStats.packetLoss = 0;
3333
+ }
3334
+ }
3335
+
3336
+ if (stat.type === 'remote-outbound-rtp') {
3337
+ parsedStats.remoteOutboundRtpStats = stat;
3338
+ }
3339
+
3340
+ // Find active candidate pair based on the selectedCandidatePairId
3341
+ if (stat.type === 'candidate-pair' &&
3342
+ (stat.selected || stat.id === selectedCandidatePairId)) {
3343
+ parsedStats.candidatePairStats = stat;
3344
+
3345
+ // RTT calculation from candidate pair
3346
+ if (stat.currentRoundTripTime) {
3347
+ parsedStats.rtt = Math.round(stat.currentRoundTripTime * 1000); // Convert to ms
3348
+ } else if (stat.totalRoundTripTime && stat.responsesReceived > 0) {
3349
+ parsedStats.rtt = Math.round((stat.totalRoundTripTime / stat.responsesReceived) * 1000);
3350
+ }
3351
+ }
3352
+ });
3353
+
3354
+ // Additional fallback for bandwidth estimation from inboundRtp
3355
+ if (parsedStats.currentBandwidth === 0 && parsedStats.inboundRtpStats) {
3356
+ const stat = parsedStats.inboundRtpStats;
3357
+ // Simple estimation based on received bytes over time
3358
+ if (stat.bytesReceived && stat.timestamp && handle.webrtcStuff.stats &&
3359
+ handle.webrtcStuff.stats[stat.id]) {
3360
+ const prevStat = handle.webrtcStuff.stats[stat.id];
3361
+ const timeDiff = stat.timestamp - prevStat.timestamp;
3362
+ if (timeDiff > 0) {
3363
+ const bitrateBps = 8000 * (stat.bytesReceived - prevStat.bytesReceived) / timeDiff;
3364
+ parsedStats.currentBandwidth = Math.floor(bitrateBps / 1000);
3365
+ }
3366
+ }
3367
+ // Store current stats for next calculation
3368
+ if (!handle.webrtcStuff.stats) {
3369
+ handle.webrtcStuff.stats = {};
3370
+ }
3371
+ handle.webrtcStuff.stats[stat.id] = { ...stat };
3372
+ }
3373
+
3374
+ return [...results, {
3375
+ stats: Object.fromEntries(stats), // Convert MapLike object to regular object
3376
+ parsedStats,
3377
+ id,
3378
+ display,
3379
+ description,
3380
+ mid
3381
+ }];
3382
+ });
3383
+ }, Promise.resolve([]))
3384
+ }
3385
+
3386
+ return [];
3387
+ }
3388
+
3389
+ #adjustVideoQualitySettings(stats) {
3390
+
3391
+ // Only proceed if we have stats to work with and simulcast is enabled
3392
+ if (!stats?.length || !this.simulcast) {
3393
+ return;
3394
+ }
3395
+
3396
+ const handle = this.#subscriberHandle;
3397
+ const webrtcStuff = handle?.webrtcStuff;
3398
+
3399
+ if(!handle) {
3400
+ return
3401
+ }
3402
+
3403
+ const now = Date.now();
3404
+
3405
+ // Process each video stream's stats
3406
+ for (const stat of stats) {
3407
+ // Skip if no parsedStats or missing essential data
3408
+ if (!stat.parsedStats || !stat.mid || !stat.id || !stat.description) {
3409
+ continue;
3410
+ }
3411
+
3412
+ const mid = stat.mid;
3413
+ const feedId = stat.id;
3414
+
3415
+ // Parse description to get simulcast settings
3416
+ let simulcastConfig = null;
3417
+ try {
3418
+ const description = typeof stat.description === 'string' ?
3419
+ JSON.parse(stat.description) : stat.description;
3420
+
3421
+ if (description?.simulcastBitrates) {
3422
+ simulcastConfig = {
3423
+ bitrates: description.simulcastBitrates,
3424
+ defaultSubstream: 0 // Default to highest quality initially
3425
+ };
3426
+ }
3427
+ } catch (e) {
3428
+ this._log('Error parsing simulcast config:', e);
3429
+ continue;
3430
+ }
3431
+
3432
+ // Skip if no simulcast config or we don't have layer tracking for this mid
3433
+ if (!simulcastConfig || !webrtcStuff?.currentLayers?.has(mid)) {
3434
+ continue;
3435
+ }
3436
+
3437
+ const currentLayer = webrtcStuff?.currentLayers?.get(mid);
3438
+ const currentSubstream = currentLayer.substream;
3439
+ const currentTemporal = currentLayer.temporal === -1 ? 2 : currentLayer.temporal;
3440
+
3441
+ // Initialize quality history for this mid if needed
3442
+ if (!webrtcStuff.qualityHistory.has(mid)) {
3443
+ webrtcStuff.qualityHistory.set(mid, []);
3444
+ webrtcStuff.lastSwitchTime.set(mid, 0);
3445
+ }
3446
+
3447
+ // Extract network stats
3448
+ const {
3449
+ packetLoss,
3450
+ jitter,
3451
+ rtt,
3452
+ currentBandwidth,
3453
+ framesDropped,
3454
+ framesReceived,
3455
+ freezeCount,
3456
+ totalFreezesDuration
3457
+ } = stat.parsedStats;
3458
+
3459
+ // Calculated stats (like frame drop rate)
3460
+ const frameDropRate = framesReceived ? (framesDropped / framesReceived * 100) : 0;
3461
+
3462
+ // Add current quality measurement to history
3463
+ const qualityMeasure = {
3464
+ timestamp: now,
3465
+ packetLoss,
3466
+ jitter,
3467
+ rtt,
3468
+ currentBandwidth,
3469
+ frameDropRate,
3470
+ freezeCount,
3471
+ totalFreezesDuration
3472
+ };
3473
+
3474
+ // Determine whether we need to switch qualities
3475
+ let targetSubstream = currentSubstream;
3476
+
3477
+ const history = webrtcStuff.qualityHistory.get(mid);
3478
+ history.push(qualityMeasure);
3479
+
3480
+ // Keep only recent history
3481
+ const recentHistory = history.filter(item => now - item.timestamp < this.#rtcStatsConfig.historySize);
3482
+ webrtcStuff.qualityHistory.set(mid, recentHistory);
3483
+
3484
+ let totalFreezesDurationTillLast = null;
3485
+
3486
+ // if we can calculate the freeze duration for last measured segment
3487
+
3488
+ if(recentHistory.length - 2 > -1) {
3489
+
3490
+ totalFreezesDurationTillLast = totalFreezesDuration - (recentHistory?.[recentHistory.length - 2]?.totalFreezesDuration ?? 0);
3491
+ if(totalFreezesDurationTillLast * 1000 >= this.#rtcStatsConfig.freezeLengthThreshold * this._statsInterval) {
3492
+ this._log(`Freezes detected for ${mid} - totalFreezesDuration: ${totalFreezesDuration}, totalFreezesDurationTillLast: ${totalFreezesDurationTillLast}`);
3493
+ if (currentSubstream > 0) {
3494
+ targetSubstream = currentSubstream - 1;
3495
+ }
3496
+
3497
+ // Apply the layer change if needed
3498
+ // Don't change layers if we haven't waited enough since last switch
3499
+
3500
+ if (targetSubstream !== currentSubstream && now - webrtcStuff.lastSwitchTime.get(mid) >= this.#rtcStatsConfig.switchCooldownForFreeze) {
3501
+ webrtcStuff.lastSwitchTime.set(mid, now);
3502
+ this.selectSubStream(feedId, targetSubstream, currentTemporal, null, mid)
3503
+ .then(() => {
3504
+ this._log(`Successfully switched substream for ${mid} to ${targetSubstream}`);
3505
+ })
3506
+ .catch(err => {
3507
+ this._log(`Failed to switch substream for ${mid}:`, err);
3508
+ });
3509
+ }
3510
+ continue;
3511
+ }
3512
+ }
3513
+
3514
+ // Don't change layers if we haven't waited enough since last switch
3515
+ if (now - webrtcStuff.lastSwitchTime.get(mid) < this.#rtcStatsConfig.switchCooldown) {
3516
+ continue;
3517
+ }
3518
+
3519
+ // Get median values from recent history to avoid making decisions on outliers
3520
+ const medianPacketLoss = median(recentHistory.map(item => item.packetLoss));
3521
+ const medianJitter = median(recentHistory.map(item => item.jitter));
3522
+ const medianRtt = median(recentHistory.map(item => item.rtt));
3523
+ const medianBandwidth = median(recentHistory.map(item => item.currentBandwidth));
3524
+ const medianFrameDropRate = median(recentHistory.map(item => item.frameDropRate));
3525
+
3526
+ // Check for network issues that would require downgrading
3527
+ const hasHighPacketLoss = medianPacketLoss > this.#rtcStatsConfig.packetLossThreshold;
3528
+ const hasHighJitter = medianJitter > this.#rtcStatsConfig.jitterBufferThreshold;
3529
+ const hasHighRtt = medianRtt > this.#rtcStatsConfig.roundTripTimeThreshold;
3530
+ // Check if we have high frame drop rate
3531
+ const hasHighFrameDropRate = medianFrameDropRate > this.#rtcStatsConfig.frameDropRateThreshold; // 5% frame drop threshold
3532
+
3533
+ // Determine required bandwidth based on simulcast config
3534
+ let currentLayerBitrate = 0;
3535
+ let nextHigherLayerBitrate = 0;
3536
+
3537
+ // Bitrates are ordered from high to low (h, m, l)
3538
+ if (simulcastConfig?.bitrates?.length > currentSubstream) {
3539
+ currentLayerBitrate = simulcastConfig.bitrates[2 - currentSubstream].maxBitrate / 1000; // Convert to kbps
3540
+
3541
+ if (currentSubstream < simulcastConfig?.bitrates?.length - 1) {
3542
+ nextHigherLayerBitrate = simulcastConfig.bitrates[(2 - currentSubstream) - 1].maxBitrate / 1000;
3543
+ }
3544
+ }
3545
+
3546
+ // !!! WE CAN'T USE THIS AS IT REPORTS medianBandwidth of 1500 for 2000 stream and the stream works just fine
3547
+ // Check if we have enough bandwidth for current layer
3548
+ const hasLowBandwidthForCurrentLayer = medianBandwidth < (currentLayerBitrate * this.#rtcStatsConfig.badBandwidthThresholdMultiplier);
3549
+
3550
+ // Log stats for debugging
3551
+ this._log(`Stream ${mid} stats:`, {
3552
+ packetLoss: medianPacketLoss,
3553
+ jitter: medianJitter,
3554
+ rtt: medianRtt,
3555
+ bandwidth: medianBandwidth,
3556
+ frameDropRate: medianFrameDropRate,
3557
+ currentLayerBitrate,
3558
+ currentSubstream,
3559
+ totalFreezesDurationTillLast
3560
+ });
3561
+
3562
+ // Network issues detected - downgrade if possible
3563
+ if (hasHighPacketLoss || hasHighJitter || hasHighRtt || hasHighFrameDropRate) {
3564
+ // Can't downgrade if already at lowest quality
3565
+ if (currentSubstream > 0) {
3566
+ targetSubstream = currentSubstream - 1;
3567
+ this._log(`Downgrading stream quality for ${mid} due to network issues:`, {
3568
+ packetLoss: medianPacketLoss,
3569
+ jitter: medianJitter,
3570
+ rtt: medianRtt,
3571
+ bandwidth: medianBandwidth,
3572
+ frameDropRate: medianFrameDropRate,
3573
+ from: currentSubstream,
3574
+ to: targetSubstream
3575
+ });
3576
+ }
3577
+ }
3578
+ // Check if we can upgrade - network is good
3579
+ else {
3580
+
3581
+ const stableHistory = recentHistory.filter(item => now - item.timestamp < this.#rtcStatsConfig.stableNetworkTime)
3582
+ const hasStableNetwork = stableHistory.every(item => item.packetLoss < this.#rtcStatsConfig.packetLossThreshold &&
3583
+ item.jitter < this.#rtcStatsConfig.jitterBufferThreshold &&
3584
+ item.rtt < this.#rtcStatsConfig.roundTripTimeThreshold &&
3585
+ item.frameDropRate < this.#rtcStatsConfig.frameDropRateThreshold
3586
+ );
3587
+
3588
+ if (hasStableNetwork) {
3589
+ if(currentSubstream < 2) {
3590
+ targetSubstream = currentSubstream + 1;
3591
+ this._log(`Upgrading stream quality for ${mid} due to good network conditions:`, {
3592
+ packetLoss: medianPacketLoss,
3593
+ jitter: medianJitter,
3594
+ rtt: medianRtt,
3595
+ bandwidth: medianBandwidth,
3596
+ from: currentSubstream,
3597
+ to: targetSubstream
3598
+ });
3599
+ }
3600
+ }
3601
+ }
3602
+
3603
+ // Apply the layer change if needed
3604
+ if (targetSubstream !== currentSubstream) {
3605
+ webrtcStuff.lastSwitchTime.set(mid, now);
3606
+ this.selectSubStream(feedId, targetSubstream, currentTemporal, null, mid)
3607
+ .then(() => {
3608
+ this._log(`Successfully switched substream for ${mid} to ${targetSubstream}`);
3609
+ })
3610
+ .catch(err => {
3611
+ this._log(`Failed to switch substream for ${mid}:`, err);
3612
+ });
3613
+ }
3614
+ }
3615
+ }
3616
+
3617
+ #emitRtcStats(stats) {
3618
+ if (stats?.length > 0) {
3619
+ const s = stats.map(stat => {
3620
+ const {
3621
+ currentBandwidth,
3622
+ frameRate,
3623
+ framesDecoded,
3624
+ framesDropped,
3625
+ framesReceived,
3626
+ freezeCount,
3627
+ jitter,
3628
+ packetLoss,
3629
+ rtt,
3630
+ totalFreezesDuration
3631
+ } = stat.parsedStats;
3632
+
3633
+ let description = {};
3634
+ try {
3635
+ description = JSON.parse(stat.description);
3636
+ } catch {}
3637
+ return {
3638
+ id: stat.id,
3639
+ mid: stat.mid,
3640
+ userId: decodeJanusDisplay(stat.display)?.userId,
3641
+ source: description.source,
3642
+ stats: {
3643
+ currentBandwidth,
3644
+ frameRate,
3645
+ framesDecoded,
3646
+ framesDropped,
3647
+ framesReceived,
3648
+ freezeCount,
3649
+ jitter,
3650
+ packetLoss,
3651
+ rtt,
3652
+ totalFreezesDuration
3653
+ }
3654
+ }
3655
+ });
3656
+ this.emit('rtcStats', s);
3657
+ }
3658
+ }
3659
+
3660
+ #disableStatsWatch() {
3661
+ if (this._statsIntervalId) {
3662
+ clearInterval(this._statsIntervalId);
3663
+ this._statsIntervalId = null;
3664
+ }
3665
+ }
3666
+
3667
+ #enableStatsWatch() {
3668
+
3669
+ this.#disableStatsWatch();
3670
+
3671
+ const loop = () => {
3672
+ this.#getStats('video')
3673
+ .then(stats => {
3674
+ if (stats && stats.length > 0) {
3675
+ this.#emitRtcStats(stats)
3676
+ if(this.options.enableVideoQualityAdjustment) {
3677
+ this.#adjustVideoQualitySettings(stats);
3678
+ }
3679
+ }
3680
+ })
3681
+ };
3682
+
3683
+ this._statsIntervalId = setInterval(loop, this._statsInterval);
3684
+ }
3685
+
3686
+ }
3687
+
3688
+
3689
+ export default Room;
3690
+
3691
+
3692
+