@voltkit-rtc/client 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +244 -2
  3. package/dist/cjs/adaptive.d.ts +104 -0
  4. package/dist/cjs/adaptive.d.ts.map +1 -0
  5. package/dist/cjs/adaptive.js +263 -0
  6. package/dist/cjs/adaptive.js.map +1 -0
  7. package/dist/cjs/devices.d.ts +42 -0
  8. package/dist/cjs/devices.d.ts.map +1 -0
  9. package/dist/cjs/devices.js +56 -0
  10. package/dist/cjs/devices.js.map +1 -0
  11. package/dist/cjs/diagnostics.d.ts +65 -0
  12. package/dist/cjs/diagnostics.d.ts.map +1 -0
  13. package/dist/cjs/diagnostics.js +69 -0
  14. package/dist/cjs/diagnostics.js.map +1 -0
  15. package/dist/cjs/fallback.d.ts +81 -0
  16. package/dist/cjs/fallback.d.ts.map +1 -0
  17. package/dist/cjs/fallback.js +148 -0
  18. package/dist/cjs/fallback.js.map +1 -0
  19. package/dist/cjs/health.d.ts +70 -0
  20. package/dist/cjs/health.d.ts.map +1 -0
  21. package/dist/cjs/health.js +116 -0
  22. package/dist/cjs/health.js.map +1 -0
  23. package/dist/cjs/index.d.ts +339 -0
  24. package/dist/cjs/index.d.ts.map +1 -1
  25. package/dist/cjs/index.js +1098 -26
  26. package/dist/cjs/index.js.map +1 -1
  27. package/dist/cjs/link.d.ts +99 -0
  28. package/dist/cjs/link.d.ts.map +1 -0
  29. package/dist/cjs/link.js +219 -0
  30. package/dist/cjs/link.js.map +1 -0
  31. package/dist/cjs/preflight.d.ts +79 -0
  32. package/dist/cjs/preflight.d.ts.map +1 -0
  33. package/dist/cjs/preflight.js +159 -0
  34. package/dist/cjs/preflight.js.map +1 -0
  35. package/dist/cjs/speakers.d.ts +34 -0
  36. package/dist/cjs/speakers.d.ts.map +1 -0
  37. package/dist/cjs/speakers.js +71 -0
  38. package/dist/cjs/speakers.js.map +1 -0
  39. package/dist/esm/adaptive.d.ts +104 -0
  40. package/dist/esm/adaptive.d.ts.map +1 -0
  41. package/dist/esm/adaptive.js +256 -0
  42. package/dist/esm/adaptive.js.map +1 -0
  43. package/dist/esm/devices.d.ts +42 -0
  44. package/dist/esm/devices.d.ts.map +1 -0
  45. package/dist/esm/devices.js +50 -0
  46. package/dist/esm/devices.js.map +1 -0
  47. package/dist/esm/diagnostics.d.ts +65 -0
  48. package/dist/esm/diagnostics.d.ts.map +1 -0
  49. package/dist/esm/diagnostics.js +65 -0
  50. package/dist/esm/diagnostics.js.map +1 -0
  51. package/dist/esm/fallback.d.ts +81 -0
  52. package/dist/esm/fallback.d.ts.map +1 -0
  53. package/dist/esm/fallback.js +142 -0
  54. package/dist/esm/fallback.js.map +1 -0
  55. package/dist/esm/health.d.ts +70 -0
  56. package/dist/esm/health.d.ts.map +1 -0
  57. package/dist/esm/health.js +111 -0
  58. package/dist/esm/health.js.map +1 -0
  59. package/dist/esm/index.d.ts +339 -0
  60. package/dist/esm/index.d.ts.map +1 -1
  61. package/dist/esm/index.js +1096 -26
  62. package/dist/esm/index.js.map +1 -1
  63. package/dist/esm/link.d.ts +99 -0
  64. package/dist/esm/link.d.ts.map +1 -0
  65. package/dist/esm/link.js +214 -0
  66. package/dist/esm/link.js.map +1 -0
  67. package/dist/esm/preflight.d.ts +79 -0
  68. package/dist/esm/preflight.d.ts.map +1 -0
  69. package/dist/esm/preflight.js +153 -0
  70. package/dist/esm/preflight.js.map +1 -0
  71. package/dist/esm/speakers.d.ts +34 -0
  72. package/dist/esm/speakers.d.ts.map +1 -0
  73. package/dist/esm/speakers.js +67 -0
  74. package/dist/esm/speakers.js.map +1 -0
  75. package/package.json +1 -1
  76. package/src/adaptive.ts +295 -0
  77. package/src/devices.ts +70 -0
  78. package/src/diagnostics.ts +94 -0
  79. package/src/fallback.ts +169 -0
  80. package/src/health.ts +127 -0
  81. package/src/index.ts +1097 -27
  82. package/src/link.ts +255 -0
  83. package/src/preflight.ts +191 -0
  84. package/src/speakers.ts +66 -0
package/dist/cjs/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.StreamClient = exports.CLIENT_EVENTS = exports.CONNECTION_STATES = exports.VIDEO_QUALITIES = exports.videoEncodings = exports.layerBitrate = exports.allowedQuality = exports.tokenExpiresAt = exports.TOKEN_EXPIRING_LEAD_MS = exports.networkLevel = exports.NETWORK_QUALITY_LEVELS = exports.NETWORK_QUALITY_INTERVAL_MS = exports.resolvePermissions = exports.DEFAULT_MEDIA_PERMISSIONS = exports.AUDIO_SETTINGS = exports.AUDIO_QUALITIES = exports.isTerminalError = exports.errorInfo = exports.ERROR_CODES = exports.RpcError = exports.ChatRpcError = exports.ChatClient = exports.MusicSource = exports.AudioMixer = exports.whipOffer = exports.whipEnd = exports.WhipError = exports.BroadcastPublisher = exports.RecordingError = exports.RecordingUploader = exports.CameraRecording = void 0;
3
+ exports.StreamClient = exports.CLIENT_EVENTS = exports.CONNECTION_STATES = exports.SDK_VERSION = exports.SPEAKING_LEVEL = exports.VIDEO_QUALITIES = exports.videoEncodings = exports.layerBitrate = exports.allowedQuality = exports.tokenExpiresAt = exports.TOKEN_EXPIRING_LEAD_MS = exports.networkLevel = exports.NETWORK_QUALITY_LEVELS = exports.NETWORK_QUALITY_INTERVAL_MS = exports.resolvePermissions = exports.DEFAULT_MEDIA_PERMISSIONS = exports.AUDIO_SETTINGS = exports.AUDIO_QUALITIES = exports.isTerminalError = exports.errorInfo = exports.ERROR_CODES = exports.RpcError = exports.ChatRpcError = exports.ChatClient = exports.MusicSource = exports.AudioMixer = exports.whipOffer = exports.whipEnd = exports.WhipError = exports.BroadcastPublisher = exports.RecordingError = exports.RecordingUploader = exports.CameraRecording = void 0;
4
4
  const recording_js_1 = require("./recording.js");
5
5
  /**
6
6
  * Thin client SDK over the orchestrator's signaling protocol.
@@ -13,6 +13,14 @@ const mediasoup_client_1 = require("mediasoup-client");
13
13
  const errors_js_1 = require("./errors.js");
14
14
  const media_permissions_js_1 = require("./media-permissions.js");
15
15
  const network_js_1 = require("./network.js");
16
+ const link_js_1 = require("./link.js");
17
+ const adaptive_js_1 = require("./adaptive.js");
18
+ const fallback_js_1 = require("./fallback.js");
19
+ const speakers_js_1 = require("./speakers.js");
20
+ const preflight_js_1 = require("./preflight.js");
21
+ const diagnostics_js_1 = require("./diagnostics.js");
22
+ const health_js_1 = require("./health.js");
23
+ const devices_js_1 = require("./devices.js");
16
24
  const token_js_1 = require("./token.js");
17
25
  const device_js_1 = require("./device.js");
18
26
  const music_js_1 = require("./music.js");
@@ -55,19 +63,43 @@ Object.defineProperty(exports, "allowedQuality", { enumerable: true, get: functi
55
63
  Object.defineProperty(exports, "layerBitrate", { enumerable: true, get: function () { return quality_js_2.layerBitrate; } });
56
64
  Object.defineProperty(exports, "videoEncodings", { enumerable: true, get: function () { return quality_js_2.videoEncodings; } });
57
65
  Object.defineProperty(exports, "VIDEO_QUALITIES", { enumerable: true, get: function () { return quality_js_2.VIDEO_QUALITIES; } });
66
+ var speakers_js_2 = require("./speakers.js");
67
+ Object.defineProperty(exports, "SPEAKING_LEVEL", { enumerable: true, get: function () { return speakers_js_2.SPEAKING_LEVEL; } });
68
+ var diagnostics_js_2 = require("./diagnostics.js");
69
+ Object.defineProperty(exports, "SDK_VERSION", { enumerable: true, get: function () { return diagnostics_js_2.SDK_VERSION; } });
58
70
  /** Where a session stands, in the order a session goes through them. `closed` is final; the `closed` event says why. */
59
71
  exports.CONNECTION_STATES = Object.freeze(['idle', 'connecting', 'connected', 'reconnecting', 'closed']);
60
72
  const defaultSource = (kind) => (kind === 'audio' ? 'mic' : 'camera');
73
+ /** A receiver's most recent audio level (0..1), from its synchronization sources: cheap, made for this. */
74
+ function receiverLevel(receiver) {
75
+ const sources = receiver?.getSynchronizationSources?.() ?? [];
76
+ let level = 0;
77
+ for (const s of sources)
78
+ if (typeof s.audioLevel === 'number')
79
+ level = Math.max(level, s.audioLevel);
80
+ return level;
81
+ }
82
+ /** A microphone producer's level now (0..1), from its media-source statistics. */
83
+ async function senderLevel(producer) {
84
+ const stats = await producer.getStats();
85
+ for (const s of stats.values()) {
86
+ if (s.type === 'media-source' && typeof s.audioLevel === 'number')
87
+ return s.audioLevel;
88
+ }
89
+ return 0;
90
+ }
61
91
  // Every event a StreamClient emits. Typed against ClientEvents, so adding an
