@reactoo/watchtogether-sdk-js 2.7.7 → 2.7.9

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.
@@ -181,17 +181,39 @@ class RoomSession {
181
181
  this.userId = null;
182
182
  this.sessiontype = type;
183
183
  this.initialBitrate = 0;
184
- this.simulcast = false;
185
184
  this.enableDtx = false;
186
- this.simulcastMode = 'controlled'; // controlled, manual, browserControlled
187
- this.simulcastDefaultManualSubstream = 0; // 0 = maximum quality
188
-
189
- // ordered from low to high
190
- this.simulcastBitrates = [
191
- { rid: 'l', active: true, maxBitrate: 180000, maxFramerate: 20, scaleResolutionDownBy: 3.3333333333333335, priority: "low" },
192
- { rid: 'm', active: true, maxBitrate: 500000, maxFramerate: 25, scaleResolutionDownBy: 1.3333333333333335, priority: "low" },
193
- { rid: 'h', active: true, maxBitrate: 2000000, maxFramerate: 30, priority: "low" },
194
- ];
185
+ this.simulcast = false;
186
+ this.defaultSimulcastSettings = {
187
+ "default" : {
188
+ mode: "controlled", // controlled, manual, browserController
189
+ defaultSubstream: 0, // 2 lowest quality, 0 highest quality
190
+ bitrates: [
191
+ {
192
+ "rid": "l",
193
+ "active": true,
194
+ "maxBitrate": 180000,
195
+ "maxFramerate": 20,
196
+ "scaleResolutionDownBy": 3.3333333333333335,
197
+ "priority": "low"
198
+ },
199
+ {
200
+ "rid": "m",
201
+ "active": true,
202
+ "maxBitrate": 500000,
203
+ "maxFramerate": 25,
204
+ "scaleResolutionDownBy": 1.3333333333333335,
205
+ "priority": "low"
206
+ },
207
+ {
208
+ "rid": "h",
209
+ "active": true,
210
+ "maxBitrate": 2000000,
211
+ "maxFramerate": 30,
212
+ "priority": "low"
213
+ }
214
+ ]
215
+ },
216
+ };
195
217
  this.recordingFilename = null;
196
218
  this.pluginName = RoomSession.sessionTypes[type];
197
219
  this.id = null;
@@ -217,6 +239,8 @@ class RoomSession {
217
239
  this._statsTimeoutId = null;
218
240
  this._statsInterval = 1000;
219
241
  this._aqInterval = 2500;
242
+ this._aqIntervalCounter = 0;
243
+ this._aqIntervalDivisor = 4;
220
244
  this._aqTimeoutId = null;
221
245
  this._sendMessageTimeout = 5000;
222
246
  this._retries = 0;
@@ -995,7 +1019,7 @@ class RoomSession {
995
1019
  stats: {},
996
1020
  selectedSubstream: {},
997
1021
  initialSimulcastSubstreamBeenSet: {},
998
- forcedBrowserControlledMode:{},
1022
+ overriddenSimulcastMode: {},
999
1023
  };
1000
1024
 
1001
1025
  if (handleId === this.handleId) {
@@ -1052,7 +1076,7 @@ class RoomSession {
1052
1076
  stats: {},
1053
1077
  selectedSubstream: {},
1054
1078
  initialSimulcastSubstreamBeenSet: {},
1055
- forcedBrowserControlledMode:{},
1079
+ overriddenSimulcastMode: {},
1056
1080
  }
1057
1081
  };
1058
1082
  this._participants.push(handle);
@@ -1288,9 +1312,7 @@ class RoomSession {
1288
1312
  initialBitrate = 0,
1289
1313
  recordingFilename,
1290
1314
  simulcast = false,
1291
- simulcastBitrates = this.simulcastBitrates,
1292
- simulcastMode = this.simulcastMode,
1293
- simulcastDefaultManualSubstream = this.simulcastDefaultManualSubstream,
1315
+ simulcastSettings = this.defaultSimulcastSettings,
1294
1316
  enableDtx = false
1295
1317
  ) {
1296
1318
 
@@ -1318,16 +1340,20 @@ class RoomSession {
1318
1340
  this.isConnecting = true;
1319
1341
  this.enableDtx = enableDtx;
1320
1342
  this.simulcast = simulcast;
1321
- this.simulcastMode = simulcastMode;
1322
- this.simulcastDefaultManualSubstream = simulcastDefaultManualSubstream;
1323
- if(simulcastBitrates !== null) {
1324
- this.simulcastBitrates = structuredClone(simulcastBitrates).sort((a, b) => {
1325
- if(a.maxBitrate === b.maxBitrate) {
1326
- return a.maxFramerate - b.maxFramerate;
1327
- }
1328
- return a.maxBitrate - b.maxBitrate;
1343
+ this.simulcastSettings = structuredClone(simulcastSettings);
1344
+
1345
+ // sort simulcast bitrates
1346
+ if(this.simulcastSettings && typeof this.simulcastSettings === 'object' && Object.keys(this.simulcastSettings).length) {
1347
+ Object.keys(this.simulcastSettings).forEach(k => {
1348
+ this.simulcastSettings[k].bitrates = this.simulcastSettings[k].bitrates.sort((a, b) => {
1349
+ if(a.maxBitrate === b.maxBitrate) {
1350
+ return a.maxFramerate - b.maxFramerate;
1351
+ }
1352
+ return a.maxBitrate - b.maxBitrate;
1353
+ });
1329
1354
  });
1330
1355
  }
1356
+
1331
1357
  this.emit('joining', true);
1332
1358
  return new Promise((resolve, reject) => {
1333
1359
 
@@ -1551,6 +1577,17 @@ class RoomSession {
1551
1577
  return this._participants.find(p => p.handleId === handleId || (rfid && p.rfid === rfid) || (userId && decodeJanusDisplay(p.userId)?.userId === userId));
1552
1578
  }
1553
1579
 
1580
+
1581
+ _findSimulcastConfig(source, settings) {
1582
+ return Object.keys(settings).reduce((acc, key) => {
1583
+ if(settings[source]) {
1584
+ return settings[source];
1585
+ } else if(source.indexOf(key.match(/\*(.*?)\*/)?.[1]) > -1) {
1586
+ return settings[key];
1587
+ } else return acc;
1588
+ }, settings['default']);
1589
+ }
1590
+
1554
1591
  _disableStatsWatch() {
1555
1592
  if (this._statsTimeoutId) {
1556
1593
  clearInterval(this._statsTimeout);
@@ -1593,9 +1630,11 @@ class RoomSession {
1593
1630
  if(this._aqTimeoutId) {
1594
1631
  clearTimeout(this._aqTimeoutId);
1595
1632
  this._aqTimeoutId = null;
1633
+ this._aqIntervalCounter = 0;
1596
1634
  }
1597
-
1598
- this._aqTimeoutId = setInterval(() => {
1635
+
1636
+
1637
+ const checkStats = () => {
1599
1638
  this._participants.forEach(p => {
1600
1639
  if(p.handleId !== this.handleId) {
1601
1640
 
@@ -1605,27 +1644,21 @@ class RoomSession {
1605
1644
 
1606
1645
  const {source, simulcastBitrates} = p.webrtcStuff.tracksMap.find(t => t.mid === mid) || {};
1607
1646
 
1647
+ // track is gone
1608
1648
  if(!simulcastBitrates) {
1609
1649
  return;
1610
1650
  }
1611
1651
 
1652
+ const simulcastConfigForSource = this._findSimulcastConfig(source, this.simulcastSettings);
1612
1653
  const initialSubstreamBeenSet = !!p.webrtcStuff.initialSimulcastSubstreamBeenSet[mid];
1654
+ const defaultSelectedSubstream = p.webrtcStuff?.overriddenSimulcastMode[mid]?.defaultSubstream || simulcastConfigForSource?.defaultSubstream;
1655
+ const simulcastMode = p.webrtcStuff?.overriddenSimulcastMode[mid]?.mode || simulcastConfigForSource?.mode;
1613
1656
 
1614
- const defaultSelectedSubstream = typeof this.simulcastDefaultManualSubstream === 'object'
1615
- ? (this.simulcastDefaultManualSubstream[source] !== undefined
1616
- ? this.simulcastDefaultManualSubstream[source]
1617
- : this.simulcastDefaultManualSubstream['default'])
1618
- : this.simulcastDefaultManualSubstream;
1619
-
1620
- const simulcastMode = typeof this.simulcastMode === 'object'
1621
- ? (this.simulcastMode[source] !== undefined ? this.simulcastMode[source] : this.simulcastMode['default'])
1622
- : this.simulcastMode;
1623
-
1624
- if((simulcastMode === 'browserControlled' || p.webrtcStuff.forcedBrowserControlledMode[mid])) {
1657
+ if((simulcastMode === 'browserControlled')) {
1625
1658
  // do nothing
1626
1659
  }
1627
-
1628
- else if(simulcastMode === 'manual' || !initialSubstreamBeenSet) {
1660
+
1661
+ else if((simulcastMode === 'manual' && this._aqIntervalCounter % this._aqIntervalDivisor === 0) || !initialSubstreamBeenSet) {
1629
1662
  p.webrtcStuff.initialSimulcastSubstreamBeenSet[mid] = true;
1630
1663
  const currentSubstream = p.webrtcStuff.selectedSubstream[mid];
1631
1664
  if(defaultSelectedSubstream !== undefined && defaultSelectedSubstream !== null && defaultSelectedSubstream !== currentSubstream) {
@@ -1638,17 +1671,18 @@ class RoomSession {
1638
1671
  else if(simulcastMode === 'controlled') {
1639
1672
  const currentSubstream = p.webrtcStuff.selectedSubstream[mid];
1640
1673
  const settingsForCurrentSubstream = simulcastBitrates?.[simulcastBitrates.length - 1 - currentSubstream];
1674
+
1641
1675
  let directionDecision = 0;
1642
1676
  if(p.webrtcStuff?.stats?.[mid]?.length > this._upStatsLength) {
1643
1677
  const upMedianStats = this._calculateMedianStats(p.webrtcStuff.stats[mid].slice(this._upStatsLength * -1));
1644
- if(upMedianStats?.framesPerSecond >= Math.floor((settingsForCurrentSubstream?.maxFramerate || 30) * 0.7) && upMedianStats?.freezeDurationSinceLast < (this._upStatsLength * this._statsInterval * 0.33) / 1000 && upMedianStats?.freezeCountSinceLast < 3) {
1678
+ if(upMedianStats?.framesPerSecond >= Math.floor((settingsForCurrentSubstream?.maxFramerate || 30) * 0.7) && upMedianStats?.freezeDurationSinceLast < (this._upStatsLength * this._statsInterval * 0.33) / 1000 /* && upMedianStats?.freezeCountSinceLast < 3 */) {
1645
1679
  directionDecision = 1;
1646
1680
  }
1647
1681
  }
1648
1682
 
1649
1683
  if(p.webrtcStuff?.stats?.[mid]?.length > this._downStatsLength) {
1650
1684
  const downMedianStats = this._calculateMedianStats(p.webrtcStuff.stats[mid].slice(this._downStatsLength * -1));
1651
- if(downMedianStats?.framesPerSecond < Math.floor((settingsForCurrentSubstream?.maxFramerate || 30) * 0.7) || downMedianStats?.freezeDurationSinceLast > (this._downStatsLength * this._statsInterval * 0.33) / 1000 || downMedianStats?.freezeCountSinceLast > 5 /* || downMedianStats?.jitter > maxJitter(settingsForCurrentSubstream.maxFramerate) */) {
1685
+ if(downMedianStats?.framesPerSecond < Math.floor((settingsForCurrentSubstream?.maxFramerate || 30) * 0.7) || downMedianStats?.freezeDurationSinceLast > (this._downStatsLength * this._statsInterval * 0.33) / 1000 /* || downMedianStats?.freezeCountSinceLast > 5 || downMedianStats?.jitter > maxJitter(settingsForCurrentSubstream.maxFramerate) */) {
1652
1686
  directionDecision = -1;
1653
1687
  }
1654
1688
  }
@@ -1676,13 +1710,19 @@ class RoomSession {
1676
1710
  });
1677
1711
  }
1678
1712
  })
1679
- }, this._aqInterval);
1713
+
1714
+ this._aqIntervalCounter++;
1715
+ this._aqTimeoutId = setTimeout(checkStats, this._aqInterval);
1716
+ }
1717
+
1718
+ checkStats();
1680
1719
  }
1681
1720
 
1682
1721
  _disableSubstreamAutoSelect() {
1683
1722
  if(this._aqTimeoutId) {
1684
1723
  clearTimeout(this._aqTimeoutId);
1685
1724
  this._aqTimeoutId = null;
1725
+ this._aqIntervalCounter = 0;
1686
1726
  }
1687
1727
  }
1688
1728
 
@@ -1737,6 +1777,10 @@ class RoomSession {
1737
1777
  powerEfficientDecoder: null,
1738
1778
  };
1739
1779
  participantStats.stats.forEach(report => {
1780
+
1781
+ const simulcastConfigForSource = this._findSimulcastConfig(participantStats.source, this.simulcastSettings);
1782
+ const simulcastMode = handle.webrtcStuff?.overriddenSimulcastMode[participantStats.mid]?.mode || simulcastConfigForSource?.mode ;
1783
+
1740
1784
  if(report.type === 'inbound-rtp' && report.kind === 'video') {
1741
1785
  stats.framesPerSecond = report.framesPerSecond || 0;
1742
1786
  stats.framesDropped = report.framesDropped || 0;
@@ -1759,14 +1803,7 @@ class RoomSession {
1759
1803
  }
1760
1804
 
1761
1805
  stats.selectedSubstream = handle.webrtcStuff.selectedSubstream[participantStats.mid];
1762
- if(handle.webrtcStuff.forcedBrowserControlledMode[participantStats.mid]) {
1763
- stats.simulcastMode = 'browserControlled';
1764
- }
1765
- else {
1766
- stats.simulcastMode = typeof this.simulcastMode === 'object'
1767
- ? (this.simulcastMode[participantStats.source] !== undefined ? this.simulcastMode[participantStats.source] : this.simulcastMode['default'])
1768
- : this.simulcastMode;
1769
- }
1806
+ stats.simulcastMode = simulcastMode;
1770
1807
 
1771
1808
  });
1772
1809
 
@@ -2060,6 +2097,10 @@ class RoomSession {
2060
2097
  });
2061
2098
  };
2062
2099
 
2100
+ let mutedTimerId = {};
2101
+ let waitPeriod = 300; // ms
2102
+ let screenShareWaitPeriod = 5000; // ms
2103
+
2063
2104
  event.track.onmute = (ev) => {
2064
2105
  this._log('Remote track muted');
2065
2106
 
@@ -2081,22 +2122,35 @@ class RoomSession {
2081
2122
  muted: true
2082
2123
  });
2083
2124
 
2084
- // questionable hotfix
2085
- // when a track is muted, we try to switch to lower quality substream
2125
+ // when a track is muted, we try to switch to lower quality substream, but not for screen sharing
2086
2126
 
2087
2127
  if(!this.simulcast) {
2088
2128
  return;
2089
2129
  }
2090
2130
 
2091
- const simulcastMode = typeof this.simulcastMode === 'object'
2092
- ? (this.simulcastMode[source] !== undefined ? this.simulcastMode[source] : this.simulcastMode['default'])
2093
- : this.simulcastMode;
2094
- const {simulcastBitrates} = handle.webrtcStuff.tracksMap.find(t => t.mid === mid) || {};
2095
- const currentSubstream = handle.webrtcStuff.selectedSubstream[mid];
2096
- if(!(simulcastMode === 'browserControlled' || handle.webrtcStuff.forcedBrowserControlledMode[mid]) && ev.target.kind === 'video' && currentSubstream < simulcastBitrates.length - 1) {
2097
- this.selectSubStream(handle.handleId, currentSubstream + 1, undefined, mid, false)
2098
- .catch((reason) => this._log(`Changing substream for mid: ${mid} failed. Reason: ${reason}`));
2131
+ const wPeriod = source.indexOf('screen') > -1 ? screenShareWaitPeriod : waitPeriod;
2132
+
2133
+ if(!mutedTimerId[mid]) {
2134
+ mutedTimerId[mid] = setTimeout(() => {
2135
+ mutedTimerId[mid] = null;
2136
+ const simulcastConfigForSource = this._findSimulcastConfig(source, this.simulcastSettings);
2137
+ const simulcastMode = handle.webrtcStuff?.overriddenSimulcastMode[mid]?.mode || simulcastConfigForSource?.mode;
2138
+ const {simulcastBitrates} = handle.webrtcStuff.tracksMap.find(t => t.mid === mid) || {};
2139
+
2140
+ // track is gone
2141
+ if(!simulcastBitrates) {
2142
+ return;
2143
+ }
2144
+
2145
+ const currentSubstream = handle.webrtcStuff.selectedSubstream[mid];
2146
+ if(!(simulcastMode === 'browserControlled') && ev.target.kind === 'video' && currentSubstream < simulcastBitrates.length - 1) {
2147
+ this._log('Attempting to down the quality due to track muted');
2148
+ this.selectSubStream(handle.handleId, currentSubstream + 1, undefined, mid, false)
2149
+ .catch((reason) => this._log(`Changing substream for mid: ${mid} failed. Reason: ${reason}`));
2150
+ }
2151
+ }, wPeriod);
2099
2152
  }
2153
+
2100
2154
  };
2101
2155
 
2102
2156
  event.track.onunmute = (ev) => {
@@ -2106,6 +2160,12 @@ class RoomSession {
2106
2160
  t => t.receiver.track === ev.target);
2107
2161
  let mid = transceiver.mid || ev.target.id;
2108
2162
  let source = Object.keys(config.streamMap).find(key => config.streamMap[key].includes(ev.target.id));
2163
+
2164
+ if(mutedTimerId[mid]) {
2165
+ clearTimeout(mutedTimerId[mid]);
2166
+ mutedTimerId[mid] = null;
2167
+ }
2168
+
2109
2169
  this.emit('remoteTrackMuted', {
2110
2170
  id: handle.handleId,
2111
2171
  mid,
@@ -2591,6 +2651,7 @@ class RoomSession {
2591
2651
  config.stream.removeTrack(oldVideoStream);
2592
2652
  }
2593
2653
 
2654
+ const simulcastConfigForSource = this._findSimulcastConfig(source, this.simulcastSettings);
2594
2655
  let audioTrackReplacePromise = Promise.resolve();
2595
2656
  let videoTrackReplacePromise = Promise.resolve();
2596
2657
 
@@ -2646,11 +2707,12 @@ class RoomSession {
2646
2707
  }
2647
2708
  else {
2648
2709
  if(adapter.browserDetails.browser !== 'firefox') {
2710
+
2649
2711
  // standard
2650
2712
  config.pc.addTransceiver(stream.getVideoTracks()[0], {
2651
2713
  direction: 'sendonly',
2652
2714
  streams: [config.stream],
2653
- sendEncodings: structuredClone(this.simulcastBitrates)
2715
+ sendEncodings: structuredClone(simulcastConfigForSource?.bitrates)
2654
2716
  })
2655
2717
  }
2656
2718
  else {
@@ -2662,7 +2724,7 @@ class RoomSession {
2662
2724
  let sender = transceiver ? transceiver.sender : null;
2663
2725
  if(sender) {
2664
2726
  let parameters = sender.getParameters() || {};
2665
- parameters.encodings = stream.getVideoTracks()[0].sendEncodings || structuredClone(this.simulcastBitrates);
2727
+ parameters.encodings = stream.getVideoTracks()[0].sendEncodings || structuredClone(simulcastConfigForSource?.bitrates);
2666
2728
  sender.setParameters(parameters);
2667
2729
  }
2668
2730
  }
@@ -2796,10 +2858,11 @@ class RoomSession {
2796
2858
 
2797
2859
  let descriptions = [];
2798
2860
  Object.keys(config.streamMap).forEach(source => {
2861
+ const simulcastConfigForSource = this._findSimulcastConfig(source, this.simulcastSettings);
2799
2862
  config.streamMap[source].forEach(trackId => {
2800
2863
  let t = transceivers.find(transceiver => transceiver.sender.track && transceiver.sender.track.id === trackId)
2801
2864
  if(t) {
2802
- descriptions.push({mid: t.mid, description: JSON.stringify({source, simulcastBitrates: this.simulcastBitrates, intercomGroups: this._talkIntercomChannels})});
2865
+ descriptions.push({mid: t.mid, description: JSON.stringify({source, simulcastBitrates: simulcastConfigForSource?.bitrates, intercomGroups: this._talkIntercomChannels})});
2803
2866
  }
2804
2867
  })
2805
2868
  });
@@ -2993,6 +3056,39 @@ class RoomSession {
2993
3056
  }
2994
3057
  }
2995
3058
 
3059
+ overrideSimulcastSettings(handleId, mid, source, settings = {}) {
3060
+ const {mode, defaultSubstream} = settings;
3061
+ let handle = this._getHandle(handleId);
3062
+ if(!handle) {
3063
+ return Promise.resolve();
3064
+ }
3065
+ let config = handle.webrtcStuff;
3066
+ if(source !== undefined || mid !== undefined) {
3067
+ if(mid === undefined) {
3068
+ let transceivers = config.pc.getTransceivers();
3069
+ for(let trackId of config.streamMap[source]) {
3070
+ let transceiver = transceivers.find(transceiver => transceiver.receiver.track && transceiver.receiver.track.kind === 'video' && transceiver.receiver.track.id === trackId)
3071
+ if(transceiver) {
3072
+ mid = transceiver.mid;
3073
+ break;
3074
+ }
3075
+ }
3076
+ }
3077
+
3078
+ if(mid !== undefined) {
3079
+ if(!config.overriddenSimulcastMode[mid]) {
3080
+ config.overriddenSimulcastMode[mid] = {};
3081
+ }
3082
+ config.overriddenSimulcastMode[mid]['defaultSubstream'] = defaultSubstream;
3083
+ config.overriddenSimulcastMode[mid]['mode'] = mode;
3084
+ return true;
3085
+ }
3086
+ else {
3087
+ return false;
3088
+ }
3089
+ }
3090
+ }
3091
+
2996
3092
  selectSubStream(handleId, substream = 2, source, mid, manual = false) {
2997
3093
  this._log('Select substream called for handle:', handleId, 'Source or mid:', source ? source : mid, 'Substream:', substream);
2998
3094
  let handle = this._getHandle(handleId);
@@ -3036,10 +3132,17 @@ class RoomSession {
3036
3132
  }
3037
3133
  }
3038
3134
  if(mid !== undefined) {
3135
+
3136
+ if(!config.overriddenSimulcastMode[mid]) {
3137
+ config.overriddenSimulcastMode[mid] = {};
3138
+ }
3139
+
3039
3140
  if(substream === null) {
3040
3141
 
3041
3142
  if(manual) {
3042
- config.forcedBrowserControlledMode[mid] = false
3143
+ // reset to previous state
3144
+ config.overriddenSimulcastMode[mid]['defaultSubstream'] = null;
3145
+ config.overriddenSimulcastMode[mid]['mode'] = null;
3043
3146
  }
3044
3147
 
3045
3148
  resolve({substream, sender: handleId});
@@ -3047,7 +3150,8 @@ class RoomSession {
3047
3150
  }
3048
3151
 
3049
3152
  if(manual) {
3050
- config.forcedBrowserControlledMode[mid] = true
3153
+ config.overriddenSimulcastMode[mid]['defaultSubstream'] = substream;
3154
+ config.overriddenSimulcastMode[mid]['mode'] = "manual";
3051
3155
  }
3052
3156
 
3053
3157
  this.ws.addEventListener('message', parseResponse);
@@ -3094,10 +3198,11 @@ class RoomSession {
3094
3198
  let transceivers = config.pc.getTransceivers();
3095
3199
  let descriptions = [];
3096
3200
  Object.keys(config.streamMap).forEach(source => {
3201
+ const simulcastConfigForSource = this._findSimulcastConfig(source, this.simulcastSettings);
3097
3202
  config.streamMap[source].forEach(trackId => {
3098
3203
  let t = transceivers.find(transceiver => transceiver.sender.track && transceiver.sender.track.id === trackId)
3099
3204
  if(t) {
3100
- descriptions.push({mid: t.mid, description: JSON.stringify({simulcastBitrates: this.simulcastBitrates, intercomGroups: groups, source:source})});
3205
+ descriptions.push({mid: t.mid, description: JSON.stringify({simulcastBitrates: simulcastConfigForSource?.bitrates, intercomGroups: groups, source:source})});
3101
3206
  }
3102
3207
  })
3103
3208
  });
@@ -128,6 +128,14 @@ const maxJitter = (x) => {
128
128
  return a / (1 + Math.exp(-b * (x - c))) + d; // Fixed the typo here
129
129
  }
130
130
 
131
+ const chunkArray = (array, chunkSize) => {
132
+ if (!array?.length) return [[]];
131
133
 
134
+ let results = [];
135
+ while (array.length) {
136
+ results.push(array.splice(0, chunkSize));
137
+ }
138
+ return results;
139
+ }
132
140
 
133
- export {wait, getBrowserFingerprint, generateUUID, decodeJanusDisplay, setExactTimeout, clearExactTimeout, median, maxJitter}
141
+ export {wait, getBrowserFingerprint, generateUUID, decodeJanusDisplay, setExactTimeout, clearExactTimeout, median, maxJitter, chunkArray}