@voltkit-rtc/client 0.7.3 → 0.9.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 (129) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +346 -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/effects-core.d.ts +95 -0
  16. package/dist/cjs/effects-core.d.ts.map +1 -0
  17. package/dist/cjs/effects-core.js +154 -0
  18. package/dist/cjs/effects-core.js.map +1 -0
  19. package/dist/cjs/effects-gl.d.ts +79 -0
  20. package/dist/cjs/effects-gl.d.ts.map +1 -0
  21. package/dist/cjs/effects-gl.js +351 -0
  22. package/dist/cjs/effects-gl.js.map +1 -0
  23. package/dist/cjs/fallback.d.ts +81 -0
  24. package/dist/cjs/fallback.d.ts.map +1 -0
  25. package/dist/cjs/fallback.js +148 -0
  26. package/dist/cjs/fallback.js.map +1 -0
  27. package/dist/cjs/health.d.ts +70 -0
  28. package/dist/cjs/health.d.ts.map +1 -0
  29. package/dist/cjs/health.js +116 -0
  30. package/dist/cjs/health.js.map +1 -0
  31. package/dist/cjs/index.d.ts +396 -0
  32. package/dist/cjs/index.d.ts.map +1 -1
  33. package/dist/cjs/index.js +1367 -81
  34. package/dist/cjs/index.js.map +1 -1
  35. package/dist/cjs/link.d.ts +99 -0
  36. package/dist/cjs/link.d.ts.map +1 -0
  37. package/dist/cjs/link.js +219 -0
  38. package/dist/cjs/link.js.map +1 -0
  39. package/dist/cjs/noise-suppression.d.ts +44 -0
  40. package/dist/cjs/noise-suppression.d.ts.map +1 -0
  41. package/dist/cjs/noise-suppression.js +103 -0
  42. package/dist/cjs/noise-suppression.js.map +1 -0
  43. package/dist/cjs/preflight.d.ts +79 -0
  44. package/dist/cjs/preflight.d.ts.map +1 -0
  45. package/dist/cjs/preflight.js +159 -0
  46. package/dist/cjs/preflight.js.map +1 -0
  47. package/dist/cjs/processors.d.ts +34 -0
  48. package/dist/cjs/processors.d.ts.map +1 -0
  49. package/dist/cjs/processors.js +10 -0
  50. package/dist/cjs/processors.js.map +1 -0
  51. package/dist/cjs/speakers.d.ts +34 -0
  52. package/dist/cjs/speakers.d.ts.map +1 -0
  53. package/dist/cjs/speakers.js +71 -0
  54. package/dist/cjs/speakers.js.map +1 -0
  55. package/dist/cjs/video-effects.d.ts +93 -0
  56. package/dist/cjs/video-effects.d.ts.map +1 -0
  57. package/dist/cjs/video-effects.js +493 -0
  58. package/dist/cjs/video-effects.js.map +1 -0
  59. package/dist/esm/adaptive.d.ts +104 -0
  60. package/dist/esm/adaptive.d.ts.map +1 -0
  61. package/dist/esm/adaptive.js +256 -0
  62. package/dist/esm/adaptive.js.map +1 -0
  63. package/dist/esm/devices.d.ts +42 -0
  64. package/dist/esm/devices.d.ts.map +1 -0
  65. package/dist/esm/devices.js +50 -0
  66. package/dist/esm/devices.js.map +1 -0
  67. package/dist/esm/diagnostics.d.ts +65 -0
  68. package/dist/esm/diagnostics.d.ts.map +1 -0
  69. package/dist/esm/diagnostics.js +65 -0
  70. package/dist/esm/diagnostics.js.map +1 -0
  71. package/dist/esm/effects-core.d.ts +95 -0
  72. package/dist/esm/effects-core.d.ts.map +1 -0
  73. package/dist/esm/effects-core.js +142 -0
  74. package/dist/esm/effects-core.js.map +1 -0
  75. package/dist/esm/effects-gl.d.ts +79 -0
  76. package/dist/esm/effects-gl.d.ts.map +1 -0
  77. package/dist/esm/effects-gl.js +347 -0
  78. package/dist/esm/effects-gl.js.map +1 -0
  79. package/dist/esm/fallback.d.ts +81 -0
  80. package/dist/esm/fallback.d.ts.map +1 -0
  81. package/dist/esm/fallback.js +142 -0
  82. package/dist/esm/fallback.js.map +1 -0
  83. package/dist/esm/health.d.ts +70 -0
  84. package/dist/esm/health.d.ts.map +1 -0
  85. package/dist/esm/health.js +111 -0
  86. package/dist/esm/health.js.map +1 -0
  87. package/dist/esm/index.d.ts +396 -0
  88. package/dist/esm/index.d.ts.map +1 -1
  89. package/dist/esm/index.js +1365 -81
  90. package/dist/esm/index.js.map +1 -1
  91. package/dist/esm/link.d.ts +99 -0
  92. package/dist/esm/link.d.ts.map +1 -0
  93. package/dist/esm/link.js +214 -0
  94. package/dist/esm/link.js.map +1 -0
  95. package/dist/esm/noise-suppression.d.ts +44 -0
  96. package/dist/esm/noise-suppression.d.ts.map +1 -0
  97. package/dist/esm/noise-suppression.js +99 -0
  98. package/dist/esm/noise-suppression.js.map +1 -0
  99. package/dist/esm/preflight.d.ts +79 -0
  100. package/dist/esm/preflight.d.ts.map +1 -0
  101. package/dist/esm/preflight.js +153 -0
  102. package/dist/esm/preflight.js.map +1 -0
  103. package/dist/esm/processors.d.ts +34 -0
  104. package/dist/esm/processors.d.ts.map +1 -0
  105. package/dist/esm/processors.js +9 -0
  106. package/dist/esm/processors.js.map +1 -0
  107. package/dist/esm/speakers.d.ts +34 -0
  108. package/dist/esm/speakers.d.ts.map +1 -0
  109. package/dist/esm/speakers.js +67 -0
  110. package/dist/esm/speakers.js.map +1 -0
  111. package/dist/esm/video-effects.d.ts +93 -0
  112. package/dist/esm/video-effects.d.ts.map +1 -0
  113. package/dist/esm/video-effects.js +488 -0
  114. package/dist/esm/video-effects.js.map +1 -0
  115. package/package.json +31 -1
  116. package/src/adaptive.ts +295 -0
  117. package/src/devices.ts +70 -0
  118. package/src/diagnostics.ts +94 -0
  119. package/src/effects-core.ts +177 -0
  120. package/src/effects-gl.ts +384 -0
  121. package/src/fallback.ts +169 -0
  122. package/src/health.ts +127 -0
  123. package/src/index.ts +1364 -81
  124. package/src/link.ts +255 -0
  125. package/src/noise-suppression.ts +135 -0
  126. package/src/preflight.ts +191 -0
  127. package/src/processors.ts +35 -0
  128. package/src/speakers.ts +66 -0
  129. package/src/video-effects.ts +552 -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;