62
92
  // event without listing it here (or listing one that does not exist) fails to
63
93
  // compile; the contract check compares this list with docs/sdk-contract.json.
64
94
  const EVENT_NAMES = {
95
+ mediaReconnecting: true,
65
96
  connected: true, disconnected: true, connectionStateChanged: true, joined: true,
66
97
  track: true, trackEnded: true, trackMuted: true,
67
98
  participantJoined: true, participantLeft: true, participantMuted: true, participantUpdated: true,
68
99
  roleChanged: true, handRaised: true, forceMuted: true, claimChanged: true, roomMetadataChanged: true,
69
100
  mustReconnect: true, roomClosed: true, kicked: true, streamInterrupted: true, closed: true,
70
- usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true,
101
+ usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true, cpuConstrained: true,
102
+ streamFallback: true, publishFallback: true, devicesChanged: true, localTrackEnded: true, audioLevels: true, activeSpeakers: true, mediaWarning: true, autoplayFailed: true,
71
103
  screenShareStarted: true, screenShareEnded: true, error: true, localMediaChanged: true, localStreamChanged: true,
72
104
  message: true, messageDeleted: true, chatMuted: true, typing: true,
73
105
  };
@@ -79,7 +111,6 @@ const MAX_RECONNECT_ATTEMPTS = 8;
79
111
  const CLOSE_REPLACED = 4001;
80
112
  /** The server ended the session on purpose and said why first: do not reconnect. */
81
113
  const CLOSE_ENDED = 4003;
82
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
83
114
  const isLayer = (value) => Number.isInteger(value) && value >= 0;
84
115
  /**
85
116
  * Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
@@ -122,6 +153,78 @@ function declareAudioQuality(rtpParameters, appData) {
122
153
  class StreamClient {
123
154
  wsUrl;
124
155
  options;
156
+ backoffWake = null;
157
+ onlineTarget = null;
158
+ online = () => {
159
+ if (this.closedByUs)
160
+ return;
161
+ this.backoffWake?.();
162
+ this.socketLiveness.checkNow();
163
+ };
164
+ socketLiveness = new link_js_1.SocketLiveness({
165
+ active: () => !this.closedByUs && this.state === 'connected',
166
+ ping: () => { void this.rpc('ping').catch(() => undefined); },
167
+ dead: () => this.recoverConnection('socket-timeout'),
168
+ });
169
+ mediaPaths = new link_js_1.MediaPathWatch({
170
+ active: () => !this.closedByUs && this.state === 'connected',
171
+ restartIce: id => this.restartMediaIce(id),
172
+ reconnect: reason => this.recoverConnection(reason),
173
+ onPathChange: (transportId, reconnecting) => {
174
+ if (!this.closedByUs)
175
+ this.emit('mediaReconnecting', { transportId, reconnecting });
176
+ },
177
+ });
178
+ recoverConnection(reason) {
179
+ if (this.closedByUs || !this.joinedOnce || this.state !== 'connected')
180
+ return;
181
+ this.setState('reconnecting');
182
+ if (this.closedByUs)
183
+ return; // a state listener may close synchronously
184
+ this.mediaPaths.reset();
185
+ if (this.closedByUs)
186
+ return;
187
+ this.detachSocket(reason);
188
+ this.networkWatch.set(false);
189
+ this.emit('disconnected', reason);
190
+ void this.scheduleReconnect();
191
+ }
192
+ async restartMediaIce(id) {
193
+ const transport = [this.sendTransport, this.recvTransport].find(t => t?.id === id);
194
+ const socket = this.ws;
195
+ if (!transport || transport.closed || this.closedByUs || this.state !== 'connected')
196
+ return 'skipped';
197
+ const { iceParameters } = await this.rpc('restartIce', { transportId: id });
198
+ if (this.ws !== socket || transport.closed || this.closedByUs ||
199
+ (transport !== this.sendTransport && transport !== this.recvTransport))
200
+ return 'skipped';
201
+ await transport.restartIce({ iceParameters });
202
+ if (this.ws === socket && !this.closedByUs && !transport.closed && transport.connectionState === 'connected') {
203
+ this.mediaPaths.update(id, 'connected');
204
+ }
205
+ return 'restarted';
206
+ }
207
+ watchTransport(transport) {
208
+ transport.on('connectionstatechange', state => {
209
+ if (transport !== this.sendTransport && transport !== this.recvTransport)
210
+ return;
211
+ if (!this.closedByUs && this.state === 'connected')
212
+ this.mediaPaths.update(transport.id, state);
213
+ });
214
+ transport.observer?.on('close', () => this.mediaPaths.forget(transport.id));
215
+ }
216
+ waitBackoff(ms) {
217
+ return new Promise(resolve => {
218
+ const finish = () => {
219
+ clearTimeout(timer);
220
+ if (this.backoffWake === finish)
221
+ this.backoffWake = null;
222
+ resolve();
223
+ };
224
+ const timer = setTimeout(finish, ms);
225
+ this.backoffWake = finish;
226
+ });
227
+ }
125
228
  ws = null;
126
229
  device = null;
127
230
  sendTransport = null;
@@ -194,6 +297,67 @@ class StreamClient {
194
297
  * the full picture.
195
298
  */
196
299
  preferredLayers = new Map();
300
+ /**
301
+ * Adaptive stream (attachVideo): each attached remote video at the size it is
302
+ * shown, and paused at the server while no attached element is visible.
303
+ */
304
+ adaptive = new adaptive_js_1.AdaptiveVideos({
305
+ layers: (id) => this.remoteVideoLayers.get(id) ?? [],
306
+ apply: (id, want) => { void this.applyAdaptive(id, this.withFallback(id, want)); },
307
+ pixelRatio: () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1),
308
+ pageVisible: adaptive_js_1.pageIsVisible,
309
+ });
310
+ /** Audio-only fallback: whether it may happen, its decisions, and the sampling that feeds them. */
311
+ audioOnlyEnabled = true;
312
+ protection = new fallback_js_1.AudioProtection((active) => this.onAudioProtection(active));
313
+ protectionTimer = null;
314
+ protectionSampling = false;
315
+ /** Publisher-side fallback: off unless asked for; its decisions, timer, and whether it holds the camera now. */
316
+ publishFallbackEnabled = false;
317
+ publishProtection = new fallback_js_1.AudioProtection((active) => { void this.onPublishProtection(active); });
318
+ publishProtectionTimer = null;
319
+ publishProtectionSampling = false;
320
+ cameraHeldByFallback = false;
321
+ /** Remote videos the media server reports it cut for this viewer's bandwidth. */
322
+ bandwidthCut = new Set();
323
+ /** Remote cameras the audio-only fallback holds back now (reported with fallback: true). */
324
+ protectionHeld = new Set();
325
+ /** audioLevels / activeSpeakers: read only while someone listens. */
326
+ levelTimer = null;
327
+ levelSampling = false;
328
+ speakers = new speakers_js_1.ActiveSpeakers();
329
+ /** mediaWarning: decisions, the sampling timer, and the system interruptions being timed. */
330
+ health = new health_js_1.HealthDecisions((warning) => this.emit('mediaWarning', warning));
331
+ healthTimer = null;
332
+ healthSampling = false;
333
+ interruptions = new Map();
334
+ /** getDiagnostics(): the SDK's recent events, for a support ticket. */
335
+ diagnosticsLog = new diagnostics_js_1.DiagnosticsLog();
336
+ /** play(): elements the browser refused to play until the viewer interacts, retried on the next gesture. */
337
+ autoplayBlocked = new Set();
338
+ gestureWatchOn = false;
339
+ onGesture = () => { void this.retryAutoplay(); };
340
+ /** setPlayoutDelay: the receive buffer asked for every remote track, in ms (null: the browser's own). */
341
+ playoutDelayMs = null;
342
+ /** devicesChanged: the device list last seen, kept while someone listens. */
343
+ knownDevices = [];
344
+ deviceWatchOn = false;
345
+ onDeviceChange = () => { void this.refreshDevices(); };
346
+ /** Videos adaptive stream has paused at the server, by producer id. */
347
+ adaptivePaused = new Set();
348
+ /** The server has no pauseConsumer (an older controller): a hidden video drops to its smallest layer instead. */
349
+ pauseUnsupported = false;
350
+ /** The last cpuConstrained said. */
351
+ cpuConstrainedNow = false;
352
+ /** prioritizePerformance(): the camera's largest simulcast layer is not encoded while true. */
353
+ performanceFirst = false;
354
+ /**
355
+ * Dynacast (the server's `producerLayers`): the highest layer any viewer needs, by producer id (-1 = nobody
356
+ * watches, the smallest layer stays on). No entry: every layer, as before the server said anything.
357
+ */
358
+ layersNeeded = new Map();
359
+ /** The highest camera layer this client set active, and on which producer. */
360
+ cameraMaxLayer = null;
197
361
  /** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
198
362
  endRoomWaiters = new Set();
199
363
  closedByUs = false;
@@ -220,7 +384,14 @@ class StreamClient {
220
384
  codecFallbackLogged = false;
221
385
  sendStatsBaseline = new Map();
222
386
  recvStatsBaseline = new Map();
223
- limitWatch = new stats_js_1.VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) => this.emit('videoLimited', event), undefined, () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; });
387
+ limitWatch = new stats_js_1.VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) => {
388
+ this.emit('videoLimited', event);
389
+ const constrained = event.reason === 'cpu';
390
+ if (constrained !== this.cpuConstrainedNow) {
391
+ this.cpuConstrainedNow = constrained;
392
+ this.emit('cpuConstrained', { constrained });
393
+ }
394
+ }, undefined, () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; });
224
395
  /** Where the session stands; see connectionStateChanged. */
225
396
  state = 'idle';
226
397
  /** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
@@ -251,11 +422,55 @@ class StreamClient {
251
422
  audioCapReported = false;
252
423
  sizeCapReported = false;
253
424
  networkWatch = new network_js_1.NetworkQualityWatch(() => this.transportStats(), () => this.producers.size > 0, (quality) => this.emit('networkQuality', quality));
425
+ /**
426
+ * A pre-call check (Twilio's runPreflight, LiveKit's ConnectionCheck, Agora's last-mile probe): joins the room the
427
+ * token names, publishes `options.media` (cloned, your tracks keep running) or a tone and a moving picture it makes,
428
+ * measures for `durationMs` (5 s) and leaves. Mint a host token for a test room nobody else is in: the session is
429
+ * real, and billed like any other for its few seconds. Resolves with each step, how media travelled (UDP is best;
430
+ * TCP or a relay means a firewall in the way), the round trip, loss and upload, and any camera or microphone warning
431
+ * seen. Never rejects: a failure is a step with `ok: false` and its error.
432
+ */
433
+ static async preflight(wsUrl, token, options = {}) {
434
+ const client = new StreamClient(wsUrl, token, { audioOnlyFallback: false });
435
+ const synthetic = options.media ? null : (0, preflight_js_1.syntheticMedia)();
436
+ const stream = options.media ? new MediaStream(options.media.getTracks().map((t) => t.clone())) : synthetic.stream;
437
+ try {
438
+ return await (0, preflight_js_1.runPreflight)({
439
+ connect: () => client.connect(),
440
+ publish: (media) => client.publish(media),
441
+ sendState: () => client.sendTransport?.connectionState ?? null,
442
+ sendStats: async () => (client.sendTransport ? (await client.sendTransport.getStats()).values() : null),
443
+ onWarning: (listener) => client.on('mediaWarning', (w) => listener(w.code, w.active)),
444
+ close: () => client.close(),
445
+ }, stream, options);
446
+ }
447
+ finally {
448
+ synthetic?.stop();
449
+ }
450
+ }
451
+ /**
452
+ * Warm the connection before `connect()` (LiveKit's prepareConnection): a HEAD request to the server, so the
453
+ * name lookup and the TLS handshake are done by the time the socket opens. Call it as the user heads for the
454
+ * room (opening the room screen, say). Never rejects; it only saves time.
455
+ */
456
+ static async prepareConnection(wsUrl) {
457
+ try {
458
+ const url = new URL(wsUrl);
459
+ url.protocol = url.protocol === 'ws:' ? 'http:' : 'https:';
460
+ url.pathname = '/';
461
+ url.search = '';
462
+ await fetch(url.toString(), { method: 'HEAD', mode: 'no-cors', cache: 'no-store' });
463
+ }
464
+ catch {
465
+ // Only a warm-up: connect() does the real work either way.
466
+ }
467
+ }
254
468
  constructor(wsUrl, token, options = {}) {
255
469
  this.wsUrl = wsUrl;
256
470
  this.options = options;
257
471
  this.token = token;
258
472
  this.tokenExpiry = (0, token_js_1.tokenExpiresAt)(token);
473
+ this.audioOnlyEnabled = options.audioOnlyFallback !== false;
259
474
  }