@@ -132,6 +235,8 @@ class StreamClient {
132
235
  // server only remembers the last one, rejecting consumes on the first.
133
236
  sendTransportPending = null;
134
237
  recvTransportPending = null;
238
+ /** Transports the join made (P3-6), each used once by the first transport of its direction. */
239
+ preparedTransports = null;
135
240
  pending = new Map();
136
241
  listeners = new Map();
137
242
  consumers = new Map();
@@ -194,6 +299,74 @@ class StreamClient {
194
299
  * the full picture.
195
300
  */
196
301
  preferredLayers = new Map();
302
+ /**
303
+ * Adaptive stream (attachVideo): each attached remote video at the size it is
304
+ * shown, and paused at the server while no attached element is visible.
305
+ */
306
+ adaptive = new adaptive_js_1.AdaptiveVideos({
307
+ layers: (id) => this.remoteVideoLayers.get(id) ?? [],
308
+ apply: (id, want) => { void this.applyAdaptive(id, this.withFallback(id, want)); },
309
+ pixelRatio: () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1),
310
+ pageVisible: adaptive_js_1.pageIsVisible,
311
+ });
312
+ /** Audio-only fallback: whether it may happen, its decisions, and the sampling that feeds them. */
313
+ audioOnlyEnabled = true;
314
+ protection = new fallback_js_1.AudioProtection((active) => this.onAudioProtection(active));
315
+ protectionTimer = null;
316
+ protectionSampling = false;
317
+ /** Publisher-side fallback: off unless asked for; its decisions, timer, and whether it holds the camera now. */
318
+ publishFallbackEnabled = false;
319
+ publishProtection = new fallback_js_1.AudioProtection((active) => { void this.onPublishProtection(active); });
320
+ publishProtectionTimer = null;
321
+ publishProtectionSampling = false;
322
+ cameraHeldByFallback = false;
323
+ /** Remote videos the media server reports it cut for this viewer's bandwidth. */
324
+ bandwidthCut = new Set();
325
+ /** Remote cameras the audio-only fallback holds back now (reported with fallback: true). */
326
+ protectionHeld = new Set();
327
+ /** audioLevels / activeSpeakers: read only while someone listens. */
328
+ levelTimer = null;
329
+ levelSampling = false;
330
+ speakers = new speakers_js_1.ActiveSpeakers();
331
+ /** mediaWarning: decisions, the sampling timer, and the system interruptions being timed. */
332
+ health = new health_js_1.HealthDecisions((warning) => this.emit('mediaWarning', warning));
333
+ healthTimer = null;
334
+ healthSampling = false;
335
+ interruptions = new Map();
336
+ /** setAudioProcessor: what the microphone passes through, and the device's own track while it does. */
337
+ audioProcessor = null;
338
+ micRaw = null;
339
+ videoProcessor = null;
340
+ /** With a video processor: the camera (or the app's track) it draws from, and the picture it makes -- sent and shown. */
341
+ camRaw = null;
342
+ camOut = null;
343
+ /** getDiagnostics(): the SDK's recent events, for a support ticket. */
344
+ diagnosticsLog = new diagnostics_js_1.DiagnosticsLog();
345
+ /** play(): elements the browser refused to play until the viewer interacts, retried on the next gesture. */
346
+ autoplayBlocked = new Set();
347
+ gestureWatchOn = false;
348
+ onGesture = () => { void this.retryAutoplay(); };
349
+ /** setPlayoutDelay: the receive buffer asked for every remote track, in ms (null: the browser's own). */
350
+ playoutDelayMs = null;
351
+ /** devicesChanged: the device list last seen, kept while someone listens. */
352
+ knownDevices = [];
353
+ deviceWatchOn = false;
354
+ onDeviceChange = () => { void this.refreshDevices(); };
355
+ /** Videos adaptive stream has paused at the server, by producer id. */
356
+ adaptivePaused = new Set();
357
+ /** The server has no pauseConsumer (an older controller): a hidden video drops to its smallest layer instead. */
358
+ pauseUnsupported = false;
359
+ /** The last cpuConstrained said. */
360
+ cpuConstrainedNow = false;
361
+ /** prioritizePerformance(): the camera's largest simulcast layer is not encoded while true. */
362
+ performanceFirst = false;
363
+ /**
364
+ * Dynacast (the server's `producerLayers`): the highest layer any viewer needs, by producer id (-1 = nobody
365
+ * watches, the smallest layer stays on). No entry: every layer, as before the server said anything.
366
+ */
367
+ layersNeeded = new Map();
368
+ /** The highest camera layer this client set active, and on which producer. */
369
+ cameraMaxLayer = null;
197
370
  /** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
198
371
  endRoomWaiters = new Set();
199
372
  closedByUs = false;
@@ -220,7 +393,14 @@ class StreamClient {
220
393
  codecFallbackLogged = false;
221
394
  sendStatsBaseline = new Map();
222
395
  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; });
396
+ limitWatch = new stats_js_1.VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) => {
397
+ this.emit('videoLimited', event);
398
+ const constrained = event.reason === 'cpu';
399
+ if (constrained !== this.cpuConstrainedNow) {
400
+ this.cpuConstrainedNow = constrained;
401
+ this.emit('cpuConstrained', { constrained });
402
+ }
403
+ }, undefined, () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; });
224
404
  /** Where the session stands; see connectionStateChanged. */
225
405
  state = 'idle';
226
406
  /** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
@@ -251,11 +431,55 @@ class StreamClient {
251
431
  audioCapReported = false;
252
432
  sizeCapReported = false;
253
433
  networkWatch = new network_js_1.NetworkQualityWatch(() => this.transportStats(), () => this.producers.size > 0, (quality) => this.emit('networkQuality', quality));
434
+ /**
435
+ * A pre-call check (Twilio's runPreflight, LiveKit's ConnectionCheck, Agora's last-mile probe): joins the room the
436
+ * token names, publishes `options.media` (cloned, your tracks keep running) or a tone and a moving picture it makes,
437
+ * measures for `durationMs` (5 s) and leaves. Mint a host token for a test room nobody else is in: the session is
438
+ * real, and billed like any other for its few seconds. Resolves with each step, how media travelled (UDP is best;
439
+ * TCP or a relay means a firewall in the way), the round trip, loss and upload, and any camera or microphone warning
440
+ * seen. Never rejects: a failure is a step with `ok: false` and its error.
441
+ */
442
+ static async preflight(wsUrl, token, options = {}) {
443
+ const client = new StreamClient(wsUrl, token, { audioOnlyFallback: false });
444
+ const synthetic = options.media ? null : (0, preflight_js_1.syntheticMedia)();
445
+ const stream = options.media ? new MediaStream(options.media.getTracks().map((t) => t.clone())) : synthetic.stream;
446
+ try {
447
+ return await (0, preflight_js_1.runPreflight)({
448
+ connect: () => client.connect(),
449
+ publish: (media) => client.publish(media),
450
+ sendState: () => client.sendTransport?.connectionState ?? null,
451
+ sendStats: async () => (client.sendTransport ? (await client.sendTransport.getStats()).values() : null),
452
+ onWarning: (listener) => client.on('mediaWarning', (w) => listener(w.code, w.active)),
453
+ close: () => client.close(),
454
+ }, stream, options);
455
+ }
456
+ finally {
457
+ synthetic?.stop();
458
+ }
459
+ }
460
+ /**
461
+ * Warm the connection before `connect()` (LiveKit's prepareConnection): a HEAD request to the server, so the
462
+ * name lookup and the TLS handshake are done by the time the socket opens. Call it as the user heads for the
463
+ * room (opening the room screen, say). Never rejects; it only saves time.
464
+ */
465
+ static async prepareConnection(wsUrl) {
466
+ try {
467
+ const url = new URL(wsUrl);
468
+ url.protocol = url.protocol === 'ws:' ? 'http:' : 'https:';
469
+ url.pathname = '/';
470
+ url.search = '';
471
+ await fetch(url.toString(), { method: 'HEAD', mode: 'no-cors', cache: 'no-store' });
472
+ }
473
+ catch {
474
+ // Only a warm-up: connect() does the real work either way.
475
+ }
476
+ }
254
477
  constructor(wsUrl, token, options = {}) {
255
478
  this.wsUrl = wsUrl;
256
479
  this.options = options;
257
480
  this.token = token;
258
481
  this.tokenExpiry = (0, token_js_1.tokenExpiresAt)(token);
482
+ this.audioOnlyEnabled = options.audioOnlyFallback !== false;
259
483
  }
260
484
  on(event, fn) {
261
485
  let set = this.listeners.get(event);
@@ -266,16 +490,28 @@ class StreamClient {
266
490
  set.add(fn);
267
491
  // videoLimited and networkQuality cost a getStats every few seconds, so
268
492
  // they run only while heard.
269
- if (event === 'videoLimited')
493
+ if (event === 'videoLimited' || event === 'cpuConstrained')
270
494
  this.syncLimitWatch();
271
495
  if (event === 'networkQuality')
272
496
  this.syncNetworkWatch();
497
+ if (event === 'devicesChanged')
498
+ this.syncDeviceWatch();
499
+ if (event === 'audioLevels' || event === 'activeSpeakers')
500
+ this.syncLevelWatch();
501
+ if (event === 'mediaWarning')
502
+ this.syncHealthWatch();
273
503
  return () => {
274
504
  set.delete(fn);
275
- if (event === 'videoLimited')
505
+ if (event === 'videoLimited' || event === 'cpuConstrained')
276
506
  this.syncLimitWatch();
277
507
  if (event === 'networkQuality')
278
508
  this.syncNetworkWatch();
509
+ if (event === 'devicesChanged')
510
+ this.syncDeviceWatch();
511
+ if (event === 'audioLevels' || event === 'activeSpeakers')
512
+ this.syncLevelWatch();
513
+ if (event === 'mediaWarning')
514
+ this.syncHealthWatch();
279
515
  };
280
516
  }
281
517
  /** Where the session stands: idle, connecting, connected, reconnecting or closed. */
@@ -289,6 +525,10 @@ class StreamClient {
289
525
  this.state = next;
290
526
  if (next === 'closed')
291
527
  this.stopTimers();
528
+ else {
529
+ this.syncLevelWatch();
530
+ this.syncHealthWatch();
531
+ }
292
532
  this.emit('connectionStateChanged', { state: next, previous });
293
533
  }
294
534
  /** Over for good: the state, then the event that says why. */
@@ -310,6 +550,20 @@ class StreamClient {
310
550
  this.close();
311
551
  }
312
552
  stopTimers() {
553
+ if (this.publishProtectionTimer)
554
+ clearInterval(this.publishProtectionTimer);
555
+ this.publishProtectionTimer = null;
556
+ this.autoplayBlocked.clear();
557
+ this.watchGesture(false);
558
+ this.stopProtection();
559
+ this.stopLevelWatch();
560
+ this.stopHealthWatch(false);
561
+ this.stopDeviceWatch();
562
+ this.socketLiveness.stop();
563
+ this.mediaPaths.reset();
564
+ this.backoffWake?.();
565
+ this.onlineTarget?.removeEventListener('online', this.online);
566
+ this.onlineTarget = null;
313
567
  if (this.tokenTimer)
314
568
  clearTimeout(this.tokenTimer);
315
569
  this.tokenTimer = null;
@@ -319,6 +573,7 @@ class StreamClient {
319
573
  return this.listeners.get(event)?.size ?? 0;
320
574
  }
321
575
  emit(event, ...args) {
576
+ this.diagnosticsLog.record(event, args);
322
577
  for (const fn of this.listeners.get(event) ?? []) {
323
578
  try {
324
579
  fn(...args);
@@ -342,8 +597,13 @@ class StreamClient {
342
597
  const opening = this.openSocket();
343
598
  socket = this.ws;
344
599
  await opening;
345
- result = (await this.rpc('join', { token: this.token }));
600
+ // The transports come with the join where the server can make them (P3-6): the first publish and the first
601
+ // video skip a createTransport round trip. Older servers ignore the request and the client asks as before.
602
+ const joined = (await this.rpc('join', { token: this.token, transports: ['send', 'recv'] }));
603
+ const { transports, ...joinResult } = joined;
604
+ result = joinResult;
346
605
  this.joinResult = result;
606
+ this.preparedTransports = transports ?? null;
347
607
  this.device = this.createDevice();
348
608
  await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
349
609
  // close() ran while the Device loaded: going on would set the state
@@ -374,6 +634,11 @@ class StreamClient {
374
634
  this.joinedOnce = true;
375
635
  this.skippedVideos.clear();
376
636
  this.setState('connected');
637
+ this.socketLiveness.start();
638
+ if (!this.onlineTarget && typeof window !== 'undefined' && window.addEventListener) {
639
+ this.onlineTarget = window;
640
+ window.addEventListener('online', this.online);
641
+ }
377
642
  this.scheduleTokenExpiring();
378
643
  // A role or mute can change while the socket is away. The fresh roster
379
644
  // decides what may return; reconnecting never turns a blocked device on.
@@ -405,12 +670,13 @@ class StreamClient {
405
670
  this.remoteVideoLayers.delete(producerId);
406
671
  // Subscribe to everything already publishing before we announce readiness.
407
672
  // 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
- }
673
+ // All at once, as newProducer events already are (and the Flutter SDK does): each subscription reserves its slot
674
+ // before its first await, in this order, and the receive transport is made once. A room with six publishers
675
+ // starts six videos together instead of one after another, two round trips each.
676
+ if (closed())
677
+ throw closedWhileConnecting();
678
+ await Promise.all(result.producers.map((p) => this.consume(p).catch((err) => { if (!closed())
679
+ this.emit('error', err); })));
414
680
  // Restore our own broadcast. This is what makes an origin failover
415
681
  // survivable for the host: it reconnects onto the new origin and the
416
682
  // stream comes back on its own.
@@ -421,6 +687,9 @@ class StreamClient {
421
687
  if (closed())
422
688
  throw closedWhileConnecting();
423
689
  this.syncNetworkWatch();
690
+ this.syncProtection();
691
+ this.syncLevelWatch();
692
+ this.syncPublishProtection();
424
693
  return result;
425
694
  }
426
695
  /**
@@ -460,14 +729,18 @@ class StreamClient {
460
729
  ws.addEventListener('open', onOpen, { once: true });
461
730
  ws.addEventListener('error', onError, { once: true });
462
731
  ws.addEventListener('message', (ev) => {
463
- if (this.ws === ws)
732
+ if (this.ws === ws) {
733
+ this.socketLiveness.received();
464
734
  this.onMessage(ev.data);
735
+ }
465
736
  });
466
737
  ws.addEventListener('close', (ev) => {
467
738
  ws.removeEventListener('error', onError);
468
739
  // Only the socket we currently consider ours may drive reconnection.
469
740
  if (this.ws !== ws)
470
741
  return;
742
+ this.socketLiveness.stop();
743
+ this.mediaPaths.reset();
471
744
  this.ws = null;
472
745
  this.failAllPending(new errors_js_1.RpcError('not-connected', `socket closed (${ev.code})`));
473
746
  this.emit('disconnected', ev.reason || String(ev.code));
@@ -500,6 +773,7 @@ class StreamClient {
500
773
  });
501
774
  }
502
775
  detachSocket(reason = 'superseded', why = 'the connection was replaced') {
776
+ this.socketLiveness.stop();
503
777
  clearTimeout(this.openDeadline);
504
778
  const ws = this.ws;
505
779
  this.ws = null;
@@ -550,7 +824,7 @@ class StreamClient {
550
824
  return;
551
825
  }
552
826
  const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
553
- await sleep(backoff + Math.random() * 400);
827
+ await this.waitBackoff(backoff + Math.random() * 400);
554
828
  if (this.closedByUs)
555
829
  return;
556
830
  try {
@@ -586,6 +860,17 @@ class StreamClient {
586
860
  }
587
861
  close() {
588
862
  void this.stopRecording().catch(() => undefined);
863
+ // The processor's audio graph goes with the session.
864
+ const processor = this.audioProcessor;
865
+ this.audioProcessor = null;
866
+ this.micRaw = null;
867
+ void Promise.resolve(processor?.destroy()).catch(() => undefined);
868
+ const videoProcessor = this.videoProcessor;
869
+ this.videoProcessor = null;
870
+ void Promise.resolve(videoProcessor?.destroy()).catch(() => undefined);
871
+ // Adaptive stream stops watching elements: nothing is received any more.
872
+ this.adaptive.dispose();
873
+ this.adaptivePaused.clear();
589
874
  // Only an app closing a live session is 'left'; kicked, room-closed and the
590
875
  // others have already reported themselves and set closedByUs.
591
876
  const wasOpen = !this.closedByUs;
@@ -605,7 +890,7 @@ class StreamClient {
605
890
  this.skippedVideos.clear();
606
891
  // Producers no longer stop their tracks, so releasing the camera and
607
892
  // microphone is ours to do -- otherwise the device light stays on.
608
- this.localStream?.getTracks().forEach((t) => t.stop());
893
+ this.stopLocalTracks();
609
894
  this.localStream = null;
610
895
  const ws = this.ws;
611
896
  this.ws = null;
@@ -619,7 +904,18 @@ class StreamClient {
619
904
  this.emit('closed', 'left');
620
905
  }
621
906
  teardownMedia() {
907
+ this.micRaw = null;
908
+ // Made for the session that is going; a rejoin brings new ones.
909
+ this.preparedTransports = null;
910
+ this.mediaPaths.reset();
911
+ // The videos go with their consumers; a reconnect decides afresh. What was reported held back is withdrawn, so an
912
+ // app's "poor connection" overlay does not outlive it.
913
+ this.clearFallbacks();
622
914
  this.subscriptions.clear();
915
+ // Producers made after this are new: the server says again what their viewers need.
916
+ this.layersNeeded.clear();
917
+ // New consumers start paused at the server; adaptive stream decides again for each.
918
+ this.adaptivePaused.clear();
623
919
  for (const c of this.consumers.values())
624
920
  c.close();
625
921
  this.consumers.clear();
@@ -839,6 +1135,35 @@ class StreamClient {
839
1135
  case 'newProducer':
840
1136
  await this.consume(data).catch((err) => this.emit('error', err));
841
1137
  break;
1138
+ case 'streamFallback': {
1139
+ // The media server cut (or resumed) one of this viewer's videos for its bandwidth.
1140
+ const e = data;
1141
+ if (typeof e.producerId !== 'string' || !this.consumers.has(e.producerId))
1142
+ break;
1143
+ const cut = e.fallback === true;
1144
+ if (cut === this.bandwidthCut.has(e.producerId))
1145
+ break;
1146
+ if (cut)
1147
+ this.bandwidthCut.add(e.producerId);
1148
+ else
1149
+ this.bandwidthCut.delete(e.producerId);
1150
+ this.emitFallback(e.producerId, cut, 'bandwidth');
1151
+ break;
1152
+ }
1153
+ case 'producerLayers': {
1154
+ // Dynacast: what this client's viewers need from one of its producers. Layers nobody receives are not
1155
+ // encoded (the smallest always is, so a new viewer starts at once); null means every layer.
1156
+ const e = data;
1157
+ if (typeof e.producerId !== 'string')
1158
+ break;
1159
+ if (typeof e.spatialLayer === 'number' && Number.isInteger(e.spatialLayer))
1160
+ this.layersNeeded.set(e.producerId, e.spatialLayer);
1161
+ else
1162
+ this.layersNeeded.delete(e.producerId);
1163
+ if (this.cameraProducer()?.id === e.producerId)
1164
+ void this.cameraTask(() => this.syncCameraLayers());
1165
+ break;
1166
+ }
842
1167
  case 'producerClosed': {
843
1168
  const { producerId, identity } = data;
844
1169
  this.subscriptions.delete(producerId);
@@ -846,6 +1171,9 @@ class StreamClient {
846
1171
  this.receiveQualities.delete(producerId);
847
1172
  this.remoteVideoLayers.delete(producerId);
848
1173
  this.skippedVideos.delete(producerId);
1174
+ this.adaptivePaused.delete(producerId);
1175
+ this.bandwidthCut.delete(producerId);
1176
+ this.protectionHeld.delete(producerId);
849
1177
  const consumer = this.consumers.get(producerId);
850
1178
  consumer?.close();
851
1179
  this.consumers.delete(producerId);
@@ -901,6 +1229,9 @@ class StreamClient {
901
1229
  const e = data;
902
1230
  this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
903
1231
  this.emit('participantLeft', e);
1232
+ const speakers = this.speakers.remove(e.identity);
1233
+ if (speakers)
1234
+ this.emit('activeSpeakers', { speakers });
904
1235
  break;
905
1236
  }
906
1237
  case 'roleChanged': {
@@ -1048,24 +1379,25 @@ class StreamClient {
1048
1379
  return Promise.resolve(this.sendTransport);
1049
1380
  if (this.sendTransportPending)
1050
1381
  return this.sendTransportPending;
1051
- this.sendTransportPending = this.createSendTransport().finally(() => {
1052
- this.sendTransportPending = null;
1382
+ const pending = this.createSendTransport().finally(() => {
1383
+ if (this.sendTransportPending === pending)
1384
+ this.sendTransportPending = null;
1053
1385
  });
1054
- return this.sendTransportPending;
1386
+ return this.sendTransportPending = pending;
1055
1387
  }
1056
1388
  async createSendTransport() {
1057
1389
  if (!this.device)
1058
1390
  throw new errors_js_1.RpcError('not-connected', 'not connected');
1059
- const params = (await this.rpc('createTransport', { direction: 'send' }));
1060
- const transport = this.device.createSendTransport({
1391
+ const device = this.device;
1392
+ const socket = this.ws;
1393
+ const params = this.takePreparedTransport('send') ?? (await this.rpc('createTransport', { direction: 'send' }));
1394
+ if (this.closedByUs || device !== this.device || socket !== this.ws)
1395
+ throw closedWhileConnecting();
1396
+ const transport = device.createSendTransport({
1061
1397
  ...params,
1062
1398
  iceServers: this.joinResult?.iceServers,
1063
1399
  });
1064
- transport.on('connect', ({ dtlsParameters }, ok, fail) => {
1065
- this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
1066
- .then(() => ok())
1067
- .catch(fail);
1068
- });
1400
+ this.connectEarly(transport);
1069
1401
  transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
1070
1402
  this.rpc('produce', {
1071
1403
  transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
@@ -1075,32 +1407,62 @@ class StreamClient {
1075
1407
  .catch(fail);
1076
1408
  });
1077
1409
  this.sendTransport = transport;
1410
+ this.watchTransport(transport);
1078
1411
  return transport;
1079
1412
  }
1413
+ /**
1414
+ * A faster first picture (docs/implementation-progress/RTC_PERF_PROGRESS.md P3-6). mediasoup-client applies the
1415
+ * remote description -- which starts ICE -- only once this transport's DTLS parameters are with the server: a round
1416
+ * trip before any media path is even tried, for the first publish and the first video alike. mediasoup does not need
1417
+ * them first: it starts DTLS as soon as ICE connects and checks the fingerprint whenever it arrives (its `auto`
1418
+ * role; the browser is always the DTLS client), and the server handles a socket's requests in order. So they are
1419
+ * sent and the transport goes on at once: ICE starts a round trip sooner, and the first produce or resume follows
1420
+ * straight after. A refusal means the transport can never carry media: the session rejoins.
1421
+ */
1422
+ connectEarly(transport) {
1423
+ transport.on('connect', ({ dtlsParameters }, ok) => {
1424
+ ok();
1425
+ this.rpc('connectTransport', { transportId: transport.id, dtlsParameters }).catch((err) => {
1426
+ if (transport.closed || this.closedByUs || (transport !== this.sendTransport && transport !== this.recvTransport))
1427
+ return;
1428
+ this.emit('error', err);
1429
+ this.recoverConnection(`connectTransport failed: ${err?.message ?? err}`);
1430
+ });
1431
+ });
1432
+ }
1433
+ /** The transport the join made for this direction, once: a later one is created as before. */
1434
+ takePreparedTransport(direction) {
1435
+ const prepared = this.preparedTransports?.[direction] ?? null;
1436
+ if (this.preparedTransports)
1437
+ this.preparedTransports[direction] = undefined;
1438
+ return prepared;
1439
+ }
1080
1440
  ensureRecvTransport() {
1081
1441
  if (this.recvTransport)
1082
1442
  return Promise.resolve(this.recvTransport);
1083
1443
  if (this.recvTransportPending)
1084
1444
  return this.recvTransportPending;
1085
- this.recvTransportPending = this.createRecvTransport().finally(() => {
1086
- this.recvTransportPending = null;
1445
+ const pending = this.createRecvTransport().finally(() => {
1446
+ if (this.recvTransportPending === pending)
1447
+ this.recvTransportPending = null;
1087
1448
  });
1088
- return this.recvTransportPending;
1449
+ return this.recvTransportPending = pending;
1089
1450
  }
1090
1451
  async createRecvTransport() {
1091
1452
  if (!this.device)
1092
1453
  throw new errors_js_1.RpcError('not-connected', 'not connected');
1093
- const params = (await this.rpc('createTransport', { direction: 'recv' }));
1094
- const transport = this.device.createRecvTransport({
1454
+ const device = this.device;
1455
+ const socket = this.ws;
1456
+ const params = this.takePreparedTransport('recv') ?? (await this.rpc('createTransport', { direction: 'recv' }));
1457
+ if (this.closedByUs || device !== this.device || socket !== this.ws)
1458
+ throw closedWhileConnecting();
1459
+ const transport = device.createRecvTransport({
1095
1460
  ...params,
1096
1461
  iceServers: this.joinResult?.iceServers,
1097
1462
  });
1098
- transport.on('connect', ({ dtlsParameters }, ok, fail) => {
1099
- this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
1100
- .then(() => ok())
1101
- .catch(fail);
1102
- });
1463
+ this.connectEarly(transport);
1103
1464
  this.recvTransport = transport;
1465
+ this.watchTransport(transport);
1104
1466
  return transport;
1105
1467
  }
1106
1468
  /**
@@ -1211,7 +1573,8 @@ class StreamClient {
1211
1573
  this.audioCapReported = false;
1212
1574
  await this.micTask(async () => {
1213
1575
  const current = this.micProducer();
1214
- const track = current?.track;
1576
+ // The device's track: the processed one is the old processor graph's, let go when it is processed again.
1577
+ const track = current ? this.deviceTrackOf(current) : null;
1215
1578
  if (!current || !track || track.readyState !== 'live' || this.micQuality === quality)
1216
1579
  return;
1217
1580
  await this.reproduceMic(current, track);
@@ -1252,8 +1615,10 @@ class StreamClient {
1252
1615
  throw unpublishedWhile('microphone');
1253
1616
  const quality = this.audioQualityFor();
1254
1617
  const settings = media_permissions_js_1.AUDIO_SETTINGS[quality];
1618
+ // Through the audio processor (noise suppression) when there is one; the device's track stays the one watched.
1619
+ const sent = await this.processMic(track);
1255
1620
  const producer = await transport.produce({
1256
- track,
1621
+ track: sent,
1257
1622
  // Keep the track alive when the producer closes: a reconnect closes
1258
1623
  // every producer, and a stopped track cannot be republished.
1259
1624
  stopTracks: false,
@@ -1266,6 +1631,8 @@ class StreamClient {
1266
1631
  appData: { source: 'mic', audioQuality: quality },
1267
1632
  });
1268
1633
  await this.keepProducer(producer, generation, 'microphone');
1634
+ this.micRaw = sent !== track ? track : null;
1635
+ this.watchTrackEnded(producer);
1269
1636
  this.micQuality = quality;
1270
1637
  // A microphone handed over switched off, or muted before a reconnect.
1271
1638
  await this.pauseOnServer(producer);
@@ -1337,8 +1704,9 @@ class StreamClient {
1337
1704
  const track = current?.track;
1338
1705
  if (!current || !track || track.readyState !== 'live')
1339
1706
  return;
1707
+ const camera = this.cameraSource(track);
1340
1708
  try {
1341
- await track.applyConstraints?.({ ...track.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } });
1709
+ await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } });
1342
1710
  }
1343
1711
  catch { /* Existing capture settings decide whether 60 fps is possible. */ }
1344
1712
  await this.reproduceCamera(current, track);
@@ -1388,42 +1756,56 @@ class StreamClient {
1388
1756
  const reconnecting = !current && this.wasPublishing && (this.localStream?.getVideoTracks().length ?? 0) > 0;
1389
1757
  if (!current && !reconnecting)
1390
1758
  throw new Error('replaceVideoTrack: no camera is being published');
1391
- if (!current) {
1392
- // The republish reads the camera's state off the track it sends, and
1393
- // the track being replaced is switched off exactly when the camera is
1394
- // paused. Hand that state on, or a camera that was off comes back on;
1395
- // and the old track back switched on, as below.
1396
- const replaced = this.localStream?.getVideoTracks()[0];
1397
- if (replaced && replaced !== track) {
1398
- track.enabled = replaced.enabled;
1399
- replaced.enabled = true;
1400
- }
1401
- }
1402
- this.adoptVideoTrack(track);
1403
- if (!current || current.track === track)
1404
- return;
1405
- const previous = current.track;
1406
- const paused = current.paused;
1759
+ // The picture behind what is sent now (a video processor's input), for its size; the new one goes through the
1760
+ // processor too, when there is one.
1761
+ const shown = current?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
1762
+ const previousSource = shown ? this.cameraSource(shown) : null;
1763
+ const before = this.camOut;
1764
+ const sent = await this.processCamera(track);
1407
1765
  try {
1408
- const { width = 0, height = 0 } = track.getSettings();
1409
- const frameRate = this.frameRateFor(track);
1410
- const next = (0, quality_js_1.videoEncodings)(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
1411
- const previousSize = current.track?.getSettings();
1412
- if (this.cameraEncodings && (0, quality_js_1.sameLayout)(this.cameraEncodings, next) &&
1413
- previousSize?.width === width && previousSize?.height === height) {
1414
- await current.replaceTrack({ track });
1415
- await this.applyLayerBitrates(current, next);
1766
+ if (!current) {
1767
+ // The republish reads the camera's state off the track it sends, and
1768
+ // the track being replaced is switched off exactly when the camera is
1769
+ // paused. Hand that state on, or a camera that was off comes back on;
1770
+ // and the old track back switched on, as below.
1771
+ const replaced = this.localStream?.getVideoTracks()[0];
1772
+ if (replaced && replaced !== sent) {
1773
+ sent.enabled = replaced.enabled;
1774
+ replaced.enabled = true;
1775
+ }
1776
+ }
1777
+ this.adoptVideoTrack(sent);
1778
+ if (!current || current.track === sent)
1416
1779
  return;
1780
+ const previous = current.track;
1781
+ const paused = current.paused;
1782
+ try {
1783
+ const { width = 0, height = 0 } = track.getSettings();
1784
+ const frameRate = this.frameRateFor(track);
1785
+ const next = (0, quality_js_1.videoEncodings)(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
1786
+ const previousSize = previousSource?.getSettings();
1787
+ if (this.cameraEncodings && (0, quality_js_1.sameLayout)(this.cameraEncodings, next) &&
1788
+ previousSize?.width === width && previousSize?.height === height) {
1789
+ await current.replaceTrack({ track: sent });
1790
+ this.watchTrackEnded(current);
1791
+ await this.applyLayerBitrates(current, next);
1792
+ return;
1793
+ }
1794
+ await this.reproduceCamera(current, sent);
1795
+ }
1796
+ finally {
1797
+ // Pausing switched the old track off (mediasoup-client pauses that
1798
+ // way), and nothing switches it back on once it is replaced. It is the
1799
+ // app's again, often the camera its canvas draws from: handed back
1800
+ // off, that canvas stays black after the camera is resumed.
1801
+ if (paused && previous && previous !== this.cameraProducer()?.track)
1802
+ previous.enabled = true;
1417
1803
  }
1418
- await this.reproduceCamera(current, track);
1419
1804
  }
1420
1805
  finally {
1421
- // Pausing switched the old track off (mediasoup-client pauses that
1422
- // way), and nothing switches it back on once it is replaced. It is the
1423
- // app's again, often the camera its canvas draws from: handed back
1424
- // off, that canvas stays black after the camera is resumed.
1425
- if (paused && previous && previous !== this.cameraProducer()?.track)
1426
- previous.enabled = true;
1806
+ // The processor's previous picture is no longer sent or shown.
1807
+ if (before && before !== this.camOut)
1808
+ before.stop();
1427
1809
  }
1428
1810
  });
1429
1811
  }
@@ -1515,7 +1897,9 @@ class StreamClient {
1515
1897
  this.receiveQualities.delete(producerId);
1516
1898
  // Kept first: a reconnect that interrupts the request still applies it.
1517
1899
  this.preferredLayers.set(producerId, layers);
1518
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
1900
+ // Shown through attachVideo: the lower of this and the size it is shown at.
1901
+ const adaptive = this.adaptive.current(producerId);
1902
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
1519
1903
  }
1520
1904
  async collectVideoStats(baseline) {
1521
1905
  const producer = this.cameraProducer();
@@ -1554,7 +1938,8 @@ class StreamClient {
1554
1938
  }
1555
1939
  this.receiveQualities.set(producerId, quality);
1556
1940
  this.preferredLayers.set(producerId, layers);
1557
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
1941
+ const adaptive = this.adaptive.current(producerId);
1942
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
1558
1943
  }
1559
1944
  cameraProducer() {
1560
1945
  for (const p of this.producers.values()) {
@@ -1601,18 +1986,29 @@ class StreamClient {
1601
1986
  async produceCamera(transport, track, generation, paused = !track.enabled) {
1602
1987
  if (generation !== this.publishGeneration)
1603
1988
  throw unpublishedWhile('camera');
1989
+ // With a video processor its picture is sent (and shown); the camera behind it has the size and frame rate. The
1990
+ // picture already made from that camera is kept: a reconnect or a new quality does not start the processor again.
1991
+ const source = this.cameraSource(track);
1992
+ const before = this.camOut;
1993
+ const beforeSource = this.camRaw;
1994
+ const sent = await this.processCamera(source);
1995
+ if (generation !== this.publishGeneration) {
1996
+ if (sent !== source && sent !== before)
1997
+ sent.stop();
1998
+ throw unpublishedWhile('camera');
1999
+ }
1604
2000
  // mediasoup-client starts a producer paused exactly when its track is
1605
2001
  // off. Setting the track to the camera's state first makes the producer
1606
2002
  // start in it, and a camera that is off never sends a frame of the new
1607
2003
  // track; the server is told below.
1608
- track.enabled = !paused;
2004
+ sent.enabled = !paused;
1609
2005
  // Simulcast from day one: it is what lets one node serve far more viewers.
1610
2006
  // The layers follow the picture's real size, the project's quality and
1611
2007
  // the device, so the browser sends every layer asked for (see
1612
2008
  // videoEncodings).
1613
- const { width = 0, height = 0 } = track.getSettings();
2009
+ const { width = 0, height = 0 } = source.getSettings();
1614
2010
  // The frame rate first: the size the project allows depends on it.
1615
- this.cameraFrameRate = this.frameRateFor(track);
2011
+ this.cameraFrameRate = this.frameRateFor(source);
1616
2012
  const quality = this.cameraQuality(this.cameraFrameRate);
1617
2013
  const encodings = (0, quality_js_1.videoEncodings)(width, height, quality, this.layerCount(), this.cameraFrameRate);
1618
2014
  let codecs;
@@ -1624,7 +2020,7 @@ class StreamClient {
1624
2020
  }
1625
2021
  const chosen = await this.cameraCodec(codecs, encodings, width, height, quality);
1626
2022
  const produce = (codec) => transport.produce({
1627
- track,
2023
+ track: sent,
1628
2024
  stopTracks: false,
1629
2025
  encodings,
1630
2026
  codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
@@ -1646,14 +2042,72 @@ class StreamClient {
1646
2042
  // unpublish() may have run while this was being made (a publish, a
1647
2043
  // quality change or a track swap under way).
1648
2044
  await this.keepProducer(producer, generation, 'camera');
2045
+ if (sent !== track)
2046
+ this.adoptVideoTrack(sent);
2047
+ if (before && before !== sent)
2048
+ before.stop();
2049
+ // A second publish brought another camera: the one the processor drew from before is no longer published.
2050
+ if (beforeSource && beforeSource !== source && beforeSource !== this.camRaw)
2051
+ beforeSource.stop();
2052
+ this.watchTrackEnded(producer);
2053
+ this.syncPublishProtection();
1649
2054
  this.cameraEncodings = encodings;
1650
2055
  this.frameRateLowSamples = 0;
1651
2056
  this.frameRateWarmupSamples = 2;
1652
2057
  await this.pauseOnServer(producer);
1653
2058
  await this.applyDegradationPreference(producer);
1654
2059
  this.syncLimitWatch();
2060
+ // A new camera producer starts with every layer: put the performance and viewer limits back on it.
2061
+ await this.syncCameraLayers();
1655
2062
  return producer;
1656
2063
  }
2064
+ /**
2065
+ * Lighten this device's work while it is overloaded (LiveKit's
2066
+ * `prioritizePerformance`): the camera's largest simulcast layer stops being
2067
+ * encoded -- the most expensive part of publishing -- and viewers get the
2068
+ * next one. `false` brings it back. Call it on `cpuConstrained`, together
2069
+ * with your app's own savings (effects off, smaller remote videos). Kept for
2070
+ * later publishes and reconnects until changed. A camera sent as one layer
2071
+ * is left as it is.
2072
+ */
2073
+ async prioritizePerformance(on = true) {
2074
+ this.performanceFirst = on;
2075
+ await this.cameraTask(() => this.syncCameraLayers());
2076
+ }
2077
+ /** Whether `prioritizePerformance()` is in force. */
2078
+ get performancePrioritized() {
2079
+ return this.performanceFirst;
2080
+ }
2081
+ /**
2082
+ * The camera's active simulcast layers: every layer, except the top one while
2083
+ * performance comes first, and none above the highest any viewer needs
2084
+ * (dynacast; the smallest always stays on, so a new viewer starts at once).
2085
+ */
2086
+ async syncCameraLayers() {
2087
+ const producer = this.cameraProducer();
2088
+ const count = this.cameraEncodings?.length ?? 0;
2089
+ if (!producer || producer.closed || count < 2)
2090
+ return;
2091
+ const top = count - 1;
2092
+ let max = top;
2093
+ if (this.performanceFirst)
2094
+ max = Math.min(max, top - 1);
2095
+ const needed = this.layersNeeded.get(producer.id);
2096
+ if (needed !== undefined)
2097
+ max = Math.min(max, Math.max(0, needed));
2098
+ // A producer just made sends every layer: nothing to ask of it until a limit applies.
2099
+ const applied = this.cameraMaxLayer?.producerId === producer.id ? this.cameraMaxLayer.layer : top;
2100
+ if (applied === max)
2101
+ return;
2102
+ try {
2103
+ await producer.setMaxSpatialLayer(max);
2104
+ this.cameraMaxLayer = { producerId: producer.id, layer: max };
2105
+ }
2106
+ catch (err) {
2107
+ if (!producer.closed)
2108
+ this.emit('error', err);
2109
+ }
2110
+ }
1657
2111
  /**
1658
2112
  * Keep a producer just made -- unless unpublish() ran while it was being
1659
2113
  * made. Kept, it would publish again what was just unpublished, so it is
@@ -1728,7 +2182,7 @@ class StreamClient {
1728
2182
  }
1729
2183
  /** videoLimited sampling runs while a camera is published and someone listens. */
1730
2184
  syncLimitWatch() {
1731
- this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
2185
+ this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.listenerCount('cpuConstrained') > 0 || this.cameraFrameRate === 60));
1732
2186
  }
1733
2187
  /**
1734
2188
  * Re-establish this client's broadcast after a reconnect.
@@ -1966,7 +2420,8 @@ class StreamClient {
1966
2420
  if (!isCurrent() || producer !== sampledProducer || this.frameRateFallback || !producer?.track || producer.closed)
1967
2421
  return;
1968
2422
  this.frameRateFallback = true;
1969
- await producer.track.applyConstraints?.({ ...producer.track.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
2423
+ const camera = this.cameraSource(producer.track);
2424
+ await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
1970
2425
  await this.reproduceCamera(producer, producer.track);
1971
2426
  this.emit('error', new errors_js_1.RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
1972
2427
  }).catch((error) => this.emit('error', error));
@@ -2188,6 +2643,8 @@ class StreamClient {
2188
2643
  const consumer = await transport.consume(params);
2189
2644
  current(consumer);
2190
2645
  this.consumers.set(remote.producerId, consumer);
2646
+ if (this.playoutDelayMs !== null)
2647
+ this.applyPlayoutDelay(consumer.rtpReceiver);
2191
2648
  this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
2192
2649
  // Layers asked for before a reconnect: the new consumer gets them before
2193
2650
  // its first frame instead of starting at the full picture. Not getting
@@ -2203,13 +2660,29 @@ class StreamClient {
2203
2660
  this.emit('error', new errors_js_1.RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
2204
2661
  }
2205
2662
  }
2663
+ // Adaptive stream: a video the app shows through attachVideo starts at the
2664
+ // size it is shown, and one nobody can see stays paused.
2665
+ const adaptive = consumer.kind === 'video' ? this.adaptive.current(remote.producerId) : null;
2666
+ if (adaptive)
2667
+ layers = this.effectiveLayers(remote.producerId, adaptive);
2668
+ // A camera arriving while the audio-only fallback holds cameras back starts held back too.
2669
+ const held = consumer.kind === 'video' && this.protection.active && (remote.source ?? 'camera') === 'camera';
2670
+ // Not waited for: the server applies them before the resume below (a socket's requests in order), so the first
2671
+ // frame is already the right size without a round trip in front of it (P3-6).
2206
2672
  if (layers) {
2207
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
2673
+ void this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
2208
2674
  }
2209
2675
  current(consumer);
2210
2676
  // The server created it paused. Resume only now that our end exists,
2211
2677
  // otherwise the first frames arrive before there is anything to decode them.
2212
- await this.rpc('resumeConsumer', { consumerId: consumer.id });
2678
+ this.adaptivePaused.delete(remote.producerId);
2679
+ if ((adaptive && !adaptive.visible) || (held && !this.pauseUnsupported))
2680
+ this.adaptivePaused.add(remote.producerId);
2681
+ else
2682
+ await this.rpc('resumeConsumer', { consumerId: consumer.id });
2683
+ if (held && this.pauseUnsupported) {
2684
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, spatialLayer: 0, temporalLayer: 0 }).catch(() => undefined);
2685
+ }
2213
2686
  current(consumer);
2214
2687
  const kind = consumer.kind;
2215
2688
  const source = remote.source ?? defaultSource(kind);
@@ -2222,6 +2695,812 @@ class StreamClient {
2222
2695
  producerId: remote.producerId,
2223
2696
  source,
2224
2697
  });
2698
+ if (kind === 'video')
2699
+ this.adaptive.consumed(remote.producerId);
2700
+ if (held) {
2701
+ this.protectionHeld.add(remote.producerId);
2702
+ this.emitFallback(remote.producerId, true, 'audio-protection');
2703
+ }
2704
+ this.syncProtection();
2705
+ }
2706
+ /**
2707
+ * Adaptive stream (like LiveKit's adaptiveStream and Tencent TRTC's small
2708
+ * stream with receiveWhenViewVisible): tell the SDK which element shows a
2709
+ * remote video. It then receives the smallest layer that fills that element
2710
+ * sharply (its size on screen × devicePixelRatio, at most 2) and pauses the
2711
+ * video at the server while the element is not visible (scrolled away,
2712
+ * `display: none`, or the tab in the background), so a grid of small tiles
2713
+ * stops decoding full-size video. Attach the same video to several elements
2714
+ * and the largest visible one decides. Returns the function that detaches
2715
+ * it; detach when the element goes away. A ceiling set with
2716
+ * `setReceiveQuality` or `setPreferredLayers` still applies. Works with any
2717
+ * server; one without `pauseConsumer` gets the smallest layer instead of a
2718
+ * pause.
2719
+ */
2720
+ attachVideo(producerId, element) {
2721
+ return this.adaptive.attach(producerId, element);
2722
+ }
2723
+ /** The layers to ask for: the app's ceiling (setReceiveQuality / setPreferredLayers) and adaptive stream's, the lower. */
2724
+ effectiveLayers(producerId, want) {
2725
+ let manual = this.preferredLayers.get(producerId);
2726
+ const quality = this.receiveQualities.get(producerId);
2727
+ if (quality && quality !== 'auto') {
2728
+ try {
2729
+ manual = { spatialLayer: (0, quality_js_1.receiveSpatialLayer)(this.remoteVideoLayers.get(producerId) ?? [], quality) };
2730
+ }
2731
+ catch {
2732
+ manual = undefined;
2733
+ }
2734
+ }
2735
+ const adaptive = want?.visible ? want.spatialLayer : null;
2736
+ if (adaptive === null || adaptive === undefined)
2737
+ return manual ?? { spatialLayer: -1, temporalLayer: -1 };
2738
+ if (!manual || manual.spatialLayer < 0)
2739
+ return { spatialLayer: adaptive };
2740
+ return { ...manual, spatialLayer: Math.min(manual.spatialLayer, adaptive) };
2741
+ }
2742
+ // ------------------------------------------------------- audio processor
2743
+ /**
2744
+ * Send the microphone through `processor` first (LiveKit's setProcessor, Agora's extensions): above all noise
2745
+ * suppression -- `createNoiseSuppressor()` from `@voltkit-rtc/client/noise-suppression`. Applies to the microphone
2746
+ * published now (swapped in place, no gap for listeners) and to every one after it: another device, a reconnect, a
2747
+ * new quality. `null` sends the microphone as it is again and lets the processor go. A processor that fails never
2748
+ * keeps the microphone off: it is sent as it is, and `error` says why.
2749
+ */
2750
+ async setAudioProcessor(processor) {
2751
+ await this.micTask(async () => {
2752
+ const previous = this.audioProcessor;
2753
+ this.audioProcessor = processor;
2754
+ const producer = this.micProducer();
2755
+ const device = producer ? this.deviceTrackOf(producer) : null;
2756
+ if (producer && device && !producer.closed) {
2757
+ const sent = await this.processMic(device);
2758
+ await producer.replaceTrack({ track: sent });
2759
+ this.micRaw = sent !== device ? device : null;
2760
+ }
2761
+ if (previous && previous !== processor)
2762
+ await Promise.resolve(previous.destroy()).catch(() => undefined);
2763
+ });
2764
+ }
2765
+ /** The processor in use now, or null. */
2766
+ get audioProcessorInUse() {
2767
+ return this.audioProcessor;
2768
+ }
2769
+ /** The track to send for the microphone's device track `track`: processed when there is a processor that works. */
2770
+ async processMic(track) {
2771
+ const processor = this.audioProcessor;
2772
+ if (!processor)
2773
+ return track;
2774
+ try {
2775
+ return await processor.process(track);
2776
+ }
2777
+ catch (err) {
2778
+ this.emit('error', new Error(`audio processor ${processor.name} failed, sending the microphone as it is: ${err?.message ?? err}`));
2779
+ return track;
2780
+ }
2781
+ }
2782
+ /** The device's own track behind a producer: for the microphone, the one before any processor. */
2783
+ deviceTrackOf(producer) {
2784
+ const source = producer.appData.source;
2785
+ if (source === 'mic')
2786
+ return this.micRaw ?? producer.track;
2787
+ return source === 'camera' && producer.track ? this.cameraSource(producer.track) : producer.track;
2788
+ }
2789
+ /**
2790
+ * Send the camera through `processor` first (LiveKit's setProcessor, Agora's and TRTC's video extensions): a blurred
2791
+ * or replaced background, beauty -- `createVideoEffects()` from `@voltkit-rtc/client/video-effects`. The processed
2792
+ * picture is what viewers get and what `local` shows. Applies to the camera published now (swapped in place: the
2793
+ * same size, so no pause for viewers) and to every one after it: `switchDevice`, `replaceVideoTrack`, a reconnect, a
2794
+ * new quality. `null` sends the camera as it is again and lets the processor go. A processor that fails never keeps
2795
+ * the camera off: it is sent as it is, and `error` says why.
2796
+ */
2797
+ async setVideoProcessor(processor) {
2798
+ await this.cameraTask(async () => {
2799
+ const previous = this.videoProcessor;
2800
+ this.videoProcessor = processor;
2801
+ const producer = this.cameraProducer();
2802
+ const shown = producer?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
2803
+ if (shown && shown.readyState === 'live') {
2804
+ const source = this.cameraSource(shown);
2805
+ const before = this.camOut;
2806
+ const paused = producer ? producer.paused : !shown.enabled;
2807
+ // Made afresh, by the new processor.
2808
+ this.camRaw = null;
2809
+ this.camOut = null;
2810
+ const sent = await this.processCamera(source);
2811
+ sent.enabled = !paused;
2812
+ if (producer && !producer.closed && producer.track !== sent)
2813
+ await producer.replaceTrack({ track: sent });
2814
+ this.adoptVideoTrack(sent);
2815
+ if (before && before !== sent)
2816
+ before.stop();
2817
+ }
2818
+ if (previous && previous !== processor)
2819
+ await Promise.resolve(previous.destroy()).catch(() => undefined);
2820
+ });
2821
+ }
2822
+ /** The video processor in use now, or null. */
2823
+ get videoProcessorInUse() {
2824
+ return this.videoProcessor;
2825
+ }
2826
+ /** The camera picture behind `track`: the one a video processor draws from, when `track` is its picture. */
2827
+ cameraSource(track) {
2828
+ return track === this.camOut && this.camRaw ? this.camRaw : track;
2829
+ }
2830
+ /**
2831
+ * The picture to send for the camera picture `source`: a video processor's, when there is one that works. The one
2832
+ * already made from `source` is kept (a reconnect, a new quality); otherwise it is made afresh.
2833
+ */
2834
+ async processCamera(source) {
2835
+ const processor = this.videoProcessor;
2836
+ if (!processor) {
2837
+ this.camRaw = null;
2838
+ this.camOut = null;
2839
+ return source;
2840
+ }
2841
+ if (this.camRaw === source && this.camOut?.readyState === 'live')
2842
+ return this.camOut;
2843
+ try {
2844
+ const out = await processor.process(source);
2845
+ // The processor draws from the camera whether or not the camera is paused: its picture carries the pause.
2846
+ source.enabled = true;
2847
+ this.camRaw = source;
2848
+ this.camOut = out;
2849
+ return out;
2850
+ }
2851
+ catch (err) {
2852
+ this.emit('error', new Error(`video processor ${processor.name} failed, sending the camera as it is: ${err?.message ?? err}`));
2853
+ this.camRaw = null;
2854
+ this.camOut = null;
2855
+ return source;
2856
+ }
2857
+ }
2858
+ /** Stop the client's own tracks, and the camera behind a video processor's picture (not in the stream itself). */
2859
+ stopLocalTracks() {
2860
+ this.localStream?.getTracks().forEach((t) => t.stop());
2861
+ this.camRaw?.stop();
2862
+ this.camOut?.stop();
2863
+ this.camRaw = null;
2864
+ this.camOut = null;
2865
+ }
2866
+ // ------------------------------------------------------- diagnostics
2867
+ /**
2868
+ * A snapshot for a support ticket (what Agora's log upload and Twilio's Insights are used for): the SDK version and
2869
+ * browser, the session (room, identity, role, state), how each media transport is connected (UDP, TCP or relayed),
2870
+ * the round trip and upload now, what is published and received, and the last 200 notable events (state changes,
2871
+ * errors, recoveries, fallbacks, warnings) with their times. Plain JSON: `JSON.stringify` it and attach it. Holds
2872
+ * no tokens and no media.
2873
+ */
2874
+ async getDiagnostics() {
2875
+ const transport = async (t) => {
2876
+ if (!t || t.closed)
2877
+ return null;
2878
+ let path = null;
2879
+ try {
2880
+ path = (0, preflight_js_1.connectionPath)((await t.getStats()).values());
2881
+ }
2882
+ catch {
2883
+ // Best effort.
2884
+ }
2885
+ return { state: t.connectionState, path };
2886
+ };
2887
+ let network = null;
2888
+ try {
2889
+ const reports = await this.transportStats();
2890
+ const current = new Map([...reports].map(([key, report]) => [key, (0, network_js_1.readTransportStats)(report)]));
2891
+ if (current.size > 0) {
2892
+ const q = (0, network_js_1.networkQualityOf)(current, new Map(), this.producers.size > 0);
2893
+ network = { rttMs: q.rttMs, availableKbps: q.availableKbps };
2894
+ }
2895
+ }
2896
+ catch {
2897
+ // Best effort.
2898
+ }
2899
+ const join = this.joinResult;
2900
+ return {
2901
+ sdk: diagnostics_js_1.SDK_VERSION,
2902
+ at: Date.now(),
2903
+ userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
2904
+ connectionState: this.state,
2905
+ room: join?.roomId ?? null,
2906
+ identity: join?.identity ?? null,
2907
+ role: join?.role ?? null,
2908
+ transports: { send: await transport(this.sendTransport), recv: await transport(this.recvTransport) },
2909
+ network,
2910
+ published: [...this.producers.values()].filter((p) => !p.closed)
2911
+ .map((p) => ({ source: String(p.appData.source ?? p.kind), kind: p.kind, paused: p.paused })),
2912
+ received: [...this.consumers.entries()].filter(([, c]) => !c.closed).map(([producerId, c]) => ({
2913
+ identity: this.remoteInfo.get(producerId)?.identity ?? '',
2914
+ source: this.remoteSources.get(producerId) ?? (c.kind === 'audio' ? 'mic' : 'camera'),
2915
+ kind: c.kind,
2916
+ paused: c.paused || this.adaptivePaused.has(producerId),
2917
+ })),
2918
+ log: this.diagnosticsLog.list(),
2919
+ };
2920
+ }
2921
+ // ------------------------------------------------------- autoplay
2922
+ /**
2923
+ * Play a `<video>` or `<audio>` showing a remote track, the way autoplay rules allow (Agora's and Tencent TRTC's
2924
+ * autoplay handling). Resolves true when it plays. When the browser refuses until the viewer interacts with the
2925
+ * page, resolves false, emits `autoplayFailed` with a `resume()` for a "Tap to unmute" button, and plays it -- with
2926
+ * every other refused element -- on the next tap, click or key press by itself.
2927
+ */
2928
+ async play(element) {
2929
+ try {
2930
+ await element.play();
2931
+ this.autoplayBlocked.delete(element);
2932
+ return true;
2933
+ }
2934
+ catch (err) {
2935
+ if (err?.name !== 'NotAllowedError')
2936
+ throw err;
2937
+ this.autoplayBlocked.add(element);
2938
+ this.watchGesture(true);
2939
+ this.emit('autoplayFailed', { element, resume: async () => { await this.play(element); } });
2940
+ return false;
2941
+ }
2942
+ }
2943
+ watchGesture(on) {
2944
+ if (typeof document === 'undefined' || on === this.gestureWatchOn)
2945
+ return;
2946
+ this.gestureWatchOn = on;
2947
+ for (const type of ['pointerdown', 'keydown', 'touchend']) {
2948
+ if (on)
2949
+ document.addEventListener(type, this.onGesture, { capture: true });
2950
+ else
2951
+ document.removeEventListener(type, this.onGesture, { capture: true });
2952
+ }
2953
+ }
2954
+ async retryAutoplay() {
2955
+ for (const element of [...this.autoplayBlocked]) {
2956
+ try {
2957
+ await element.play();
2958
+ this.autoplayBlocked.delete(element);
2959
+ }
2960
+ catch {
2961
+ // Still refused (or the element went away); the next gesture tries again.
2962
+ if (!element.isConnected)
2963
+ this.autoplayBlocked.delete(element);
2964
+ }
2965
+ }
2966
+ if (this.autoplayBlocked.size === 0)
2967
+ this.watchGesture(false);
2968
+ }
2969
+ // ------------------------------------------------------- playout delay
2970
+ /**
2971
+ * Trade a little delay for smoother playback (Tencent TRTC's playoutDelay): every remote track is buffered about
2972
+ * `ms` before it plays, which rides out the jitter of a mobile network without freezes. For an audience watching a
2973
+ * stream, 300-500 ms is a good value; people talking with each other want `null` (the browser's own, as small as
2974
+ * it can keep). Applies to what is received now and later. Browsers without the setting (before Chrome 114) keep
2975
+ * their own; 0-4000 ms.
2976
+ */
2977
+ setPlayoutDelay(ms) {
2978
+ this.playoutDelayMs = ms === null ? null : Math.min(4000, Math.max(0, Math.round(ms)));
2979
+ for (const consumer of this.consumers.values())
2980
+ this.applyPlayoutDelay(consumer.rtpReceiver);
2981
+ }
2982
+ applyPlayoutDelay(receiver) {
2983
+ if (!receiver || !('jitterBufferTarget' in receiver))
2984
+ return;
2985
+ try {
2986
+ receiver.jitterBufferTarget = this.playoutDelayMs;
2987
+ }
2988
+ catch {
2989
+ // Out of the browser's range: it keeps its own.
2990
+ }
2991
+ }
2992
+ // ------------------------------------------------------- audio levels
2993
+ /** Levels are read only while someone listens and the session is connected. */
2994
+ syncLevelWatch() {
2995
+ const wanted = !this.closedByUs && this.state === 'connected' &&
2996
+ (this.listenerCount('audioLevels') > 0 || this.listenerCount('activeSpeakers') > 0);
2997
+ if (wanted && !this.levelTimer) {
2998
+ this.levelTimer = setInterval(() => void this.sampleAudioLevels(), speakers_js_1.AUDIO_LEVEL_INTERVAL_MS);
2999
+ this.levelTimer.unref?.();
3000
+ }
3001
+ else if (!wanted) {
3002
+ this.stopLevelWatch();
3003
+ }
3004
+ }
3005
+ stopLevelWatch() {
3006
+ if (this.levelTimer)
3007
+ clearInterval(this.levelTimer);
3008
+ this.levelTimer = null;
3009
+ this.speakers.reset();
3010
+ }
3011
+ /**
3012
+ * One reading: each remote audio from its receiver's synchronization sources (the most recent packet's level, no
3013
+ * statistics report needed), the own microphone from its sender's media-source statistics.
3014
+ */
3015
+ async sampleAudioLevels() {
3016
+ if (this.levelSampling)
3017
+ return;
3018
+ this.levelSampling = true;
3019
+ try {
3020
+ const levels = [];
3021
+ for (const [producerId, consumer] of this.consumers) {
3022
+ if (consumer.kind !== 'audio' || consumer.closed)
3023
+ continue;
3024
+ const info = this.remoteInfo.get(producerId);
3025
+ if (!info)
3026
+ continue;
3027
+ const level = info.paused ? 0 : receiverLevel(consumer.rtpReceiver);
3028
+ levels.push({ identity: info.identity, producerId, source: this.remoteSources.get(producerId) ?? 'mic', level, speaking: level > speakers_js_1.SPEAKING_LEVEL, local: false });
3029
+ }
3030
+ const mic = this.micProducer();
3031
+ const self = this.joinResult?.identity;
3032
+ if (mic && !mic.paused && self) {
3033
+ const level = await senderLevel(mic);
3034
+ levels.push({ identity: self, producerId: mic.id, source: 'mic', level, speaking: level > speakers_js_1.SPEAKING_LEVEL, local: true });
3035
+ }
3036
+ if (!this.levelTimer)
3037
+ return;
3038
+ if (this.listenerCount('audioLevels') > 0)
3039
+ this.emit('audioLevels', levels);
3040
+ const loudest = new Map();
3041
+ for (const l of levels)
3042
+ if (l.source === 'mic')
3043
+ loudest.set(l.identity, Math.max(loudest.get(l.identity) ?? 0, l.level));
3044
+ const speakers = this.speakers.update(loudest);
3045
+ if (speakers)
3046
+ this.emit('activeSpeakers', { speakers });
3047
+ }
3048
+ catch {
3049
+ // Best effort; the next reading tries again.
3050
+ }
3051
+ finally {
3052
+ this.levelSampling = false;
3053
+ }
3054
+ }
3055
+ // ------------------------------------------------------- media health
3056
+ /** The published devices are sampled only while someone listens and the session is connected. */
3057
+ syncHealthWatch() {
3058
+ const wanted = !this.closedByUs && this.state === 'connected' && this.listenerCount('mediaWarning') > 0;
3059
+ if (wanted && !this.healthTimer) {
3060
+ this.healthTimer = setInterval(() => void this.sampleHealth(), health_js_1.HEALTH_SAMPLE_MS);
3061
+ this.healthTimer.unref?.();
3062
+ }
3063
+ else if (!wanted) {
3064
+ this.stopHealthWatch(this.listenerCount('mediaWarning') > 0 && !this.closedByUs);
3065
+ }
3066
+ }
3067
+ /** Stop sampling; `report` says each active warning cleared (nothing is said after close). */
3068
+ stopHealthWatch(report) {
3069
+ if (this.healthTimer)
3070
+ clearInterval(this.healthTimer);
3071
+ this.healthTimer = null;
3072
+ for (const timer of this.interruptions.values())
3073
+ clearTimeout(timer);
3074
+ this.interruptions.clear();
3075
+ if (report)
3076
+ this.health.clearAll();
3077
+ }
3078
+ async sampleHealth() {
3079
+ if (this.healthSampling)
3080
+ return;
3081
+ this.healthSampling = true;
3082
+ try {
3083
+ const mic = this.micProducer();
3084
+ const camera = this.cameraProducer();
3085
+ // A processed microphone (noise suppression) is near-silent between words by design: no mic-silent then.
3086
+ const micLevel = mic && !mic.paused && !this.audioProcessor ? (0, health_js_1.readMediaSource)((await mic.getStats()).values()).level : null;
3087
+ // A processed picture runs at the processor's pace, not the camera's: no camera-fps-low then.
3088
+ const cameraFps = camera && !camera.paused && !this.videoProcessor ? (0, health_js_1.readMediaSource)((await camera.getStats()).values()).fps : null;
3089
+ if (!this.healthTimer)
3090
+ return;
3091
+ this.health.feed({ micLevel, cameraFps, askedFps: this.videoFrameRate });
3092
+ // An interrupted device that is no longer published never says it came back: that is over too.
3093
+ if (!mic)
3094
+ this.health.interrupted('mic-interrupted', false);
3095
+ if (!camera)
3096
+ this.health.interrupted('camera-interrupted', false);
3097
+ }
3098
+ catch {
3099
+ // Best effort; the next sample tries again.
3100
+ }
3101
+ finally {
3102
+ this.healthSampling = false;
3103
+ }
3104
+ }
3105
+ // ---------------------------------------------------------------- devices
3106
+ /**
3107
+ * The cameras, microphones and speakers the browser offers, optionally of one kind. Labels are empty until the
3108
+ * page has been granted camera or microphone access.
3109
+ */
3110
+ listDevices(kind) {
3111
+ return (0, devices_js_1.enumerate)(kind);
3112
+ }
3113
+ /**
3114
+ * Publish from another camera or microphone (LiveKit's switchActiveDevice, Agora's setDevice). The device is
3115
+ * captured with the constraints publish was given (size, frame rate, echo cancellation) and its track replaces the
3116
+ * published one: a camera of the same size swaps with no pause for viewers, a microphone always does. The track it
3117
+ * replaces is stopped, releasing that device; the client's own stream follows (`localStreamChanged`), and a
3118
+ * reconnect captures the chosen device again. A camera or microphone that is switched off stays off.
3119
+ *
3120
+ * For a camera published as a canvas or another processed track, capture the new camera yourself and pass your
3121
+ * processed track to `replaceVideoTrack`; this rejects rather than replace your picture with the raw camera.
3122
+ * Rejects when that kind is not being published.
3123
+ */
3124
+ async switchDevice(kind, deviceId) {
3125
+ const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
3126
+ if (!md?.getUserMedia)
3127
+ throw new Error('switchDevice: this browser cannot capture media');
3128
+ if (kind === 'videoinput') {
3129
+ const shown = this.cameraProducer()?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
3130
+ // With a video processor, the camera it draws from.
3131
+ const current = shown ? this.cameraSource(shown) : null;
3132
+ if (!current)
3133
+ throw new Error('switchDevice: no camera is being published');
3134
+ if (!(0, devices_js_1.deviceIdOf)(current)) {
3135
+ throw new Error('switchDevice: the published video is not a camera capture (a canvas or processed track); capture the camera yourself and call replaceVideoTrack');
3136
+ }
3137
+ const video = (0, devices_js_1.constraintsFor)(this.publishConstraints?.video, deviceId);
3138
+ const track = (await md.getUserMedia({ video })).getVideoTracks()[0];
3139
+ if (!track)
3140
+ throw new Error('switchDevice: the camera gave no video');
3141
+ try {
3142
+ await this.replaceVideoTrack(track);
3143
+ }
3144
+ catch (err) {
3145
+ track.stop();
3146
+ throw err;
3147
+ }
3148
+ if (current !== track)
3149
+ current.stop();
3150
+ this.publishConstraints = { ...(this.publishConstraints ?? {}), video };
3151
+ return;
3152
+ }
3153
+ await this.micTask(async () => {
3154
+ const producer = this.micProducer();
3155
+ if (!producer)
3156
+ throw new Error('switchDevice: no microphone is being published');
3157
+ const audio = (0, devices_js_1.constraintsFor)(this.publishConstraints?.audio, deviceId);
3158
+ const track = (await md.getUserMedia({ audio })).getAudioTracks()[0];
3159
+ if (!track)
3160
+ throw new Error('switchDevice: the microphone gave no audio');
3161
+ if (producer.closed || this.micProducer() !== producer) {
3162
+ track.stop();
3163
+ throw new Error('switchDevice: the microphone was unpublished meanwhile');
3164
+ }
3165
+ await this.adoptAudioTrack(producer, track);
3166
+ this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
3167
+ });
3168
+ }
3169
+ /** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
3170
+ async adoptAudioTrack(producer, track) {
3171
+ const previous = this.micRaw ?? producer.track;
3172
+ const sent = await this.processMic(track);
3173
+ await producer.replaceTrack({ track: sent });
3174
+ this.micRaw = sent !== track ? track : null;
3175
+ const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
3176
+ this.localStream = new MediaStream([track, ...video]);
3177
+ this.emit('localStreamChanged', this.localStream);
3178
+ if (previous && previous !== track)
3179
+ previous.stop();
3180
+ this.watchTrackEnded(producer);
3181
+ }
3182
+ /** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
3183
+ watchTrackEnded(producer) {
3184
+ // The device's track, not the processed one a processor sends: that is the one a device ends.
3185
+ const track = this.deviceTrackOf(producer);
3186
+ if (!track || typeof track.addEventListener !== 'function')
3187
+ return;
3188
+ track.addEventListener('ended', () => { void this.onTrackEnded(producer, track); }, { once: true });
3189
+ // The system taking the device (a call, another app) mutes the track; held a moment, it is reported.
3190
+ const which = producer.appData.source === 'mic' ? 'mic' : producer.appData.source === 'camera' ? 'camera' : null;
3191
+ if (!which)
3192
+ return;
3193
+ const code = which === 'mic' ? 'mic-interrupted' : 'camera-interrupted';
3194
+ const current = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track;
3195
+ track.addEventListener('mute', () => {
3196
+ if (!current() || this.interruptions.has(which))
3197
+ return;
3198
+ this.interruptions.set(which, setTimeout(() => {
3199
+ this.interruptions.delete(which);
3200
+ if (current() && track.muted)
3201
+ this.health.interrupted(code, true);
3202
+ }, health_js_1.INTERRUPTED_AFTER_MS));
3203
+ });
3204
+ track.addEventListener('unmute', () => {
3205
+ const timer = this.interruptions.get(which);
3206
+ if (timer)
3207
+ clearTimeout(timer);
3208
+ this.interruptions.delete(which);
3209
+ if (current())
3210
+ this.health.interrupted(code, false);
3211
+ });
3212
+ }
3213
+ /**
3214
+ * A published camera or microphone stopped from outside. The microphone comes back from the system default (Chime
3215
+ * keeps audio flowing, LiveKit moves to the first available device); a camera is switched off and reported, never
3216
+ * swapped for another camera on its own (Chime stops it the same way).
3217
+ */
3218
+ async onTrackEnded(producer, track) {
3219
+ const live = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track && this.producers.get(producer.id) === producer;
3220
+ if (!live())
3221
+ return;
3222
+ const deviceId = (0, devices_js_1.deviceIdOf)(track);
3223
+ if (producer.appData.source === 'mic') {
3224
+ await this.micTask(async () => {
3225
+ if (!live())
3226
+ return;
3227
+ try {
3228
+ const md = navigator.mediaDevices;
3229
+ const audio = (0, devices_js_1.constraintsFor)(this.publishConstraints?.audio, null);
3230
+ const next = (await md.getUserMedia({ audio })).getAudioTracks()[0];
3231
+ if (!next)
3232
+ throw new Error('no microphone');
3233
+ if (!live()) {
3234
+ next.stop();
3235
+ return;
3236
+ }
3237
+ await this.adoptAudioTrack(producer, next);
3238
+ this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
3239
+ this.emit('localTrackEnded', { kind: 'audio', deviceId, recovered: true });
3240
+ }
3241
+ catch {
3242
+ if (!live())
3243
+ return;
3244
+ await this.setMicEnabled(false).catch(() => undefined);
3245
+ this.emit('localTrackEnded', { kind: 'audio', deviceId, recovered: false });
3246
+ }
3247
+ });
3248
+ return;
3249
+ }
3250
+ if (producer.appData.source === 'camera') {
3251
+ await this.setCamEnabled(false).catch(() => undefined);
3252
+ if (!this.closedByUs)
3253
+ this.emit('localTrackEnded', { kind: 'video', deviceId, recovered: false });
3254
+ }
3255
+ }
3256
+ /** devicesChanged is watched only while someone listens. */
3257
+ syncDeviceWatch() {
3258
+ const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
3259
+ const wanted = !this.closedByUs && this.listenerCount('devicesChanged') > 0 && typeof md?.addEventListener === 'function';
3260
+ if (wanted && !this.deviceWatchOn) {
3261
+ this.deviceWatchOn = true;
3262
+ md.addEventListener('devicechange', this.onDeviceChange);
3263
+ void (0, devices_js_1.enumerate)().then((list) => { if (this.deviceWatchOn)
3264
+ this.knownDevices = list; }).catch(() => undefined);
3265
+ }
3266
+ else if (!wanted) {
3267
+ this.stopDeviceWatch();
3268
+ }
3269
+ }
3270
+ stopDeviceWatch() {
3271
+ if (!this.deviceWatchOn)
3272
+ return;
3273
+ this.deviceWatchOn = false;
3274
+ navigator.mediaDevices?.removeEventListener?.('devicechange', this.onDeviceChange);
3275
+ }
3276
+ async refreshDevices() {
3277
+ let now;
3278
+ try {
3279
+ now = await (0, devices_js_1.enumerate)();
3280
+ }
3281
+ catch {
3282
+ return;
3283
+ }
3284
+ if (!this.deviceWatchOn)
3285
+ return;
3286
+ const { added, removed } = (0, devices_js_1.diffDevices)(this.knownDevices, now);
3287
+ this.knownDevices = now;
3288
+ if (added.length > 0 || removed.length > 0)
3289
+ this.emit('devicesChanged', { devices: now, added, removed });
3290
+ }
3291
+ /** Adaptive stream's decision for a video, with the audio-only fallback holding remote cameras back. */
3292
+ withFallback(producerId, want) {
3293
+ if (this.protection.active && this.remoteSources.get(producerId) === 'camera')
3294
+ return { visible: false, spatialLayer: null };
3295
+ return want;
3296
+ }
3297
+ /**
3298
+ * Audio-only fallback (like Agora's AUDIO_ONLY stream fallback): on unless `audioOnlyFallback: false` was given.
3299
+ * While this viewer's received sound breaks up badly -- a quarter of it lost over about 15 s -- remote camera
3300
+ * videos are paused at the server (screen shares stay) so what bandwidth there is goes to the audio; they come
3301
+ * back once it has recovered for a while, holding longer if it falls back again soon. Each held video is reported
3302
+ * by `streamFallback`. `false` brings any held video back now and stops watching.
3303
+ */
3304
+ setAudioOnlyFallback(enabled) {
3305
+ this.audioOnlyEnabled = enabled;
3306
+ if (!enabled)
3307
+ this.protection.release();
3308
+ this.syncProtection();
3309
+ }
3310
+ /**
3311
+ * The publisher side of the audio-only fallback (Agora's setLocalPublishFallbackOption(AUDIO_ONLY)), off by default:
3312
+ * while this client's own sound breaks up on the way to the server -- a quarter of it lost over about 15 s -- its
3313
+ * camera is paused (viewers see it paused) so the upload carries the voice, and resumed once it recovers, holding
3314
+ * longer after a relapse. Reported by `publishFallback`. A camera you or a moderator switch off meanwhile stays off.
3315
+ * `false` brings a held camera back now.
3316
+ */
3317
+ setPublishFallback(enabled) {
3318
+ this.publishFallbackEnabled = enabled;
3319
+ if (!enabled)
3320
+ this.publishProtection.release();
3321
+ this.syncPublishProtection();
3322
+ }
3323
+ syncPublishProtection() {
3324
+ const wanted = this.publishFallbackEnabled && !this.closedByUs && this.state === 'connected' &&
3325
+ this.micProducer() !== undefined && (this.cameraProducer() !== undefined || this.publishProtection.active);
3326
+ if (wanted && !this.publishProtectionTimer) {
3327
+ this.publishProtectionTimer = setInterval(() => void this.samplePublishProtection(), fallback_js_1.PROTECT_SAMPLE_MS);
3328
+ this.publishProtectionTimer.unref?.();
3329
+ }
3330
+ else if (!wanted && this.publishProtectionTimer) {
3331
+ clearInterval(this.publishProtectionTimer);
3332
+ this.publishProtectionTimer = null;
3333
+ }
3334
+ }
3335
+ async samplePublishProtection() {
3336
+ const transport = this.sendTransport;
3337
+ if (this.publishProtectionSampling || !transport || transport.closed)
3338
+ return;
3339
+ this.publishProtectionSampling = true;
3340
+ try {
3341
+ const report = await transport.getStats();
3342
+ if (transport !== this.sendTransport || !this.publishProtectionTimer)
3343
+ return;
3344
+ this.publishProtection.feed((0, fallback_js_1.readOutboundAudio)(report.values()));
3345
+ }
3346
+ catch {
3347
+ // Best effort.
3348
+ }
3349
+ finally {
3350
+ this.publishProtectionSampling = false;
3351
+ }
3352
+ }
3353
+ /** The publisher-side fallback decided: hold the camera, or bring it back if it is still ours to bring back. */
3354
+ async onPublishProtection(active) {
3355
+ const camera = this.cameraProducer();
3356
+ if (active) {
3357
+ if (!camera || camera.paused) {
3358
+ this.syncPublishProtection();
3359
+ return;
3360
+ }
3361
+ this.cameraHeldByFallback = true;
3362
+ camera.pause();
3363
+ this.emit('publishFallback', { fallback: true });
3364
+ await this.rpc('pauseProducer', { producerId: camera.id, paused: true }).catch(() => undefined);
3365
+ this.emitLocalMedia();
3366
+ }
3367
+ else if (this.cameraHeldByFallback) {
3368
+ this.cameraHeldByFallback = false;
3369
+ if (camera && camera.paused && !this.forcedOff.video) {
3370
+ camera.resume();
3371
+ await this.rpc('pauseProducer', { producerId: camera.id, paused: false }).catch(() => undefined);
3372
+ this.emitLocalMedia();
3373
+ }
3374
+ this.emit('publishFallback', { fallback: false });
3375
+ }
3376
+ this.syncPublishProtection();
3377
+ }
3378
+ /** Whether the audio-only fallback is holding remote cameras back now. */
3379
+ get audioOnlyFallbackActive() {
3380
+ return this.protection.active;
3381
+ }
3382
+ /** The audio-only fallback decided: hold remote cameras back, or let them come back. */
3383
+ onAudioProtection(active) {
3384
+ for (const [producerId, consumer] of this.consumers) {
3385
+ if (consumer.kind !== 'video' || this.remoteSources.get(producerId) !== 'camera')
3386
+ continue;
3387
+ const want = this.withFallback(producerId, this.adaptive.current(producerId) ?? { visible: true, spatialLayer: null });
3388
+ void this.applyAdaptive(producerId, want);
3389
+ if (active && !this.protectionHeld.has(producerId)) {
3390
+ this.protectionHeld.add(producerId);
3391
+ this.emitFallback(producerId, true, 'audio-protection');
3392
+ }
3393
+ else if (!active && this.protectionHeld.delete(producerId)) {
3394
+ this.emitFallback(producerId, false, 'audio-protection');
3395
+ }
3396
+ }
3397
+ this.syncProtection();
3398
+ }
3399
+ emitFallback(producerId, fallback, reason) {
3400
+ const info = this.remoteInfo.get(producerId);
3401
+ this.emit('streamFallback', {
3402
+ producerId,
3403
+ identity: info?.identity ?? '',
3404
+ source: this.remoteSources.get(producerId) ?? 'camera',
3405
+ fallback,
3406
+ reason,
3407
+ });
3408
+ }
3409
+ /** Withdraw every held-back report (teardown; nothing is said after close) and forget the decisions. */
3410
+ clearFallbacks() {
3411
+ if (!this.closedByUs) {
3412
+ for (const producerId of this.bandwidthCut)
3413
+ this.emitFallback(producerId, false, 'bandwidth');
3414
+ for (const producerId of this.protectionHeld)
3415
+ this.emitFallback(producerId, false, 'audio-protection');
3416
+ }
3417
+ this.bandwidthCut.clear();
3418
+ this.protectionHeld.clear();
3419
+ this.protection.reset();
3420
+ this.stopProtection();
3421
+ }
3422
+ /**
3423
+ * The received audio is sampled only while the fallback could matter: enabled, connected, with remote audio to
3424
+ * judge by and a remote camera to hold back (or one held back already).
3425
+ */
3426
+ syncProtection() {
3427
+ let audio = false;
3428
+ let camera = false;
3429
+ for (const [producerId, consumer] of this.consumers) {
3430
+ if (consumer.closed)
3431
+ continue;
3432
+ if (consumer.kind === 'audio')
3433
+ audio = true;
3434
+ else if (this.remoteSources.get(producerId) === 'camera')
3435
+ camera = true;
3436
+ }
3437
+ const wanted = this.audioOnlyEnabled && !this.closedByUs && this.state === 'connected' && audio && (camera || this.protection.active);
3438
+ if (wanted && !this.protectionTimer) {
3439
+ this.protectionTimer = setInterval(() => void this.sampleProtection(), fallback_js_1.PROTECT_SAMPLE_MS);
3440
+ // Background sampling never keeps a server-side or test process alive (Node); browsers have no unref.
3441
+ this.protectionTimer.unref?.();
3442
+ }
3443
+ else if (!wanted) {
3444
+ this.stopProtection();
3445
+ }
3446
+ }
3447
+ stopProtection() {
3448
+ if (this.protectionTimer)
3449
+ clearInterval(this.protectionTimer);
3450
+ this.protectionTimer = null;
3451
+ }
3452
+ async sampleProtection() {
3453
+ const transport = this.recvTransport;
3454
+ if (this.protectionSampling || !transport || transport.closed)
3455
+ return;
3456
+ this.protectionSampling = true;
3457
+ try {
3458
+ const report = await transport.getStats();
3459
+ if (transport !== this.recvTransport || !this.protectionTimer)
3460
+ return;
3461
+ this.protection.feed((0, fallback_js_1.readInboundAudio)(report.values()));
3462
+ }
3463
+ catch {
3464
+ // Statistics are best effort; the next sample tries again.
3465
+ }
3466
+ finally {
3467
+ this.protectionSampling = false;
3468
+ }
3469
+ }
3470
+ /** Apply adaptive stream's decision for one video: pause it, or resume it at its layer. */
3471
+ async applyAdaptive(producerId, want) {
3472
+ const consumer = this.consumers.get(producerId);
3473
+ // Not received (yet): a consumer made later starts from the decision in force.
3474
+ if (!consumer || consumer.kind !== 'video' || consumer.closed || this.closedByUs)
3475
+ return;
3476
+ try {
3477
+ if (!want.visible) {
3478
+ if (this.pauseUnsupported) {
3479
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, spatialLayer: 0, temporalLayer: 0 });
3480
+ return;
3481
+ }
3482
+ if (this.adaptivePaused.has(producerId))
3483
+ return;
3484
+ this.adaptivePaused.add(producerId);
3485
+ await this.rpc('pauseConsumer', { consumerId: consumer.id });
3486
+ return;
3487
+ }
3488
+ if (this.adaptivePaused.delete(producerId))
3489
+ await this.rpc('resumeConsumer', { consumerId: consumer.id });
3490
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...this.effectiveLayers(producerId, want) });
3491
+ }
3492
+ catch (err) {
3493
+ if (this.consumers.get(producerId) !== consumer || consumer.closed || this.closedByUs)
3494
+ return;
3495
+ if (!want.visible && err instanceof errors_js_1.RpcError && err.code === 'unknown-method') {
3496
+ // An older server: drop to the smallest layer instead of pausing.
3497
+ this.adaptivePaused.delete(producerId);
3498
+ this.pauseUnsupported = true;
3499
+ await this.applyAdaptive(producerId, want);
3500
+ return;
3501
+ }
3502
+ this.emit('error', err);
3503
+ }
2225
3504
  }
2226
3505
  // ------------------------------------------------------------- moderation
2227
3506
  // ------------------------------------------------------------ local media
@@ -2269,6 +3548,12 @@ class StreamClient {
2269
3548
  this.music?.setEnabled(false);
2270
3549
  }
2271
3550
  async setKindEnabled(kind, enabled) {
3551
+ // Your own choice about the camera ends the publisher-side fallback's hold on it: it never undoes your choice.
3552
+ if (kind === 'video' && this.cameraHeldByFallback) {
3553
+ this.cameraHeldByFallback = false;
3554
+ this.publishProtection.reset();
3555
+ this.emit('publishFallback', { fallback: false });
3556
+ }
2272
3557
  const generation = this.publishGeneration;
2273
3558
  // Unpublished or closed while waiting: nothing is left to switch, and the
2274
3559
  // call resolves, as it does with nothing published (V072-03).
@@ -2319,6 +3604,7 @@ class StreamClient {
2319
3604
  /** Stop publishing entirely and release the local devices. */
2320
3605
  async unpublish() {
2321
3606
  void this.stopRecording().catch(error => this.emit('error', error));
3607
+ this.layersNeeded.clear();
2322
3608
  // All of it takes effect now; only telling the server is waited for. So
2323
3609
  // publish() and unpublish() act in the order they are called: a publish
2324
3610
  // called while this still waits is not undone by it, and one already
@@ -2338,7 +3624,7 @@ class StreamClient {
2338
3624
  const cleared = this.limitWatch.reset();
2339
3625
  if (cleared)
2340
3626
  this.emit('videoLimited', cleared);
2341
- this.localStream?.getTracks().forEach((t) => t.stop());
3627
+ this.stopLocalTracks();
2342
3628
  this.localStream = null;
2343
3629
  // Forgotten with the stream: kept, a reconnect would ask for the camera
2344
3630
  // and microphone again and publish them, after the app unpublished them.