260
475
  on(event, fn) {
261
476
  let set = this.listeners.get(event);
@@ -266,16 +481,28 @@ class StreamClient {
266
481
  set.add(fn);
267
482
  // videoLimited and networkQuality cost a getStats every few seconds, so
268
483
  // they run only while heard.
269
- if (event === 'videoLimited')
484
+ if (event === 'videoLimited' || event === 'cpuConstrained')
270
485
  this.syncLimitWatch();
271
486
  if (event === 'networkQuality')
272
487
  this.syncNetworkWatch();
488
+ if (event === 'devicesChanged')
489
+ this.syncDeviceWatch();
490
+ if (event === 'audioLevels' || event === 'activeSpeakers')
491
+ this.syncLevelWatch();
492
+ if (event === 'mediaWarning')
493
+ this.syncHealthWatch();
273
494
  return () => {
274
495
  set.delete(fn);
275
- if (event === 'videoLimited')
496
+ if (event === 'videoLimited' || event === 'cpuConstrained')
276
497
  this.syncLimitWatch();
277
498
  if (event === 'networkQuality')
278
499
  this.syncNetworkWatch();
500
+ if (event === 'devicesChanged')
501
+ this.syncDeviceWatch();
502
+ if (event === 'audioLevels' || event === 'activeSpeakers')
503
+ this.syncLevelWatch();
504
+ if (event === 'mediaWarning')
505
+ this.syncHealthWatch();
279
506
  };
280
507
  }
281
508
  /** Where the session stands: idle, connecting, connected, reconnecting or closed. */
@@ -289,6 +516,10 @@ class StreamClient {
289
516
  this.state = next;
290
517
  if (next === 'closed')
291
518
  this.stopTimers();
519
+ else {
520
+ this.syncLevelWatch();
521
+ this.syncHealthWatch();
522
+ }
292
523
  this.emit('connectionStateChanged', { state: next, previous });
293
524
  }
294
525
  /** Over for good: the state, then the event that says why. */
@@ -310,6 +541,20 @@ class StreamClient {
310
541
  this.close();
311
542
  }
312
543
  stopTimers() {
544
+ if (this.publishProtectionTimer)
545
+ clearInterval(this.publishProtectionTimer);
546
+ this.publishProtectionTimer = null;
547
+ this.autoplayBlocked.clear();
548
+ this.watchGesture(false);
549
+ this.stopProtection();
550
+ this.stopLevelWatch();
551
+ this.stopHealthWatch(false);
552
+ this.stopDeviceWatch();
553
+ this.socketLiveness.stop();
554
+ this.mediaPaths.reset();
555
+ this.backoffWake?.();
556
+ this.onlineTarget?.removeEventListener('online', this.online);
557
+ this.onlineTarget = null;
313
558
  if (this.tokenTimer)
314
559
  clearTimeout(this.tokenTimer);
315
560
  this.tokenTimer = null;
@@ -319,6 +564,7 @@ class StreamClient {
319
564
  return this.listeners.get(event)?.size ?? 0;
320
565
  }
321
566
  emit(event, ...args) {
567
+ this.diagnosticsLog.record(event, args);
322
568
  for (const fn of this.listeners.get(event) ?? []) {
323
569
  try {
324
570
  fn(...args);
@@ -374,6 +620,11 @@ class StreamClient {
374
620
  this.joinedOnce = true;
375
621
  this.skippedVideos.clear();
376
622
  this.setState('connected');
623
+ this.socketLiveness.start();
624
+ if (!this.onlineTarget && typeof window !== 'undefined' && window.addEventListener) {
625
+ this.onlineTarget = window;
626
+ window.addEventListener('online', this.online);
627
+ }
377
628
  this.scheduleTokenExpiring();
378
629
  // A role or mute can change while the socket is away. The fresh roster
379
630
  // decides what may return; reconnecting never turns a blocked device on.
@@ -405,12 +656,13 @@ class StreamClient {
405
656
  this.remoteVideoLayers.delete(producerId);
406
657
  // Subscribe to everything already publishing before we announce readiness.
407
658
  // A close() part-way stops it: what is left would only fail, after closed.
408
- for (const p of result.producers) {
409
- if (closed())
410
- throw closedWhileConnecting();
411
- await this.consume(p).catch((err) => { if (!closed())
412
- this.emit('error', err); });
413
- }
659
+ // All at once, as newProducer events already are (and the Flutter SDK does): each subscription reserves its slot
660
+ // before its first await, in this order, and the receive transport is made once. A room with six publishers
661
+ // starts six videos together instead of one after another, two round trips each.
662
+ if (closed())
663
+ throw closedWhileConnecting();
664
+ await Promise.all(result.producers.map((p) => this.consume(p).catch((err) => { if (!closed())
665
+ this.emit('error', err); })));
414
666
  // Restore our own broadcast. This is what makes an origin failover
415
667
  // survivable for the host: it reconnects onto the new origin and the
416
668
  // stream comes back on its own.
@@ -421,6 +673,9 @@ class StreamClient {
421
673
  if (closed())
422
674
  throw closedWhileConnecting();
423
675
  this.syncNetworkWatch();
676
+ this.syncProtection();
677
+ this.syncLevelWatch();
678
+ this.syncPublishProtection();
424
679
  return result;
425
680
  }
426
681
  /**
@@ -460,14 +715,18 @@ class StreamClient {
460
715
  ws.addEventListener('open', onOpen, { once: true });
461
716
  ws.addEventListener('error', onError, { once: true });
462
717
  ws.addEventListener('message', (ev) => {
463
- if (this.ws === ws)
718
+ if (this.ws === ws) {
719
+ this.socketLiveness.received();
464
720
  this.onMessage(ev.data);
721
+ }
465
722
  });
466
723
  ws.addEventListener('close', (ev) => {
467
724
  ws.removeEventListener('error', onError);
468
725
  // Only the socket we currently consider ours may drive reconnection.
469
726
  if (this.ws !== ws)
470
727
  return;
728
+ this.socketLiveness.stop();
729
+ this.mediaPaths.reset();
471
730
  this.ws = null;
472
731
  this.failAllPending(new errors_js_1.RpcError('not-connected', `socket closed (${ev.code})`));
473
732
  this.emit('disconnected', ev.reason || String(ev.code));
@@ -500,6 +759,7 @@ class StreamClient {
500
759
  });
501
760
  }
502
761
  detachSocket(reason = 'superseded', why = 'the connection was replaced') {
762
+ this.socketLiveness.stop();
503
763
  clearTimeout(this.openDeadline);
504
764
  const ws = this.ws;
505
765
  this.ws = null;
@@ -550,7 +810,7 @@ class StreamClient {
550
810
  return;
551
811
  }
552
812
  const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
553
- await sleep(backoff + Math.random() * 400);
813
+ await this.waitBackoff(backoff + Math.random() * 400);
554
814
  if (this.closedByUs)
555
815
  return;
556
816
  try {
@@ -586,6 +846,9 @@ class StreamClient {
586
846
  }
587
847
  close() {
588
848
  void this.stopRecording().catch(() => undefined);
849
+ // Adaptive stream stops watching elements: nothing is received any more.
850
+ this.adaptive.dispose();
851
+ this.adaptivePaused.clear();
589
852
  // Only an app closing a live session is 'left'; kicked, room-closed and the
590
853
  // others have already reported themselves and set closedByUs.
591
854
  const wasOpen = !this.closedByUs;
@@ -619,7 +882,15 @@ class StreamClient {
619
882
  this.emit('closed', 'left');
620
883
  }
621
884
  teardownMedia() {
885
+ this.mediaPaths.reset();
886
+ // The videos go with their consumers; a reconnect decides afresh. What was reported held back is withdrawn, so an
887
+ // app's "poor connection" overlay does not outlive it.
888
+ this.clearFallbacks();
622
889
  this.subscriptions.clear();
890
+ // Producers made after this are new: the server says again what their viewers need.
891
+ this.layersNeeded.clear();
892
+ // New consumers start paused at the server; adaptive stream decides again for each.
893
+ this.adaptivePaused.clear();
623
894
  for (const c of this.consumers.values())
624
895
  c.close();
625
896
  this.consumers.clear();
@@ -839,6 +1110,35 @@ class StreamClient {
839
1110
  case 'newProducer':
840
1111
  await this.consume(data).catch((err) => this.emit('error', err));
841
1112
  break;
1113
+ case 'streamFallback': {
1114
+ // The media server cut (or resumed) one of this viewer's videos for its bandwidth.
1115
+ const e = data;
1116
+ if (typeof e.producerId !== 'string' || !this.consumers.has(e.producerId))
1117
+ break;
1118
+ const cut = e.fallback === true;
1119
+ if (cut === this.bandwidthCut.has(e.producerId))
1120
+ break;
1121
+ if (cut)
1122
+ this.bandwidthCut.add(e.producerId);
1123
+ else
1124
+ this.bandwidthCut.delete(e.producerId);
1125
+ this.emitFallback(e.producerId, cut, 'bandwidth');
1126
+ break;
1127
+ }
1128
+ case 'producerLayers': {
1129
+ // Dynacast: what this client's viewers need from one of its producers. Layers nobody receives are not
1130
+ // encoded (the smallest always is, so a new viewer starts at once); null means every layer.
1131
+ const e = data;
1132
+ if (typeof e.producerId !== 'string')
1133
+ break;
1134
+ if (typeof e.spatialLayer === 'number' && Number.isInteger(e.spatialLayer))
1135
+ this.layersNeeded.set(e.producerId, e.spatialLayer);
1136
+ else
1137
+ this.layersNeeded.delete(e.producerId);
1138
+ if (this.cameraProducer()?.id === e.producerId)
1139
+ void this.cameraTask(() => this.syncCameraLayers());
1140
+ break;
1141
+ }
842
1142
  case 'producerClosed': {
843
1143
  const { producerId, identity } = data;
844
1144
  this.subscriptions.delete(producerId);
@@ -846,6 +1146,9 @@ class StreamClient {
846
1146
  this.receiveQualities.delete(producerId);
847
1147
  this.remoteVideoLayers.delete(producerId);
848
1148
  this.skippedVideos.delete(producerId);
1149
+ this.adaptivePaused.delete(producerId);
1150
+ this.bandwidthCut.delete(producerId);
1151
+ this.protectionHeld.delete(producerId);
849
1152
  const consumer = this.consumers.get(producerId);
850
1153
  consumer?.close();
851
1154
  this.consumers.delete(producerId);
@@ -901,6 +1204,9 @@ class StreamClient {
901
1204
  const e = data;
902
1205
  this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
903
1206
  this.emit('participantLeft', e);
1207
+ const speakers = this.speakers.remove(e.identity);
1208
+ if (speakers)
1209
+ this.emit('activeSpeakers', { speakers });
904
1210
  break;
905
1211
  }
906
1212
  case 'roleChanged': {
@@ -1048,16 +1354,21 @@ class StreamClient {
1048
1354
  return Promise.resolve(this.sendTransport);
1049
1355
  if (this.sendTransportPending)
1050
1356
  return this.sendTransportPending;
1051
- this.sendTransportPending = this.createSendTransport().finally(() => {
1052
- this.sendTransportPending = null;
1357
+ const pending = this.createSendTransport().finally(() => {
1358
+ if (this.sendTransportPending === pending)
1359
+ this.sendTransportPending = null;
1053
1360
  });
1054
- return this.sendTransportPending;
1361
+ return this.sendTransportPending = pending;
1055
1362
  }
1056
1363
  async createSendTransport() {
1057
1364
  if (!this.device)
1058
1365
  throw new errors_js_1.RpcError('not-connected', 'not connected');
1366
+ const device = this.device;
1367
+ const socket = this.ws;
1059
1368
  const params = (await this.rpc('createTransport', { direction: 'send' }));
1060
- const transport = this.device.createSendTransport({
1369
+ if (this.closedByUs || device !== this.device || socket !== this.ws)
1370
+ throw closedWhileConnecting();
1371
+ const transport = device.createSendTransport({
1061
1372
  ...params,
1062
1373
  iceServers: this.joinResult?.iceServers,
1063
1374
  });
@@ -1075,6 +1386,7 @@ class StreamClient {
1075
1386
  .catch(fail);
1076
1387
  });
1077
1388
  this.sendTransport = transport;
1389
+ this.watchTransport(transport);
1078
1390
  return transport;
1079
1391
  }
1080
1392
  ensureRecvTransport() {
@@ -1082,16 +1394,21 @@ class StreamClient {
1082
1394
  return Promise.resolve(this.recvTransport);
1083
1395
  if (this.recvTransportPending)
1084
1396
  return this.recvTransportPending;
1085
- this.recvTransportPending = this.createRecvTransport().finally(() => {
1086
- this.recvTransportPending = null;
1397
+ const pending = this.createRecvTransport().finally(() => {
1398
+ if (this.recvTransportPending === pending)
1399
+ this.recvTransportPending = null;
1087
1400
  });
1088
- return this.recvTransportPending;
1401
+ return this.recvTransportPending = pending;
1089
1402
  }
1090
1403
  async createRecvTransport() {
1091
1404
  if (!this.device)
1092
1405
  throw new errors_js_1.RpcError('not-connected', 'not connected');
1406
+ const device = this.device;
1407
+ const socket = this.ws;
1093
1408
  const params = (await this.rpc('createTransport', { direction: 'recv' }));
1094
- const transport = this.device.createRecvTransport({
1409
+ if (this.closedByUs || device !== this.device || socket !== this.ws)
1410
+ throw closedWhileConnecting();
1411
+ const transport = device.createRecvTransport({
1095
1412
  ...params,
1096
1413
  iceServers: this.joinResult?.iceServers,
1097
1414
  });
@@ -1101,6 +1418,7 @@ class StreamClient {
1101
1418
  .catch(fail);
1102
1419
  });
1103
1420
  this.recvTransport = transport;
1421
+ this.watchTransport(transport);
1104
1422
  return transport;
1105
1423
  }
1106
1424
  /**
@@ -1266,6 +1584,7 @@ class StreamClient {
1266
1584
  appData: { source: 'mic', audioQuality: quality },
1267
1585
  });
1268
1586
  await this.keepProducer(producer, generation, 'microphone');
1587
+ this.watchTrackEnded(producer);
1269
1588
  this.micQuality = quality;
1270
1589
  // A microphone handed over switched off, or muted before a reconnect.
1271
1590
  await this.pauseOnServer(producer);
@@ -1412,6 +1731,7 @@ class StreamClient {
1412
1731
  if (this.cameraEncodings && (0, quality_js_1.sameLayout)(this.cameraEncodings, next) &&
1413
1732
  previousSize?.width === width && previousSize?.height === height) {
1414
1733
  await current.replaceTrack({ track });
1734
+ this.watchTrackEnded(current);
1415
1735
  await this.applyLayerBitrates(current, next);
1416
1736
  return;
1417
1737
  }
@@ -1515,7 +1835,9 @@ class StreamClient {
1515
1835
  this.receiveQualities.delete(producerId);
1516
1836
  // Kept first: a reconnect that interrupts the request still applies it.
1517
1837
  this.preferredLayers.set(producerId, layers);
1518
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
1838
+ // Shown through attachVideo: the lower of this and the size it is shown at.
1839
+ const adaptive = this.adaptive.current(producerId);
1840
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
1519
1841
  }
1520
1842
  async collectVideoStats(baseline) {
1521
1843
  const producer = this.cameraProducer();
@@ -1554,7 +1876,8 @@ class StreamClient {
1554
1876
  }
1555
1877
  this.receiveQualities.set(producerId, quality);
1556
1878
  this.preferredLayers.set(producerId, layers);
1557
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
1879
+ const adaptive = this.adaptive.current(producerId);
1880
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
1558
1881
  }
1559
1882
  cameraProducer() {
1560
1883
  for (const p of this.producers.values()) {
@@ -1646,14 +1969,65 @@ class StreamClient {
1646
1969
  // unpublish() may have run while this was being made (a publish, a
1647
1970
  // quality change or a track swap under way).
1648
1971
  await this.keepProducer(producer, generation, 'camera');
1972
+ this.watchTrackEnded(producer);
1973
+ this.syncPublishProtection();
1649
1974
  this.cameraEncodings = encodings;
1650
1975
  this.frameRateLowSamples = 0;
1651
1976
  this.frameRateWarmupSamples = 2;
1652
1977
  await this.pauseOnServer(producer);
1653
1978
  await this.applyDegradationPreference(producer);
1654
1979
  this.syncLimitWatch();
1980
+ // A new camera producer starts with every layer: put the performance and viewer limits back on it.
1981
+ await this.syncCameraLayers();
1655
1982
  return producer;
1656
1983
  }
1984
+ /**
1985
+ * Lighten this device's work while it is overloaded (LiveKit's
1986
+ * `prioritizePerformance`): the camera's largest simulcast layer stops being
1987
+ * encoded -- the most expensive part of publishing -- and viewers get the
1988
+ * next one. `false` brings it back. Call it on `cpuConstrained`, together
1989
+ * with your app's own savings (effects off, smaller remote videos). Kept for
1990
+ * later publishes and reconnects until changed. A camera sent as one layer
1991
+ * is left as it is.
1992
+ */
1993
+ async prioritizePerformance(on = true) {
1994
+ this.performanceFirst = on;
1995
+ await this.cameraTask(() => this.syncCameraLayers());
1996
+ }
1997
+ /** Whether `prioritizePerformance()` is in force. */
1998
+ get performancePrioritized() {
1999
+ return this.performanceFirst;
2000
+ }
2001
+ /**
2002
+ * The camera's active simulcast layers: every layer, except the top one while
2003
+ * performance comes first, and none above the highest any viewer needs
2004
+ * (dynacast; the smallest always stays on, so a new viewer starts at once).
2005
+ */
2006
+ async syncCameraLayers() {
2007
+ const producer = this.cameraProducer();
2008
+ const count = this.cameraEncodings?.length ?? 0;
2009
+ if (!producer || producer.closed || count < 2)
2010
+ return;
2011
+ const top = count - 1;
2012
+ let max = top;
2013
+ if (this.performanceFirst)
2014
+ max = Math.min(max, top - 1);
2015
+ const needed = this.layersNeeded.get(producer.id);
2016
+ if (needed !== undefined)
2017
+ max = Math.min(max, Math.max(0, needed));
2018
+ // A producer just made sends every layer: nothing to ask of it until a limit applies.
2019
+ const applied = this.cameraMaxLayer?.producerId === producer.id ? this.cameraMaxLayer.layer : top;
2020
+ if (applied === max)
2021
+ return;
2022
+ try {
2023
+ await producer.setMaxSpatialLayer(max);
2024
+ this.cameraMaxLayer = { producerId: producer.id, layer: max };
2025
+ }
2026
+ catch (err) {
2027
+ if (!producer.closed)
2028
+ this.emit('error', err);
2029
+ }
2030
+ }
1657
2031
  /**
1658
2032
  * Keep a producer just made -- unless unpublish() ran while it was being
1659
2033
  * made. Kept, it would publish again what was just unpublished, so it is
@@ -1728,7 +2102,7 @@ class StreamClient {
1728
2102
  }
1729
2103
  /** videoLimited sampling runs while a camera is published and someone listens. */
1730
2104
  syncLimitWatch() {
1731
- this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
2105
+ this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.listenerCount('cpuConstrained') > 0 || this.cameraFrameRate === 60));
1732
2106
  }
1733
2107
  /**
1734
2108
  * Re-establish this client's broadcast after a reconnect.
@@ -2188,6 +2562,8 @@ class StreamClient {
2188
2562
  const consumer = await transport.consume(params);
2189
2563
  current(consumer);
2190
2564
  this.consumers.set(remote.producerId, consumer);
2565
+ if (this.playoutDelayMs !== null)
2566
+ this.applyPlayoutDelay(consumer.rtpReceiver);
2191
2567
  this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
2192
2568
  // Layers asked for before a reconnect: the new consumer gets them before
2193
2569
  // its first frame instead of starting at the full picture. Not getting
@@ -2203,13 +2579,27 @@ class StreamClient {
2203
2579
  this.emit('error', new errors_js_1.RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
2204
2580
  }
2205
2581
  }
2582
+ // Adaptive stream: a video the app shows through attachVideo starts at the
2583
+ // size it is shown, and one nobody can see stays paused.
2584
+ const adaptive = consumer.kind === 'video' ? this.adaptive.current(remote.producerId) : null;
2585
+ if (adaptive)
2586
+ layers = this.effectiveLayers(remote.producerId, adaptive);
2587
+ // A camera arriving while the audio-only fallback holds cameras back starts held back too.
2588
+ const held = consumer.kind === 'video' && this.protection.active && (remote.source ?? 'camera') === 'camera';
2206
2589
  if (layers) {
2207
2590
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
2208
2591
  }
2209
2592
  current(consumer);
2210
2593
  // The server created it paused. Resume only now that our end exists,
2211
2594
  // otherwise the first frames arrive before there is anything to decode them.
2212
- await this.rpc('resumeConsumer', { consumerId: consumer.id });
2595
+ this.adaptivePaused.delete(remote.producerId);
2596
+ if ((adaptive && !adaptive.visible) || (held && !this.pauseUnsupported))
2597
+ this.adaptivePaused.add(remote.producerId);
2598
+ else
2599
+ await this.rpc('resumeConsumer', { consumerId: consumer.id });
2600
+ if (held && this.pauseUnsupported) {
2601
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, spatialLayer: 0, temporalLayer: 0 }).catch(() => undefined);
2602
+ }
2213
2603
  current(consumer);
2214
2604
  const kind = consumer.kind;
2215
2605
  const source = remote.source ?? defaultSource(kind);
@@ -2222,6 +2612,681 @@ class StreamClient {
2222
2612
  producerId: remote.producerId,
2223
2613
  source,
2224
2614
  });
2615
+ if (kind === 'video')
2616
+ this.adaptive.consumed(remote.producerId);
2617
+ if (held) {
2618
+ this.protectionHeld.add(remote.producerId);
2619
+ this.emitFallback(remote.producerId, true, 'audio-protection');
2620
+ }
2621
+ this.syncProtection();
2622
+ }
2623
+ /**
2624
+ * Adaptive stream (like LiveKit's adaptiveStream and Tencent TRTC's small
2625
+ * stream with receiveWhenViewVisible): tell the SDK which element shows a
2626
+ * remote video. It then receives the smallest layer that fills that element
2627
+ * sharply (its size on screen × devicePixelRatio, at most 2) and pauses the
2628
+ * video at the server while the element is not visible (scrolled away,
2629
+ * `display: none`, or the tab in the background), so a grid of small tiles
2630
+ * stops decoding full-size video. Attach the same video to several elements
2631
+ * and the largest visible one decides. Returns the function that detaches
2632
+ * it; detach when the element goes away. A ceiling set with
2633
+ * `setReceiveQuality` or `setPreferredLayers` still applies. Works with any
2634
+ * server; one without `pauseConsumer` gets the smallest layer instead of a
2635
+ * pause.
2636
+ */
2637
+ attachVideo(producerId, element) {
2638
+ return this.adaptive.attach(producerId, element);
2639
+ }
2640
+ /** The layers to ask for: the app's ceiling (setReceiveQuality / setPreferredLayers) and adaptive stream's, the lower. */
2641
+ effectiveLayers(producerId, want) {
2642
+ let manual = this.preferredLayers.get(producerId);
2643
+ const quality = this.receiveQualities.get(producerId);
2644
+ if (quality && quality !== 'auto') {
2645
+ try {
2646
+ manual = { spatialLayer: (0, quality_js_1.receiveSpatialLayer)(this.remoteVideoLayers.get(producerId) ?? [], quality) };
2647
+ }
2648
+ catch {
2649
+ manual = undefined;
2650
+ }
2651
+ }
2652
+ const adaptive = want?.visible ? want.spatialLayer : null;
2653
+ if (adaptive === null || adaptive === undefined)
2654
+ return manual ?? { spatialLayer: -1, temporalLayer: -1 };
2655
+ if (!manual || manual.spatialLayer < 0)
2656
+ return { spatialLayer: adaptive };
2657
+ return { ...manual, spatialLayer: Math.min(manual.spatialLayer, adaptive) };
2658
+ }
2659
+ // ------------------------------------------------------- diagnostics
2660
+ /**
2661
+ * A snapshot for a support ticket (what Agora's log upload and Twilio's Insights are used for): the SDK version and
2662
+ * browser, the session (room, identity, role, state), how each media transport is connected (UDP, TCP or relayed),
2663
+ * the round trip and upload now, what is published and received, and the last 200 notable events (state changes,
2664
+ * errors, recoveries, fallbacks, warnings) with their times. Plain JSON: `JSON.stringify` it and attach it. Holds
2665
+ * no tokens and no media.
2666
+ */
2667
+ async getDiagnostics() {
2668
+ const transport = async (t) => {
2669
+ if (!t || t.closed)
2670
+ return null;
2671
+ let path = null;
2672
+ try {
2673
+ path = (0, preflight_js_1.connectionPath)((await t.getStats()).values());
2674
+ }
2675
+ catch {
2676
+ // Best effort.
2677
+ }
2678
+ return { state: t.connectionState, path };
2679
+ };
2680
+ let network = null;
2681
+ try {
2682
+ const reports = await this.transportStats();
2683
+ const current = new Map([...reports].map(([key, report]) => [key, (0, network_js_1.readTransportStats)(report)]));
2684
+ if (current.size > 0) {
2685
+ const q = (0, network_js_1.networkQualityOf)(current, new Map(), this.producers.size > 0);
2686
+ network = { rttMs: q.rttMs, availableKbps: q.availableKbps };
2687
+ }
2688
+ }
2689
+ catch {
2690
+ // Best effort.
2691
+ }
2692
+ const join = this.joinResult;
2693
+ return {
2694
+ sdk: diagnostics_js_1.SDK_VERSION,
2695
+ at: Date.now(),
2696
+ userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
2697
+ connectionState: this.state,
2698
+ room: join?.roomId ?? null,
2699
+ identity: join?.identity ?? null,
2700
+ role: join?.role ?? null,
2701
+ transports: { send: await transport(this.sendTransport), recv: await transport(this.recvTransport) },
2702
+ network,
2703
+ published: [...this.producers.values()].filter((p) => !p.closed)
2704
+ .map((p) => ({ source: String(p.appData.source ?? p.kind), kind: p.kind, paused: p.paused })),
2705
+ received: [...this.consumers.entries()].filter(([, c]) => !c.closed).map(([producerId, c]) => ({
2706
+ identity: this.remoteInfo.get(producerId)?.identity ?? '',
2707
+ source: this.remoteSources.get(producerId) ?? (c.kind === 'audio' ? 'mic' : 'camera'),
2708
+ kind: c.kind,
2709
+ paused: c.paused || this.adaptivePaused.has(producerId),
2710
+ })),
2711
+ log: this.diagnosticsLog.list(),
2712
+ };
2713
+ }
2714
+ // ------------------------------------------------------- autoplay
2715
+ /**
2716
+ * Play a `<video>` or `<audio>` showing a remote track, the way autoplay rules allow (Agora's and Tencent TRTC's
2717
+ * autoplay handling). Resolves true when it plays. When the browser refuses until the viewer interacts with the
2718
+ * page, resolves false, emits `autoplayFailed` with a `resume()` for a "Tap to unmute" button, and plays it -- with
2719
+ * every other refused element -- on the next tap, click or key press by itself.
2720
+ */
2721
+ async play(element) {
2722
+ try {
2723
+ await element.play();
2724
+ this.autoplayBlocked.delete(element);
2725
+ return true;
2726
+ }
2727
+ catch (err) {
2728
+ if (err?.name !== 'NotAllowedError')
2729
+ throw err;
2730
+ this.autoplayBlocked.add(element);
2731
+ this.watchGesture(true);
2732
+ this.emit('autoplayFailed', { element, resume: async () => { await this.play(element); } });
2733
+ return false;
2734
+ }
2735
+ }
2736
+ watchGesture(on) {
2737
+ if (typeof document === 'undefined' || on === this.gestureWatchOn)
2738
+ return;
2739
+ this.gestureWatchOn = on;
2740
+ for (const type of ['pointerdown', 'keydown', 'touchend']) {
2741
+ if (on)
2742
+ document.addEventListener(type, this.onGesture, { capture: true });
2743
+ else
2744
+ document.removeEventListener(type, this.onGesture, { capture: true });
2745
+ }
2746
+ }
2747
+ async retryAutoplay() {
2748
+ for (const element of [...this.autoplayBlocked]) {
2749
+ try {
2750
+ await element.play();
2751
+ this.autoplayBlocked.delete(element);
2752
+ }
2753
+ catch {
2754
+ // Still refused (or the element went away); the next gesture tries again.
2755
+ if (!element.isConnected)
2756
+ this.autoplayBlocked.delete(element);
2757
+ }
2758
+ }
2759
+ if (this.autoplayBlocked.size === 0)
2760
+ this.watchGesture(false);
2761
+ }
2762
+ // ------------------------------------------------------- playout delay
2763
+ /**
2764
+ * Trade a little delay for smoother playback (Tencent TRTC's playoutDelay): every remote track is buffered about
2765
+ * `ms` before it plays, which rides out the jitter of a mobile network without freezes. For an audience watching a
2766
+ * stream, 300-500 ms is a good value; people talking with each other want `null` (the browser's own, as small as
2767
+ * it can keep). Applies to what is received now and later. Browsers without the setting (before Chrome 114) keep
2768
+ * their own; 0-4000 ms.
2769
+ */
2770
+ setPlayoutDelay(ms) {
2771
+ this.playoutDelayMs = ms === null ? null : Math.min(4000, Math.max(0, Math.round(ms)));
2772
+ for (const consumer of this.consumers.values())
2773
+ this.applyPlayoutDelay(consumer.rtpReceiver);
2774
+ }
2775
+ applyPlayoutDelay(receiver) {
2776
+ if (!receiver || !('jitterBufferTarget' in receiver))
2777
+ return;
2778
+ try {
2779
+ receiver.jitterBufferTarget = this.playoutDelayMs;
2780
+ }
2781
+ catch {
2782
+ // Out of the browser's range: it keeps its own.
2783
+ }
2784
+ }
2785
+ // ------------------------------------------------------- audio levels
2786
+ /** Levels are read only while someone listens and the session is connected. */
2787
+ syncLevelWatch() {
2788
+ const wanted = !this.closedByUs && this.state === 'connected' &&
2789
+ (this.listenerCount('audioLevels') > 0 || this.listenerCount('activeSpeakers') > 0);
2790
+ if (wanted && !this.levelTimer) {
2791
+ this.levelTimer = setInterval(() => void this.sampleAudioLevels(), speakers_js_1.AUDIO_LEVEL_INTERVAL_MS);
2792
+ this.levelTimer.unref?.();
2793
+ }
2794
+ else if (!wanted) {
2795
+ this.stopLevelWatch();
2796
+ }
2797
+ }
2798
+ stopLevelWatch() {
2799
+ if (this.levelTimer)
2800
+ clearInterval(this.levelTimer);
2801
+ this.levelTimer = null;
2802
+ this.speakers.reset();
2803
+ }
2804
+ /**
2805
+ * One reading: each remote audio from its receiver's synchronization sources (the most recent packet's level, no
2806
+ * statistics report needed), the own microphone from its sender's media-source statistics.
2807
+ */
2808
+ async sampleAudioLevels() {
2809
+ if (this.levelSampling)
2810
+ return;
2811
+ this.levelSampling = true;
2812
+ try {
2813
+ const levels = [];
2814
+ for (const [producerId, consumer] of this.consumers) {
2815
+ if (consumer.kind !== 'audio' || consumer.closed)
2816
+ continue;
2817
+ const info = this.remoteInfo.get(producerId);
2818
+ if (!info)
2819
+ continue;
2820
+ const level = info.paused ? 0 : receiverLevel(consumer.rtpReceiver);
2821
+ levels.push({ identity: info.identity, producerId, source: this.remoteSources.get(producerId) ?? 'mic', level, speaking: level > speakers_js_1.SPEAKING_LEVEL, local: false });
2822
+ }
2823
+ const mic = this.micProducer();
2824
+ const self = this.joinResult?.identity;
2825
+ if (mic && !mic.paused && self) {
2826
+ const level = await senderLevel(mic);
2827
+ levels.push({ identity: self, producerId: mic.id, source: 'mic', level, speaking: level > speakers_js_1.SPEAKING_LEVEL, local: true });
2828
+ }
2829
+ if (!this.levelTimer)
2830
+ return;
2831
+ if (this.listenerCount('audioLevels') > 0)
2832
+ this.emit('audioLevels', levels);
2833
+ const loudest = new Map();
2834
+ for (const l of levels)
2835
+ if (l.source === 'mic')
2836
+ loudest.set(l.identity, Math.max(loudest.get(l.identity) ?? 0, l.level));
2837
+ const speakers = this.speakers.update(loudest);
2838
+ if (speakers)
2839
+ this.emit('activeSpeakers', { speakers });
2840
+ }
2841
+ catch {
2842
+ // Best effort; the next reading tries again.
2843
+ }
2844
+ finally {
2845
+ this.levelSampling = false;
2846
+ }
2847
+ }
2848
+ // ------------------------------------------------------- media health
2849
+ /** The published devices are sampled only while someone listens and the session is connected. */
2850
+ syncHealthWatch() {
2851
+ const wanted = !this.closedByUs && this.state === 'connected' && this.listenerCount('mediaWarning') > 0;
2852
+ if (wanted && !this.healthTimer) {
2853
+ this.healthTimer = setInterval(() => void this.sampleHealth(), health_js_1.HEALTH_SAMPLE_MS);
2854
+ this.healthTimer.unref?.();
2855
+ }
2856
+ else if (!wanted) {
2857
+ this.stopHealthWatch(this.listenerCount('mediaWarning') > 0 && !this.closedByUs);
2858
+ }
2859
+ }
2860
+ /** Stop sampling; `report` says each active warning cleared (nothing is said after close). */
2861
+ stopHealthWatch(report) {
2862
+ if (this.healthTimer)
2863
+ clearInterval(this.healthTimer);
2864
+ this.healthTimer = null;
2865
+ for (const timer of this.interruptions.values())
2866
+ clearTimeout(timer);
2867
+ this.interruptions.clear();
2868
+ if (report)
2869
+ this.health.clearAll();
2870
+ }
2871
+ async sampleHealth() {
2872
+ if (this.healthSampling)
2873
+ return;
2874
+ this.healthSampling = true;
2875
+ try {
2876
+ const mic = this.micProducer();
2877
+ const camera = this.cameraProducer();
2878
+ const micLevel = mic && !mic.paused ? (0, health_js_1.readMediaSource)((await mic.getStats()).values()).level : null;
2879
+ const cameraFps = camera && !camera.paused ? (0, health_js_1.readMediaSource)((await camera.getStats()).values()).fps : null;
2880
+ if (!this.healthTimer)
2881
+ return;
2882
+ this.health.feed({ micLevel, cameraFps, askedFps: this.videoFrameRate });
2883
+ // An interrupted device that is no longer published never says it came back: that is over too.
2884
+ if (!mic)
2885
+ this.health.interrupted('mic-interrupted', false);
2886
+ if (!camera)
2887
+ this.health.interrupted('camera-interrupted', false);
2888
+ }
2889
+ catch {
2890
+ // Best effort; the next sample tries again.
2891
+ }
2892
+ finally {
2893
+ this.healthSampling = false;
2894
+ }
2895
+ }
2896
+ // ---------------------------------------------------------------- devices
2897
+ /**
2898
+ * The cameras, microphones and speakers the browser offers, optionally of one kind. Labels are empty until the
2899
+ * page has been granted camera or microphone access.
2900
+ */
2901
+ listDevices(kind) {
2902
+ return (0, devices_js_1.enumerate)(kind);
2903
+ }
2904
+ /**
2905
+ * Publish from another camera or microphone (LiveKit's switchActiveDevice, Agora's setDevice). The device is
2906
+ * captured with the constraints publish was given (size, frame rate, echo cancellation) and its track replaces the
2907
+ * published one: a camera of the same size swaps with no pause for viewers, a microphone always does. The track it
2908
+ * replaces is stopped, releasing that device; the client's own stream follows (`localStreamChanged`), and a
2909
+ * reconnect captures the chosen device again. A camera or microphone that is switched off stays off.
2910
+ *
2911
+ * For a camera published as a canvas or another processed track, capture the new camera yourself and pass your
2912
+ * processed track to `replaceVideoTrack`; this rejects rather than replace your picture with the raw camera.
2913
+ * Rejects when that kind is not being published.
2914
+ */
2915
+ async switchDevice(kind, deviceId) {
2916
+ const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
2917
+ if (!md?.getUserMedia)
2918
+ throw new Error('switchDevice: this browser cannot capture media');
2919
+ if (kind === 'videoinput') {
2920
+ const current = this.cameraProducer()?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
2921
+ if (!current)
2922
+ throw new Error('switchDevice: no camera is being published');
2923
+ if (!(0, devices_js_1.deviceIdOf)(current)) {
2924
+ throw new Error('switchDevice: the published video is not a camera capture (a canvas or processed track); capture the camera yourself and call replaceVideoTrack');
2925
+ }
2926
+ const video = (0, devices_js_1.constraintsFor)(this.publishConstraints?.video, deviceId);
2927
+ const track = (await md.getUserMedia({ video })).getVideoTracks()[0];
2928
+ if (!track)
2929
+ throw new Error('switchDevice: the camera gave no video');
2930
+ try {
2931
+ await this.replaceVideoTrack(track);
2932
+ }
2933
+ catch (err) {
2934
+ track.stop();
2935
+ throw err;
2936
+ }
2937
+ if (current !== track)
2938
+ current.stop();
2939
+ this.publishConstraints = { ...(this.publishConstraints ?? {}), video };
2940
+ return;
2941
+ }
2942
+ await this.micTask(async () => {
2943
+ const producer = this.micProducer();
2944
+ if (!producer)
2945
+ throw new Error('switchDevice: no microphone is being published');
2946
+ const audio = (0, devices_js_1.constraintsFor)(this.publishConstraints?.audio, deviceId);
2947
+ const track = (await md.getUserMedia({ audio })).getAudioTracks()[0];
2948
+ if (!track)
2949
+ throw new Error('switchDevice: the microphone gave no audio');
2950
+ if (producer.closed || this.micProducer() !== producer) {
2951
+ track.stop();
2952
+ throw new Error('switchDevice: the microphone was unpublished meanwhile');
2953
+ }
2954
+ await this.adoptAudioTrack(producer, track);
2955
+ this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
2956
+ });
2957
+ }
2958
+ /** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
2959
+ async adoptAudioTrack(producer, track) {
2960
+ const previous = producer.track;
2961
+ await producer.replaceTrack({ track });
2962
+ const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
2963
+ this.localStream = new MediaStream([track, ...video]);
2964
+ this.emit('localStreamChanged', this.localStream);
2965
+ if (previous && previous !== track)
2966
+ previous.stop();
2967
+ this.watchTrackEnded(producer);
2968
+ }
2969
+ /** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
2970
+ watchTrackEnded(producer) {
2971
+ const track = producer.track;
2972
+ if (!track || typeof track.addEventListener !== 'function')
2973
+ return;
2974
+ track.addEventListener('ended', () => { void this.onTrackEnded(producer, track); }, { once: true });
2975
+ // The system taking the device (a call, another app) mutes the track; held a moment, it is reported.
2976
+ const which = producer.appData.source === 'mic' ? 'mic' : producer.appData.source === 'camera' ? 'camera' : null;
2977
+ if (!which)
2978
+ return;
2979
+ const code = which === 'mic' ? 'mic-interrupted' : 'camera-interrupted';
2980
+ const current = () => !this.closedByUs && !producer.closed && producer.track === track;
2981
+ track.addEventListener('mute', () => {
2982
+ if (!current() || this.interruptions.has(which))
2983
+ return;
2984
+ this.interruptions.set(which, setTimeout(() => {
2985
+ this.interruptions.delete(which);
2986
+ if (current() && track.muted)
2987
+ this.health.interrupted(code, true);
2988
+ }, health_js_1.INTERRUPTED_AFTER_MS));
2989
+ });
2990
+ track.addEventListener('unmute', () => {
2991
+ const timer = this.interruptions.get(which);
2992
+ if (timer)
2993
+ clearTimeout(timer);
2994
+ this.interruptions.delete(which);
2995
+ if (current())
2996
+ this.health.interrupted(code, false);
2997
+ });
2998
+ }
2999
+ /**
3000
+ * A published camera or microphone stopped from outside. The microphone comes back from the system default (Chime
3001
+ * keeps audio flowing, LiveKit moves to the first available device); a camera is switched off and reported, never
3002
+ * swapped for another camera on its own (Chime stops it the same way).
3003
+ */
3004
+ async onTrackEnded(producer, track) {
3005
+ const live = () => !this.closedByUs && !producer.closed && producer.track === track && this.producers.get(producer.id) === producer;
3006
+ if (!live())
3007
+ return;
3008
+ const deviceId = (0, devices_js_1.deviceIdOf)(track);
3009
+ if (producer.appData.source === 'mic') {
3010
+ await this.micTask(async () => {
3011
+ if (!live())
3012
+ return;
3013
+ try {
3014
+ const md = navigator.mediaDevices;
3015
+ const audio = (0, devices_js_1.constraintsFor)(this.publishConstraints?.audio, null);
3016
+ const next = (await md.getUserMedia({ audio })).getAudioTracks()[0];
3017
+ if (!next)
3018
+ throw new Error('no microphone');
3019
+ if (!live()) {
3020
+ next.stop();
3021
+ return;
3022
+ }
3023
+ await this.adoptAudioTrack(producer, next);
3024
+ this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
3025
+ this.emit('localTrackEnded', { kind: 'audio', deviceId, recovered: true });
3026
+ }
3027
+ catch {
3028
+ if (!live())
3029
+ return;
3030
+ await this.setMicEnabled(false).catch(() => undefined);
3031
+ this.emit('localTrackEnded', { kind: 'audio', deviceId, recovered: false });
3032
+ }
3033
+ });
3034
+ return;
3035
+ }
3036
+ if (producer.appData.source === 'camera') {
3037
+ await this.setCamEnabled(false).catch(() => undefined);
3038
+ if (!this.closedByUs)
3039
+ this.emit('localTrackEnded', { kind: 'video', deviceId, recovered: false });
3040
+ }
3041
+ }
3042
+ /** devicesChanged is watched only while someone listens. */
3043
+ syncDeviceWatch() {
3044
+ const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
3045
+ const wanted = !this.closedByUs && this.listenerCount('devicesChanged') > 0 && typeof md?.addEventListener === 'function';
3046
+ if (wanted && !this.deviceWatchOn) {
3047
+ this.deviceWatchOn = true;
3048
+ md.addEventListener('devicechange', this.onDeviceChange);
3049
+ void (0, devices_js_1.enumerate)().then((list) => { if (this.deviceWatchOn)
3050
+ this.knownDevices = list; }).catch(() => undefined);
3051
+ }
3052
+ else if (!wanted) {
3053
+ this.stopDeviceWatch();
3054
+ }
3055
+ }
3056
+ stopDeviceWatch() {
3057
+ if (!this.deviceWatchOn)
3058
+ return;
3059
+ this.deviceWatchOn = false;
3060
+ navigator.mediaDevices?.removeEventListener?.('devicechange', this.onDeviceChange);
3061
+ }
3062
+ async refreshDevices() {
3063
+ let now;
3064
+ try {
3065
+ now = await (0, devices_js_1.enumerate)();
3066
+ }
3067
+ catch {
3068
+ return;
3069
+ }
3070
+ if (!this.deviceWatchOn)
3071
+ return;
3072
+ const { added, removed } = (0, devices_js_1.diffDevices)(this.knownDevices, now);
3073
+ this.knownDevices = now;
3074
+ if (added.length > 0 || removed.length > 0)
3075
+ this.emit('devicesChanged', { devices: now, added, removed });
3076
+ }
3077
+ /** Adaptive stream's decision for a video, with the audio-only fallback holding remote cameras back. */
3078
+ withFallback(producerId, want) {
3079
+ if (this.protection.active && this.remoteSources.get(producerId) === 'camera')
3080
+ return { visible: false, spatialLayer: null };
3081
+ return want;
3082
+ }
3083
+ /**
3084
+ * Audio-only fallback (like Agora's AUDIO_ONLY stream fallback): on unless `audioOnlyFallback: false` was given.
3085
+ * While this viewer's received sound breaks up badly -- a quarter of it lost over about 15 s -- remote camera
3086
+ * videos are paused at the server (screen shares stay) so what bandwidth there is goes to the audio; they come
3087
+ * back once it has recovered for a while, holding longer if it falls back again soon. Each held video is reported
3088
+ * by `streamFallback`. `false` brings any held video back now and stops watching.
3089
+ */
3090
+ setAudioOnlyFallback(enabled) {
3091
+ this.audioOnlyEnabled = enabled;
3092
+ if (!enabled)
3093
+ this.protection.release();
3094
+ this.syncProtection();
3095
+ }
3096
+ /**
3097
+ * The publisher side of the audio-only fallback (Agora's setLocalPublishFallbackOption(AUDIO_ONLY)), off by default:
3098
+ * while this client's own sound breaks up on the way to the server -- a quarter of it lost over about 15 s -- its
3099
+ * camera is paused (viewers see it paused) so the upload carries the voice, and resumed once it recovers, holding
3100
+ * longer after a relapse. Reported by `publishFallback`. A camera you or a moderator switch off meanwhile stays off.
3101
+ * `false` brings a held camera back now.
3102
+ */
3103
+ setPublishFallback(enabled) {
3104
+ this.publishFallbackEnabled = enabled;
3105
+ if (!enabled)
3106
+ this.publishProtection.release();
3107
+ this.syncPublishProtection();
3108
+ }
3109
+ syncPublishProtection() {
3110
+ const wanted = this.publishFallbackEnabled && !this.closedByUs && this.state === 'connected' &&
3111
+ this.micProducer() !== undefined && (this.cameraProducer() !== undefined || this.publishProtection.active);
3112
+ if (wanted && !this.publishProtectionTimer) {
3113
+ this.publishProtectionTimer = setInterval(() => void this.samplePublishProtection(), fallback_js_1.PROTECT_SAMPLE_MS);
3114
+ this.publishProtectionTimer.unref?.();
3115
+ }
3116
+ else if (!wanted && this.publishProtectionTimer) {
3117
+ clearInterval(this.publishProtectionTimer);
3118
+ this.publishProtectionTimer = null;
3119
+ }
3120
+ }
3121
+ async samplePublishProtection() {
3122
+ const transport = this.sendTransport;
3123
+ if (this.publishProtectionSampling || !transport || transport.closed)
3124
+ return;
3125
+ this.publishProtectionSampling = true;
3126
+ try {
3127
+ const report = await transport.getStats();
3128
+ if (transport !== this.sendTransport || !this.publishProtectionTimer)
3129
+ return;
3130
+ this.publishProtection.feed((0, fallback_js_1.readOutboundAudio)(report.values()));
3131
+ }
3132
+ catch {
3133
+ // Best effort.
3134
+ }
3135
+ finally {
3136
+ this.publishProtectionSampling = false;
3137
+ }
3138
+ }
3139
+ /** The publisher-side fallback decided: hold the camera, or bring it back if it is still ours to bring back. */
3140
+ async onPublishProtection(active) {
3141
+ const camera = this.cameraProducer();
3142
+ if (active) {
3143
+ if (!camera || camera.paused) {
3144
+ this.syncPublishProtection();
3145
+ return;
3146
+ }
3147
+ this.cameraHeldByFallback = true;
3148
+ camera.pause();
3149
+ this.emit('publishFallback', { fallback: true });
3150
+ await this.rpc('pauseProducer', { producerId: camera.id, paused: true }).catch(() => undefined);
3151
+ this.emitLocalMedia();
3152
+ }
3153
+ else if (this.cameraHeldByFallback) {
3154
+ this.cameraHeldByFallback = false;
3155
+ if (camera && camera.paused && !this.forcedOff.video) {
3156
+ camera.resume();
3157
+ await this.rpc('pauseProducer', { producerId: camera.id, paused: false }).catch(() => undefined);
3158
+ this.emitLocalMedia();
3159
+ }
3160
+ this.emit('publishFallback', { fallback: false });
3161
+ }
3162
+ this.syncPublishProtection();
3163
+ }
3164
+ /** Whether the audio-only fallback is holding remote cameras back now. */
3165
+ get audioOnlyFallbackActive() {
3166
+ return this.protection.active;
3167
+ }
3168
+ /** The audio-only fallback decided: hold remote cameras back, or let them come back. */
3169
+ onAudioProtection(active) {
3170
+ for (const [producerId, consumer] of this.consumers) {
3171
+ if (consumer.kind !== 'video' || this.remoteSources.get(producerId) !== 'camera')
3172
+ continue;
3173
+ const want = this.withFallback(producerId, this.adaptive.current(producerId) ?? { visible: true, spatialLayer: null });
3174
+ void this.applyAdaptive(producerId, want);
3175
+ if (active && !this.protectionHeld.has(producerId)) {
3176
+ this.protectionHeld.add(producerId);
3177
+ this.emitFallback(producerId, true, 'audio-protection');
3178
+ }
3179
+ else if (!active && this.protectionHeld.delete(producerId)) {
3180
+ this.emitFallback(producerId, false, 'audio-protection');
3181
+ }
3182
+ }
3183
+ this.syncProtection();
3184
+ }
3185
+ emitFallback(producerId, fallback, reason) {
3186
+ const info = this.remoteInfo.get(producerId);
3187
+ this.emit('streamFallback', {
3188
+ producerId,
3189
+ identity: info?.identity ?? '',
3190
+ source: this.remoteSources.get(producerId) ?? 'camera',
3191
+ fallback,
3192
+ reason,
3193
+ });
3194
+ }
3195
+ /** Withdraw every held-back report (teardown; nothing is said after close) and forget the decisions. */
3196
+ clearFallbacks() {
3197
+ if (!this.closedByUs) {
3198
+ for (const producerId of this.bandwidthCut)
3199
+ this.emitFallback(producerId, false, 'bandwidth');
3200
+ for (const producerId of this.protectionHeld)
3201
+ this.emitFallback(producerId, false, 'audio-protection');
3202
+ }
3203
+ this.bandwidthCut.clear();
3204
+ this.protectionHeld.clear();
3205
+ this.protection.reset();
3206
+ this.stopProtection();
3207
+ }
3208
+ /**
3209
+ * The received audio is sampled only while the fallback could matter: enabled, connected, with remote audio to
3210
+ * judge by and a remote camera to hold back (or one held back already).
3211
+ */
3212
+ syncProtection() {
3213
+ let audio = false;
3214
+ let camera = false;
3215
+ for (const [producerId, consumer] of this.consumers) {
3216
+ if (consumer.closed)
3217
+ continue;
3218
+ if (consumer.kind === 'audio')
3219
+ audio = true;
3220
+ else if (this.remoteSources.get(producerId) === 'camera')
3221
+ camera = true;
3222
+ }
3223
+ const wanted = this.audioOnlyEnabled && !this.closedByUs && this.state === 'connected' && audio && (camera || this.protection.active);
3224
+ if (wanted && !this.protectionTimer) {
3225
+ this.protectionTimer = setInterval(() => void this.sampleProtection(), fallback_js_1.PROTECT_SAMPLE_MS);
3226
+ // Background sampling never keeps a server-side or test process alive (Node); browsers have no unref.
3227
+ this.protectionTimer.unref?.();
3228
+ }
3229
+ else if (!wanted) {
3230
+ this.stopProtection();
3231
+ }
3232
+ }
3233
+ stopProtection() {
3234
+ if (this.protectionTimer)
3235
+ clearInterval(this.protectionTimer);
3236
+ this.protectionTimer = null;
3237
+ }
3238
+ async sampleProtection() {
3239
+ const transport = this.recvTransport;
3240
+ if (this.protectionSampling || !transport || transport.closed)
3241
+ return;
3242
+ this.protectionSampling = true;
3243
+ try {
3244
+ const report = await transport.getStats();
3245
+ if (transport !== this.recvTransport || !this.protectionTimer)
3246
+ return;
3247
+ this.protection.feed((0, fallback_js_1.readInboundAudio)(report.values()));
3248
+ }
3249
+ catch {
3250
+ // Statistics are best effort; the next sample tries again.
3251
+ }
3252
+ finally {
3253
+ this.protectionSampling = false;
3254
+ }
3255
+ }
3256
+ /** Apply adaptive stream's decision for one video: pause it, or resume it at its layer. */
3257
+ async applyAdaptive(producerId, want) {
3258
+ const consumer = this.consumers.get(producerId);
3259
+ // Not received (yet): a consumer made later starts from the decision in force.
3260
+ if (!consumer || consumer.kind !== 'video' || consumer.closed || this.closedByUs)
3261
+ return;
3262
+ try {
3263
+ if (!want.visible) {
3264
+ if (this.pauseUnsupported) {
3265
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, spatialLayer: 0, temporalLayer: 0 });
3266
+ return;
3267
+ }
3268
+ if (this.adaptivePaused.has(producerId))
3269
+ return;
3270
+ this.adaptivePaused.add(producerId);
3271
+ await this.rpc('pauseConsumer', { consumerId: consumer.id });
3272
+ return;
3273
+ }
3274
+ if (this.adaptivePaused.delete(producerId))
3275
+ await this.rpc('resumeConsumer', { consumerId: consumer.id });
3276
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...this.effectiveLayers(producerId, want) });
3277
+ }
3278
+ catch (err) {
3279
+ if (this.consumers.get(producerId) !== consumer || consumer.closed || this.closedByUs)
3280
+ return;
3281
+ if (!want.visible && err instanceof errors_js_1.RpcError && err.code === 'unknown-method') {
3282
+ // An older server: drop to the smallest layer instead of pausing.
3283
+ this.adaptivePaused.delete(producerId);
3284
+ this.pauseUnsupported = true;
3285
+ await this.applyAdaptive(producerId, want);
3286
+ return;
3287
+ }
3288
+ this.emit('error', err);
3289
+ }
2225
3290
  }
2226
3291
  // ------------------------------------------------------------- moderation
2227
3292
  // ------------------------------------------------------------ local media
@@ -2269,6 +3334,12 @@ class StreamClient {
2269
3334
  this.music?.setEnabled(false);
2270
3335
  }
2271
3336
  async setKindEnabled(kind, enabled) {
3337
+ // Your own choice about the camera ends the publisher-side fallback's hold on it: it never undoes your choice.
3338
+ if (kind === 'video' && this.cameraHeldByFallback) {
3339
+ this.cameraHeldByFallback = false;
3340
+ this.publishProtection.reset();
3341
+ this.emit('publishFallback', { fallback: false });
3342
+ }
2272
3343
  const generation = this.publishGeneration;
2273
3344
  // Unpublished or closed while waiting: nothing is left to switch, and the
2274
3345
  // call resolves, as it does with nothing published (V072-03).
@@ -2319,6 +3390,7 @@ class StreamClient {
2319
3390
  /** Stop publishing entirely and release the local devices. */
2320
3391
  async unpublish() {
2321
3392
  void this.stopRecording().catch(error => this.emit('error', error));
3393
+ this.layersNeeded.clear();
2322
3394
  // All of it takes effect now; only telling the server is waited for. So
2323
3395
  // publish() and unpublish() act in the order they are called: a publish
2324
3396
  // called while this still waits is not undone by it, and one already