@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/esm/index.js CHANGED
@@ -9,7 +9,15 @@ import { CameraRecording } from './recording.js';
9
9
  import { Device } from 'mediasoup-client';
10
10
  import { isTerminalError, RpcError } from './errors.js';
11
11
  import { above60fpsCap, AUDIO_SETTINGS, qualityAt, resolvePermissions, } from './media-permissions.js';
12
- import { NetworkQualityWatch } from './network.js';
12
+ import { NetworkQualityWatch, networkQualityOf, readTransportStats } from './network.js';
13
+ import { MediaPathWatch, SocketLiveness } from './link.js';
14
+ import { AdaptiveVideos, pageIsVisible } from './adaptive.js';
15
+ import { AudioProtection, PROTECT_SAMPLE_MS, readInboundAudio, readOutboundAudio } from './fallback.js';
16
+ import { ActiveSpeakers, AUDIO_LEVEL_INTERVAL_MS, SPEAKING_LEVEL } from './speakers.js';
17
+ import { connectionPath, runPreflight, syntheticMedia } from './preflight.js';
18
+ import { DiagnosticsLog, SDK_VERSION } from './diagnostics.js';
19
+ import { HEALTH_SAMPLE_MS, HealthDecisions, INTERRUPTED_AFTER_MS, readMediaSource } from './health.js';
20
+ import { constraintsFor, deviceIdOf, diffDevices, enumerate } from './devices.js';
13
21
  import { socketUrl, TOKEN_EXPIRING_LEAD_MS, tokenExpiresAt } from './token.js';
14
22
  import { chooseCodec, currentEnv, defaultLayerCount, findCodec, h264HardwareEncode, isAppleMobile, isPhoneOrTablet, normalizeCodec, } from './device.js';
15
23
  import { MusicSource } from './music.js';
@@ -24,19 +32,41 @@ export { AUDIO_QUALITIES, AUDIO_SETTINGS, DEFAULT_MEDIA_PERMISSIONS, resolvePerm
24
32
  export { NETWORK_QUALITY_INTERVAL_MS, NETWORK_QUALITY_LEVELS, networkLevel } from './network.js';
25
33
  export { TOKEN_EXPIRING_LEAD_MS, tokenExpiresAt } from './token.js';
26
34
  export { allowedQuality, layerBitrate, videoEncodings, VIDEO_QUALITIES } from './quality.js';
35
+ export { SPEAKING_LEVEL } from './speakers.js';
36
+ export { SDK_VERSION } from './diagnostics.js';
27
37
  /** Where a session stands, in the order a session goes through them. `closed` is final; the `closed` event says why. */
28
38
  export const CONNECTION_STATES = Object.freeze(['idle', 'connecting', 'connected', 'reconnecting', 'closed']);
29
39
  const defaultSource = (kind) => (kind === 'audio' ? 'mic' : 'camera');
40
+ /** A receiver's most recent audio level (0..1), from its synchronization sources: cheap, made for this. */
41
+ function receiverLevel(receiver) {
42
+ const sources = receiver?.getSynchronizationSources?.() ?? [];
43
+ let level = 0;
44
+ for (const s of sources)
45
+ if (typeof s.audioLevel === 'number')
46
+ level = Math.max(level, s.audioLevel);
47
+ return level;
48
+ }
49
+ /** A microphone producer's level now (0..1), from its media-source statistics. */
50
+ async function senderLevel(producer) {
51
+ const stats = await producer.getStats();
52
+ for (const s of stats.values()) {
53
+ if (s.type === 'media-source' && typeof s.audioLevel === 'number')
54
+ return s.audioLevel;
55
+ }
56
+ return 0;
57
+ }
30
58
  // Every event a StreamClient emits. Typed against ClientEvents, so adding an
31
59
  // event without listing it here (or listing one that does not exist) fails to
32
60
  // compile; the contract check compares this list with docs/sdk-contract.json.
33
61
  const EVENT_NAMES = {
62
+ mediaReconnecting: true,
34
63
  connected: true, disconnected: true, connectionStateChanged: true, joined: true,
35
64
  track: true, trackEnded: true, trackMuted: true,
36
65
  participantJoined: true, participantLeft: true, participantMuted: true, participantUpdated: true,
37
66
  roleChanged: true, handRaised: true, forceMuted: true, claimChanged: true, roomMetadataChanged: true,
38
67
  mustReconnect: true, roomClosed: true, kicked: true, streamInterrupted: true, closed: true,
39
- usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true,
68
+ usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true, cpuConstrained: true,
69
+ streamFallback: true, publishFallback: true, devicesChanged: true, localTrackEnded: true, audioLevels: true, activeSpeakers: true, mediaWarning: true, autoplayFailed: true,
40
70
  screenShareStarted: true, screenShareEnded: true, error: true, localMediaChanged: true, localStreamChanged: true,
41
71
  message: true, messageDeleted: true, chatMuted: true, typing: true,
42
72
  };
@@ -48,7 +78,6 @@ const MAX_RECONNECT_ATTEMPTS = 8;
48
78
  const CLOSE_REPLACED = 4001;
49
79
  /** The server ended the session on purpose and said why first: do not reconnect. */
50
80
  const CLOSE_ENDED = 4003;
51
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
52
81
  const isLayer = (value) => Number.isInteger(value) && value >= 0;
53
82
  /**
54
83
  * Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
@@ -91,6 +120,78 @@ function declareAudioQuality(rtpParameters, appData) {
91
120
  export class StreamClient {
92
121
  wsUrl;
93
122
  options;
123
+ backoffWake = null;
124
+ onlineTarget = null;
125
+ online = () => {
126
+ if (this.closedByUs)
127
+ return;
128
+ this.backoffWake?.();
129
+ this.socketLiveness.checkNow();
130
+ };
131
+ socketLiveness = new SocketLiveness({
132
+ active: () => !this.closedByUs && this.state === 'connected',
133
+ ping: () => { void this.rpc('ping').catch(() => undefined); },
134
+ dead: () => this.recoverConnection('socket-timeout'),
135
+ });
136
+ mediaPaths = new MediaPathWatch({
137
+ active: () => !this.closedByUs && this.state === 'connected',
138
+ restartIce: id => this.restartMediaIce(id),
139
+ reconnect: reason => this.recoverConnection(reason),
140
+ onPathChange: (transportId, reconnecting) => {
141
+ if (!this.closedByUs)
142
+ this.emit('mediaReconnecting', { transportId, reconnecting });
143
+ },
144
+ });
145
+ recoverConnection(reason) {
146
+ if (this.closedByUs || !this.joinedOnce || this.state !== 'connected')
147
+ return;
148
+ this.setState('reconnecting');
149
+ if (this.closedByUs)
150
+ return; // a state listener may close synchronously
151
+ this.mediaPaths.reset();
152
+ if (this.closedByUs)
153
+ return;
154
+ this.detachSocket(reason);
155
+ this.networkWatch.set(false);
156
+ this.emit('disconnected', reason);
157
+ void this.scheduleReconnect();
158
+ }
159
+ async restartMediaIce(id) {
160
+ const transport = [this.sendTransport, this.recvTransport].find(t => t?.id === id);
161
+ const socket = this.ws;
162
+ if (!transport || transport.closed || this.closedByUs || this.state !== 'connected')
163
+ return 'skipped';
164
+ const { iceParameters } = await this.rpc('restartIce', { transportId: id });
165
+ if (this.ws !== socket || transport.closed || this.closedByUs ||
166
+ (transport !== this.sendTransport && transport !== this.recvTransport))
167
+ return 'skipped';
168
+ await transport.restartIce({ iceParameters });
169
+ if (this.ws === socket && !this.closedByUs && !transport.closed && transport.connectionState === 'connected') {
170
+ this.mediaPaths.update(id, 'connected');
171
+ }
172
+ return 'restarted';
173
+ }
174
+ watchTransport(transport) {
175
+ transport.on('connectionstatechange', state => {
176
+ if (transport !== this.sendTransport && transport !== this.recvTransport)
177
+ return;
178
+ if (!this.closedByUs && this.state === 'connected')
179
+ this.mediaPaths.update(transport.id, state);
180
+ });
181
+ transport.observer?.on('close', () => this.mediaPaths.forget(transport.id));
182
+ }
183
+ waitBackoff(ms) {
184
+ return new Promise(resolve => {
185
+ const finish = () => {
186
+ clearTimeout(timer);
187
+ if (this.backoffWake === finish)
188
+ this.backoffWake = null;
189
+ resolve();
190
+ };
191
+ const timer = setTimeout(finish, ms);
192
+ this.backoffWake = finish;
193
+ });
194
+ }
94
195
  ws = null;
95
196
  device = null;
96
197
  sendTransport = null;
@@ -101,6 +202,8 @@ export class StreamClient {
101
202
  // server only remembers the last one, rejecting consumes on the first.
102
203
  sendTransportPending = null;
103
204
  recvTransportPending = null;
205
+ /** Transports the join made (P3-6), each used once by the first transport of its direction. */
206
+ preparedTransports = null;
104
207
  pending = new Map();
105
208
  listeners = new Map();
106
209
  consumers = new Map();
@@ -163,6 +266,74 @@ export class StreamClient {
163
266
  * the full picture.
164
267
  */
165
268
  preferredLayers = new Map();
269
+ /**
270
+ * Adaptive stream (attachVideo): each attached remote video at the size it is
271
+ * shown, and paused at the server while no attached element is visible.
272
+ */
273
+ adaptive = new AdaptiveVideos({
274
+ layers: (id) => this.remoteVideoLayers.get(id) ?? [],
275
+ apply: (id, want) => { void this.applyAdaptive(id, this.withFallback(id, want)); },
276
+ pixelRatio: () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1),
277
+ pageVisible: pageIsVisible,
278
+ });
279
+ /** Audio-only fallback: whether it may happen, its decisions, and the sampling that feeds them. */
280
+ audioOnlyEnabled = true;
281
+ protection = new AudioProtection((active) => this.onAudioProtection(active));
282
+ protectionTimer = null;
283
+ protectionSampling = false;
284
+ /** Publisher-side fallback: off unless asked for; its decisions, timer, and whether it holds the camera now. */
285
+ publishFallbackEnabled = false;
286
+ publishProtection = new AudioProtection((active) => { void this.onPublishProtection(active); });
287
+ publishProtectionTimer = null;
288
+ publishProtectionSampling = false;
289
+ cameraHeldByFallback = false;
290
+ /** Remote videos the media server reports it cut for this viewer's bandwidth. */
291
+ bandwidthCut = new Set();
292
+ /** Remote cameras the audio-only fallback holds back now (reported with fallback: true). */
293
+ protectionHeld = new Set();
294
+ /** audioLevels / activeSpeakers: read only while someone listens. */
295
+ levelTimer = null;
296
+ levelSampling = false;
297
+ speakers = new ActiveSpeakers();
298
+ /** mediaWarning: decisions, the sampling timer, and the system interruptions being timed. */
299
+ health = new HealthDecisions((warning) => this.emit('mediaWarning', warning));
300
+ healthTimer = null;
301
+ healthSampling = false;
302
+ interruptions = new Map();
303
+ /** setAudioProcessor: what the microphone passes through, and the device's own track while it does. */
304
+ audioProcessor = null;
305
+ micRaw = null;
306
+ videoProcessor = null;
307
+ /** With a video processor: the camera (or the app's track) it draws from, and the picture it makes -- sent and shown. */
308
+ camRaw = null;
309
+ camOut = null;
310
+ /** getDiagnostics(): the SDK's recent events, for a support ticket. */
311
+ diagnosticsLog = new DiagnosticsLog();
312
+ /** play(): elements the browser refused to play until the viewer interacts, retried on the next gesture. */
313
+ autoplayBlocked = new Set();
314
+ gestureWatchOn = false;
315
+ onGesture = () => { void this.retryAutoplay(); };
316
+ /** setPlayoutDelay: the receive buffer asked for every remote track, in ms (null: the browser's own). */
317
+ playoutDelayMs = null;
318
+ /** devicesChanged: the device list last seen, kept while someone listens. */
319
+ knownDevices = [];
320
+ deviceWatchOn = false;
321
+ onDeviceChange = () => { void this.refreshDevices(); };
322
+ /** Videos adaptive stream has paused at the server, by producer id. */
323
+ adaptivePaused = new Set();
324
+ /** The server has no pauseConsumer (an older controller): a hidden video drops to its smallest layer instead. */
325
+ pauseUnsupported = false;
326
+ /** The last cpuConstrained said. */
327
+ cpuConstrainedNow = false;
328
+ /** prioritizePerformance(): the camera's largest simulcast layer is not encoded while true. */
329
+ performanceFirst = false;
330
+ /**
331
+ * Dynacast (the server's `producerLayers`): the highest layer any viewer needs, by producer id (-1 = nobody
332
+ * watches, the smallest layer stays on). No entry: every layer, as before the server said anything.
333
+ */
334
+ layersNeeded = new Map();
335
+ /** The highest camera layer this client set active, and on which producer. */
336
+ cameraMaxLayer = null;
166
337
  /** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
167
338
  endRoomWaiters = new Set();
168
339
  closedByUs = false;
@@ -189,7 +360,14 @@ export class StreamClient {
189
360
  codecFallbackLogged = false;
190
361
  sendStatsBaseline = new Map();
191
362
  recvStatsBaseline = new Map();
192
- limitWatch = new VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) => this.emit('videoLimited', event), undefined, () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; });
363
+ limitWatch = new VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) => {
364
+ this.emit('videoLimited', event);
365
+ const constrained = event.reason === 'cpu';
366
+ if (constrained !== this.cpuConstrainedNow) {
367
+ this.cpuConstrainedNow = constrained;
368
+ this.emit('cpuConstrained', { constrained });
369
+ }
370
+ }, undefined, () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; });
193
371
  /** Where the session stands; see connectionStateChanged. */
194
372
  state = 'idle';
195
373
  /** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
@@ -220,11 +398,55 @@ export class StreamClient {
220
398
  audioCapReported = false;
221
399
  sizeCapReported = false;
222
400
  networkWatch = new NetworkQualityWatch(() => this.transportStats(), () => this.producers.size > 0, (quality) => this.emit('networkQuality', quality));
401
+ /**
402
+ * A pre-call check (Twilio's runPreflight, LiveKit's ConnectionCheck, Agora's last-mile probe): joins the room the
403
+ * token names, publishes `options.media` (cloned, your tracks keep running) or a tone and a moving picture it makes,
404
+ * measures for `durationMs` (5 s) and leaves. Mint a host token for a test room nobody else is in: the session is
405
+ * real, and billed like any other for its few seconds. Resolves with each step, how media travelled (UDP is best;
406
+ * TCP or a relay means a firewall in the way), the round trip, loss and upload, and any camera or microphone warning
407
+ * seen. Never rejects: a failure is a step with `ok: false` and its error.
408
+ */
409
+ static async preflight(wsUrl, token, options = {}) {
410
+ const client = new StreamClient(wsUrl, token, { audioOnlyFallback: false });
411
+ const synthetic = options.media ? null : syntheticMedia();
412
+ const stream = options.media ? new MediaStream(options.media.getTracks().map((t) => t.clone())) : synthetic.stream;
413
+ try {
414
+ return await runPreflight({
415
+ connect: () => client.connect(),
416
+ publish: (media) => client.publish(media),
417
+ sendState: () => client.sendTransport?.connectionState ?? null,
418
+ sendStats: async () => (client.sendTransport ? (await client.sendTransport.getStats()).values() : null),
419
+ onWarning: (listener) => client.on('mediaWarning', (w) => listener(w.code, w.active)),
420
+ close: () => client.close(),
421
+ }, stream, options);
422
+ }
423
+ finally {
424
+ synthetic?.stop();
425
+ }
426
+ }
427
+ /**
428
+ * Warm the connection before `connect()` (LiveKit's prepareConnection): a HEAD request to the server, so the
429
+ * name lookup and the TLS handshake are done by the time the socket opens. Call it as the user heads for the
430
+ * room (opening the room screen, say). Never rejects; it only saves time.
431
+ */
432
+ static async prepareConnection(wsUrl) {
433
+ try {
434
+ const url = new URL(wsUrl);
435
+ url.protocol = url.protocol === 'ws:' ? 'http:' : 'https:';
436
+ url.pathname = '/';
437
+ url.search = '';
438
+ await fetch(url.toString(), { method: 'HEAD', mode: 'no-cors', cache: 'no-store' });
439
+ }
440
+ catch {
441
+ // Only a warm-up: connect() does the real work either way.
442
+ }
443
+ }
223
444
  constructor(wsUrl, token, options = {}) {
224
445
  this.wsUrl = wsUrl;
225
446
  this.options = options;
226
447
  this.token = token;
227
448
  this.tokenExpiry = tokenExpiresAt(token);
449
+ this.audioOnlyEnabled = options.audioOnlyFallback !== false;
228
450
  }
229
451
  on(event, fn) {
230
452
  let set = this.listeners.get(event);
@@ -235,16 +457,28 @@ export class StreamClient {
235
457
  set.add(fn);
236
458
  // videoLimited and networkQuality cost a getStats every few seconds, so
237
459
  // they run only while heard.
238
- if (event === 'videoLimited')
460
+ if (event === 'videoLimited' || event === 'cpuConstrained')
239
461
  this.syncLimitWatch();
240
462
  if (event === 'networkQuality')
241
463
  this.syncNetworkWatch();
464
+ if (event === 'devicesChanged')
465
+ this.syncDeviceWatch();
466
+ if (event === 'audioLevels' || event === 'activeSpeakers')
467
+ this.syncLevelWatch();
468
+ if (event === 'mediaWarning')
469
+ this.syncHealthWatch();
242
470
  return () => {
243
471
  set.delete(fn);
244
- if (event === 'videoLimited')
472
+ if (event === 'videoLimited' || event === 'cpuConstrained')
245
473
  this.syncLimitWatch();
246
474
  if (event === 'networkQuality')
247
475
  this.syncNetworkWatch();
476
+ if (event === 'devicesChanged')
477
+ this.syncDeviceWatch();
478
+ if (event === 'audioLevels' || event === 'activeSpeakers')
479
+ this.syncLevelWatch();
480
+ if (event === 'mediaWarning')
481
+ this.syncHealthWatch();
248
482
  };
249
483
  }
250
484
  /** Where the session stands: idle, connecting, connected, reconnecting or closed. */
@@ -258,6 +492,10 @@ export class StreamClient {
258
492
  this.state = next;
259
493
  if (next === 'closed')
260
494
  this.stopTimers();
495
+ else {
496
+ this.syncLevelWatch();
497
+ this.syncHealthWatch();
498
+ }
261
499
  this.emit('connectionStateChanged', { state: next, previous });
262
500
  }
263
501
  /** Over for good: the state, then the event that says why. */
@@ -279,6 +517,20 @@ export class StreamClient {
279
517
  this.close();
280
518
  }
281
519
  stopTimers() {
520
+ if (this.publishProtectionTimer)
521
+ clearInterval(this.publishProtectionTimer);
522
+ this.publishProtectionTimer = null;
523
+ this.autoplayBlocked.clear();
524
+ this.watchGesture(false);
525
+ this.stopProtection();
526
+ this.stopLevelWatch();
527
+ this.stopHealthWatch(false);
528
+ this.stopDeviceWatch();
529
+ this.socketLiveness.stop();
530
+ this.mediaPaths.reset();
531
+ this.backoffWake?.();
532
+ this.onlineTarget?.removeEventListener('online', this.online);
533
+ this.onlineTarget = null;
282
534
  if (this.tokenTimer)
283
535
  clearTimeout(this.tokenTimer);
284
536
  this.tokenTimer = null;
@@ -288,6 +540,7 @@ export class StreamClient {
288
540
  return this.listeners.get(event)?.size ?? 0;
289
541
  }
290
542
  emit(event, ...args) {
543
+ this.diagnosticsLog.record(event, args);
291
544
  for (const fn of this.listeners.get(event) ?? []) {
292
545
  try {
293
546
  fn(...args);
@@ -311,8 +564,13 @@ export class StreamClient {
311
564
  const opening = this.openSocket();
312
565
  socket = this.ws;
313
566
  await opening;
314
- result = (await this.rpc('join', { token: this.token }));
567
+ // The transports come with the join where the server can make them (P3-6): the first publish and the first
568
+ // video skip a createTransport round trip. Older servers ignore the request and the client asks as before.
569
+ const joined = (await this.rpc('join', { token: this.token, transports: ['send', 'recv'] }));
570
+ const { transports, ...joinResult } = joined;
571
+ result = joinResult;
315
572
  this.joinResult = result;
573
+ this.preparedTransports = transports ?? null;
316
574
  this.device = this.createDevice();
317
575
  await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
318
576
  // close() ran while the Device loaded: going on would set the state
@@ -343,6 +601,11 @@ export class StreamClient {
343
601
  this.joinedOnce = true;
344
602
  this.skippedVideos.clear();
345
603
  this.setState('connected');
604
+ this.socketLiveness.start();
605
+ if (!this.onlineTarget && typeof window !== 'undefined' && window.addEventListener) {
606
+ this.onlineTarget = window;
607
+ window.addEventListener('online', this.online);
608
+ }
346
609
  this.scheduleTokenExpiring();
347
610
  // A role or mute can change while the socket is away. The fresh roster
348
611
  // decides what may return; reconnecting never turns a blocked device on.
@@ -374,12 +637,13 @@ export class StreamClient {
374
637
  this.remoteVideoLayers.delete(producerId);
375
638
  // Subscribe to everything already publishing before we announce readiness.
376
639
  // A close() part-way stops it: what is left would only fail, after closed.
377
- for (const p of result.producers) {
378
- if (closed())
379
- throw closedWhileConnecting();
380
- await this.consume(p).catch((err) => { if (!closed())
381
- this.emit('error', err); });
382
- }
640
+ // All at once, as newProducer events already are (and the Flutter SDK does): each subscription reserves its slot
641
+ // before its first await, in this order, and the receive transport is made once. A room with six publishers
642
+ // starts six videos together instead of one after another, two round trips each.
643
+ if (closed())
644
+ throw closedWhileConnecting();
645
+ await Promise.all(result.producers.map((p) => this.consume(p).catch((err) => { if (!closed())
646
+ this.emit('error', err); })));
383
647
  // Restore our own broadcast. This is what makes an origin failover
384
648
  // survivable for the host: it reconnects onto the new origin and the
385
649
  // stream comes back on its own.
@@ -390,6 +654,9 @@ export class StreamClient {
390
654
  if (closed())
391
655
  throw closedWhileConnecting();
392
656
  this.syncNetworkWatch();
657
+ this.syncProtection();
658
+ this.syncLevelWatch();
659
+ this.syncPublishProtection();
393
660
  return result;
394
661
  }
395
662
  /**
@@ -429,14 +696,18 @@ export class StreamClient {
429
696
  ws.addEventListener('open', onOpen, { once: true });
430
697
  ws.addEventListener('error', onError, { once: true });
431
698
  ws.addEventListener('message', (ev) => {
432
- if (this.ws === ws)
699
+ if (this.ws === ws) {
700
+ this.socketLiveness.received();
433
701
  this.onMessage(ev.data);
702
+ }
434
703
  });
435
704
  ws.addEventListener('close', (ev) => {
436
705
  ws.removeEventListener('error', onError);
437
706
  // Only the socket we currently consider ours may drive reconnection.
438
707
  if (this.ws !== ws)
439
708
  return;
709
+ this.socketLiveness.stop();
710
+ this.mediaPaths.reset();
440
711
  this.ws = null;
441
712
  this.failAllPending(new RpcError('not-connected', `socket closed (${ev.code})`));
442
713
  this.emit('disconnected', ev.reason || String(ev.code));
@@ -469,6 +740,7 @@ export class StreamClient {
469
740
  });
470
741
  }
471
742
  detachSocket(reason = 'superseded', why = 'the connection was replaced') {
743
+ this.socketLiveness.stop();
472
744
  clearTimeout(this.openDeadline);
473
745
  const ws = this.ws;
474
746
  this.ws = null;
@@ -519,7 +791,7 @@ export class StreamClient {
519
791
  return;
520
792
  }
521
793
  const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
522
- await sleep(backoff + Math.random() * 400);
794
+ await this.waitBackoff(backoff + Math.random() * 400);
523
795
  if (this.closedByUs)
524
796
  return;
525
797
  try {
@@ -555,6 +827,17 @@ export class StreamClient {
555
827
  }
556
828
  close() {
557
829
  void this.stopRecording().catch(() => undefined);
830
+ // The processor's audio graph goes with the session.
831
+ const processor = this.audioProcessor;
832
+ this.audioProcessor = null;
833
+ this.micRaw = null;
834
+ void Promise.resolve(processor?.destroy()).catch(() => undefined);
835
+ const videoProcessor = this.videoProcessor;
836
+ this.videoProcessor = null;
837
+ void Promise.resolve(videoProcessor?.destroy()).catch(() => undefined);
838
+ // Adaptive stream stops watching elements: nothing is received any more.
839
+ this.adaptive.dispose();
840
+ this.adaptivePaused.clear();
558
841
  // Only an app closing a live session is 'left'; kicked, room-closed and the
559
842
  // others have already reported themselves and set closedByUs.
560
843
  const wasOpen = !this.closedByUs;
@@ -574,7 +857,7 @@ export class StreamClient {
574
857
  this.skippedVideos.clear();
575
858
  // Producers no longer stop their tracks, so releasing the camera and
576
859
  // microphone is ours to do -- otherwise the device light stays on.
577
- this.localStream?.getTracks().forEach((t) => t.stop());
860
+ this.stopLocalTracks();
578
861
  this.localStream = null;
579
862
  const ws = this.ws;
580
863
  this.ws = null;
@@ -588,7 +871,18 @@ export class StreamClient {
588
871
  this.emit('closed', 'left');
589
872
  }
590
873
  teardownMedia() {
874
+ this.micRaw = null;
875
+ // Made for the session that is going; a rejoin brings new ones.
876
+ this.preparedTransports = null;
877
+ this.mediaPaths.reset();
878
+ // The videos go with their consumers; a reconnect decides afresh. What was reported held back is withdrawn, so an
879
+ // app's "poor connection" overlay does not outlive it.
880
+ this.clearFallbacks();
591
881
  this.subscriptions.clear();
882
+ // Producers made after this are new: the server says again what their viewers need.
883
+ this.layersNeeded.clear();
884
+ // New consumers start paused at the server; adaptive stream decides again for each.
885
+ this.adaptivePaused.clear();
592
886
  for (const c of this.consumers.values())
593
887
  c.close();
594
888
  this.consumers.clear();
@@ -808,6 +1102,35 @@ export class StreamClient {
808
1102
  case 'newProducer':
809
1103
  await this.consume(data).catch((err) => this.emit('error', err));
810
1104
  break;
1105
+ case 'streamFallback': {
1106
+ // The media server cut (or resumed) one of this viewer's videos for its bandwidth.
1107
+ const e = data;
1108
+ if (typeof e.producerId !== 'string' || !this.consumers.has(e.producerId))
1109
+ break;
1110
+ const cut = e.fallback === true;
1111
+ if (cut === this.bandwidthCut.has(e.producerId))
1112
+ break;
1113
+ if (cut)
1114
+ this.bandwidthCut.add(e.producerId);
1115
+ else
1116
+ this.bandwidthCut.delete(e.producerId);
1117
+ this.emitFallback(e.producerId, cut, 'bandwidth');
1118
+ break;
1119
+ }
1120
+ case 'producerLayers': {
1121
+ // Dynacast: what this client's viewers need from one of its producers. Layers nobody receives are not
1122
+ // encoded (the smallest always is, so a new viewer starts at once); null means every layer.
1123
+ const e = data;
1124
+ if (typeof e.producerId !== 'string')
1125
+ break;
1126
+ if (typeof e.spatialLayer === 'number' && Number.isInteger(e.spatialLayer))
1127
+ this.layersNeeded.set(e.producerId, e.spatialLayer);
1128
+ else
1129
+ this.layersNeeded.delete(e.producerId);
1130
+ if (this.cameraProducer()?.id === e.producerId)
1131
+ void this.cameraTask(() => this.syncCameraLayers());
1132
+ break;
1133
+ }
811
1134
  case 'producerClosed': {
812
1135
  const { producerId, identity } = data;
813
1136
  this.subscriptions.delete(producerId);
@@ -815,6 +1138,9 @@ export class StreamClient {
815
1138
  this.receiveQualities.delete(producerId);
816
1139
  this.remoteVideoLayers.delete(producerId);
817
1140
  this.skippedVideos.delete(producerId);
1141
+ this.adaptivePaused.delete(producerId);
1142
+ this.bandwidthCut.delete(producerId);
1143
+ this.protectionHeld.delete(producerId);
818
1144
  const consumer = this.consumers.get(producerId);
819
1145
  consumer?.close();
820
1146
  this.consumers.delete(producerId);
@@ -870,6 +1196,9 @@ export class StreamClient {
870
1196
  const e = data;
871
1197
  this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
872
1198
  this.emit('participantLeft', e);
1199
+ const speakers = this.speakers.remove(e.identity);
1200
+ if (speakers)
1201
+ this.emit('activeSpeakers', { speakers });
873
1202
  break;
874
1203
  }
875
1204
  case 'roleChanged': {
@@ -1017,24 +1346,25 @@ export class StreamClient {
1017
1346
  return Promise.resolve(this.sendTransport);
1018
1347
  if (this.sendTransportPending)
1019
1348
  return this.sendTransportPending;
1020
- this.sendTransportPending = this.createSendTransport().finally(() => {
1021
- this.sendTransportPending = null;
1349
+ const pending = this.createSendTransport().finally(() => {
1350
+ if (this.sendTransportPending === pending)
1351
+ this.sendTransportPending = null;
1022
1352
  });
1023
- return this.sendTransportPending;
1353
+ return this.sendTransportPending = pending;
1024
1354
  }
1025
1355
  async createSendTransport() {
1026
1356
  if (!this.device)
1027
1357
  throw new RpcError('not-connected', 'not connected');
1028
- const params = (await this.rpc('createTransport', { direction: 'send' }));
1029
- const transport = this.device.createSendTransport({
1358
+ const device = this.device;
1359
+ const socket = this.ws;
1360
+ const params = this.takePreparedTransport('send') ?? (await this.rpc('createTransport', { direction: 'send' }));
1361
+ if (this.closedByUs || device !== this.device || socket !== this.ws)
1362
+ throw closedWhileConnecting();
1363
+ const transport = device.createSendTransport({
1030
1364
  ...params,
1031
1365
  iceServers: this.joinResult?.iceServers,
1032
1366
  });
1033
- transport.on('connect', ({ dtlsParameters }, ok, fail) => {
1034
- this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
1035
- .then(() => ok())
1036
- .catch(fail);
1037
- });
1367
+ this.connectEarly(transport);
1038
1368
  transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
1039
1369
  this.rpc('produce', {
1040
1370
  transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
@@ -1044,32 +1374,62 @@ export class StreamClient {
1044
1374
  .catch(fail);
1045
1375
  });
1046
1376
  this.sendTransport = transport;
1377
+ this.watchTransport(transport);
1047
1378
  return transport;
1048
1379
  }
1380
+ /**
1381
+ * A faster first picture (docs/implementation-progress/RTC_PERF_PROGRESS.md P3-6). mediasoup-client applies the
1382
+ * remote description -- which starts ICE -- only once this transport's DTLS parameters are with the server: a round
1383
+ * trip before any media path is even tried, for the first publish and the first video alike. mediasoup does not need
1384
+ * them first: it starts DTLS as soon as ICE connects and checks the fingerprint whenever it arrives (its `auto`
1385
+ * role; the browser is always the DTLS client), and the server handles a socket's requests in order. So they are
1386
+ * sent and the transport goes on at once: ICE starts a round trip sooner, and the first produce or resume follows
1387
+ * straight after. A refusal means the transport can never carry media: the session rejoins.
1388
+ */
1389
+ connectEarly(transport) {
1390
+ transport.on('connect', ({ dtlsParameters }, ok) => {
1391
+ ok();
1392
+ this.rpc('connectTransport', { transportId: transport.id, dtlsParameters }).catch((err) => {
1393
+ if (transport.closed || this.closedByUs || (transport !== this.sendTransport && transport !== this.recvTransport))
1394
+ return;
1395
+ this.emit('error', err);
1396
+ this.recoverConnection(`connectTransport failed: ${err?.message ?? err}`);
1397
+ });
1398
+ });
1399
+ }
1400
+ /** The transport the join made for this direction, once: a later one is created as before. */
1401
+ takePreparedTransport(direction) {
1402
+ const prepared = this.preparedTransports?.[direction] ?? null;
1403
+ if (this.preparedTransports)
1404
+ this.preparedTransports[direction] = undefined;
1405
+ return prepared;
1406
+ }
1049
1407
  ensureRecvTransport() {
1050
1408
  if (this.recvTransport)
1051
1409
  return Promise.resolve(this.recvTransport);
1052
1410
  if (this.recvTransportPending)
1053
1411
  return this.recvTransportPending;
1054
- this.recvTransportPending = this.createRecvTransport().finally(() => {
1055
- this.recvTransportPending = null;
1412
+ const pending = this.createRecvTransport().finally(() => {
1413
+ if (this.recvTransportPending === pending)
1414
+ this.recvTransportPending = null;
1056
1415
  });
1057
- return this.recvTransportPending;
1416
+ return this.recvTransportPending = pending;
1058
1417
  }
1059
1418
  async createRecvTransport() {
1060
1419
  if (!this.device)
1061
1420
  throw new RpcError('not-connected', 'not connected');
1062
- const params = (await this.rpc('createTransport', { direction: 'recv' }));
1063
- const transport = this.device.createRecvTransport({
1421
+ const device = this.device;
1422
+ const socket = this.ws;
1423
+ const params = this.takePreparedTransport('recv') ?? (await this.rpc('createTransport', { direction: 'recv' }));
1424
+ if (this.closedByUs || device !== this.device || socket !== this.ws)
1425
+ throw closedWhileConnecting();
1426
+ const transport = device.createRecvTransport({
1064
1427
  ...params,
1065
1428
  iceServers: this.joinResult?.iceServers,
1066
1429
  });
1067
- transport.on('connect', ({ dtlsParameters }, ok, fail) => {
1068
- this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
1069
- .then(() => ok())
1070
- .catch(fail);
1071
- });
1430
+ this.connectEarly(transport);
1072
1431
  this.recvTransport = transport;
1432
+ this.watchTransport(transport);
1073
1433
  return transport;
1074
1434
  }
1075
1435
  /**
@@ -1180,7 +1540,8 @@ export class StreamClient {
1180
1540
  this.audioCapReported = false;
1181
1541
  await this.micTask(async () => {
1182
1542
  const current = this.micProducer();
1183
- const track = current?.track;
1543
+ // The device's track: the processed one is the old processor graph's, let go when it is processed again.
1544
+ const track = current ? this.deviceTrackOf(current) : null;
1184
1545
  if (!current || !track || track.readyState !== 'live' || this.micQuality === quality)
1185
1546
  return;
1186
1547
  await this.reproduceMic(current, track);
@@ -1221,8 +1582,10 @@ export class StreamClient {
1221
1582
  throw unpublishedWhile('microphone');
1222
1583
  const quality = this.audioQualityFor();
1223
1584
  const settings = AUDIO_SETTINGS[quality];
1585
+ // Through the audio processor (noise suppression) when there is one; the device's track stays the one watched.
1586
+ const sent = await this.processMic(track);
1224
1587
  const producer = await transport.produce({
1225
- track,
1588
+ track: sent,
1226
1589
  // Keep the track alive when the producer closes: a reconnect closes
1227
1590
  // every producer, and a stopped track cannot be republished.
1228
1591
  stopTracks: false,
@@ -1235,6 +1598,8 @@ export class StreamClient {
1235
1598
  appData: { source: 'mic', audioQuality: quality },
1236
1599
  });
1237
1600
  await this.keepProducer(producer, generation, 'microphone');
1601
+ this.micRaw = sent !== track ? track : null;
1602
+ this.watchTrackEnded(producer);
1238
1603
  this.micQuality = quality;
1239
1604
  // A microphone handed over switched off, or muted before a reconnect.
1240
1605
  await this.pauseOnServer(producer);
@@ -1306,8 +1671,9 @@ export class StreamClient {
1306
1671
  const track = current?.track;
1307
1672
  if (!current || !track || track.readyState !== 'live')
1308
1673
  return;
1674
+ const camera = this.cameraSource(track);
1309
1675
  try {
1310
- await track.applyConstraints?.({ ...track.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } });
1676
+ await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } });
1311
1677
  }
1312
1678
  catch { /* Existing capture settings decide whether 60 fps is possible. */ }
1313
1679
  await this.reproduceCamera(current, track);
@@ -1357,42 +1723,56 @@ export class StreamClient {
1357
1723
  const reconnecting = !current && this.wasPublishing && (this.localStream?.getVideoTracks().length ?? 0) > 0;
1358
1724
  if (!current && !reconnecting)
1359
1725
  throw new Error('replaceVideoTrack: no camera is being published');
1360
- if (!current) {
1361
- // The republish reads the camera's state off the track it sends, and
1362
- // the track being replaced is switched off exactly when the camera is
1363
- // paused. Hand that state on, or a camera that was off comes back on;
1364
- // and the old track back switched on, as below.
1365
- const replaced = this.localStream?.getVideoTracks()[0];
1366
- if (replaced && replaced !== track) {
1367
- track.enabled = replaced.enabled;
1368
- replaced.enabled = true;
1369
- }
1370
- }
1371
- this.adoptVideoTrack(track);
1372
- if (!current || current.track === track)
1373
- return;
1374
- const previous = current.track;
1375
- const paused = current.paused;
1726
+ // The picture behind what is sent now (a video processor's input), for its size; the new one goes through the
1727
+ // processor too, when there is one.
1728
+ const shown = current?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
1729
+ const previousSource = shown ? this.cameraSource(shown) : null;
1730
+ const before = this.camOut;
1731
+ const sent = await this.processCamera(track);
1376
1732
  try {
1377
- const { width = 0, height = 0 } = track.getSettings();
1378
- const frameRate = this.frameRateFor(track);
1379
- const next = videoEncodings(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
1380
- const previousSize = current.track?.getSettings();
1381
- if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
1382
- previousSize?.width === width && previousSize?.height === height) {
1383
- await current.replaceTrack({ track });
1384
- await this.applyLayerBitrates(current, next);
1733
+ if (!current) {
1734
+ // The republish reads the camera's state off the track it sends, and
1735
+ // the track being replaced is switched off exactly when the camera is
1736
+ // paused. Hand that state on, or a camera that was off comes back on;
1737
+ // and the old track back switched on, as below.
1738
+ const replaced = this.localStream?.getVideoTracks()[0];
1739
+ if (replaced && replaced !== sent) {
1740
+ sent.enabled = replaced.enabled;
1741
+ replaced.enabled = true;
1742
+ }
1743
+ }
1744
+ this.adoptVideoTrack(sent);
1745
+ if (!current || current.track === sent)
1385
1746
  return;
1747
+ const previous = current.track;
1748
+ const paused = current.paused;
1749
+ try {
1750
+ const { width = 0, height = 0 } = track.getSettings();
1751
+ const frameRate = this.frameRateFor(track);
1752
+ const next = videoEncodings(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
1753
+ const previousSize = previousSource?.getSettings();
1754
+ if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
1755
+ previousSize?.width === width && previousSize?.height === height) {
1756
+ await current.replaceTrack({ track: sent });
1757
+ this.watchTrackEnded(current);
1758
+ await this.applyLayerBitrates(current, next);
1759
+ return;
1760
+ }
1761
+ await this.reproduceCamera(current, sent);
1762
+ }
1763
+ finally {
1764
+ // Pausing switched the old track off (mediasoup-client pauses that
1765
+ // way), and nothing switches it back on once it is replaced. It is the
1766
+ // app's again, often the camera its canvas draws from: handed back
1767
+ // off, that canvas stays black after the camera is resumed.
1768
+ if (paused && previous && previous !== this.cameraProducer()?.track)
1769
+ previous.enabled = true;
1386
1770
  }
1387
- await this.reproduceCamera(current, track);
1388
1771
  }
1389
1772
  finally {
1390
- // Pausing switched the old track off (mediasoup-client pauses that
1391
- // way), and nothing switches it back on once it is replaced. It is the
1392
- // app's again, often the camera its canvas draws from: handed back
1393
- // off, that canvas stays black after the camera is resumed.
1394
- if (paused && previous && previous !== this.cameraProducer()?.track)
1395
- previous.enabled = true;
1773
+ // The processor's previous picture is no longer sent or shown.
1774
+ if (before && before !== this.camOut)
1775
+ before.stop();
1396
1776
  }
1397
1777
  });
1398
1778
  }
@@ -1484,7 +1864,9 @@ export class StreamClient {
1484
1864
  this.receiveQualities.delete(producerId);
1485
1865
  // Kept first: a reconnect that interrupts the request still applies it.
1486
1866
  this.preferredLayers.set(producerId, layers);
1487
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
1867
+ // Shown through attachVideo: the lower of this and the size it is shown at.
1868
+ const adaptive = this.adaptive.current(producerId);
1869
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
1488
1870
  }
1489
1871
  async collectVideoStats(baseline) {
1490
1872
  const producer = this.cameraProducer();
@@ -1523,7 +1905,8 @@ export class StreamClient {
1523
1905
  }
1524
1906
  this.receiveQualities.set(producerId, quality);
1525
1907
  this.preferredLayers.set(producerId, layers);
1526
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
1908
+ const adaptive = this.adaptive.current(producerId);
1909
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
1527
1910
  }
1528
1911
  cameraProducer() {
1529
1912
  for (const p of this.producers.values()) {
@@ -1570,18 +1953,29 @@ export class StreamClient {
1570
1953
  async produceCamera(transport, track, generation, paused = !track.enabled) {
1571
1954
  if (generation !== this.publishGeneration)
1572
1955
  throw unpublishedWhile('camera');
1956
+ // With a video processor its picture is sent (and shown); the camera behind it has the size and frame rate. The
1957
+ // picture already made from that camera is kept: a reconnect or a new quality does not start the processor again.
1958
+ const source = this.cameraSource(track);
1959
+ const before = this.camOut;
1960
+ const beforeSource = this.camRaw;
1961
+ const sent = await this.processCamera(source);
1962
+ if (generation !== this.publishGeneration) {
1963
+ if (sent !== source && sent !== before)
1964
+ sent.stop();
1965
+ throw unpublishedWhile('camera');
1966
+ }
1573
1967
  // mediasoup-client starts a producer paused exactly when its track is
1574
1968
  // off. Setting the track to the camera's state first makes the producer
1575
1969
  // start in it, and a camera that is off never sends a frame of the new
1576
1970
  // track; the server is told below.
1577
- track.enabled = !paused;
1971
+ sent.enabled = !paused;
1578
1972
  // Simulcast from day one: it is what lets one node serve far more viewers.
1579
1973
  // The layers follow the picture's real size, the project's quality and
1580
1974
  // the device, so the browser sends every layer asked for (see
1581
1975
  // videoEncodings).
1582
- const { width = 0, height = 0 } = track.getSettings();
1976
+ const { width = 0, height = 0 } = source.getSettings();
1583
1977
  // The frame rate first: the size the project allows depends on it.
1584
- this.cameraFrameRate = this.frameRateFor(track);
1978
+ this.cameraFrameRate = this.frameRateFor(source);
1585
1979
  const quality = this.cameraQuality(this.cameraFrameRate);
1586
1980
  const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
1587
1981
  let codecs;
@@ -1593,7 +1987,7 @@ export class StreamClient {
1593
1987
  }
1594
1988
  const chosen = await this.cameraCodec(codecs, encodings, width, height, quality);
1595
1989
  const produce = (codec) => transport.produce({
1596
- track,
1990
+ track: sent,
1597
1991
  stopTracks: false,
1598
1992
  encodings,
1599
1993
  codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
@@ -1615,14 +2009,72 @@ export class StreamClient {
1615
2009
  // unpublish() may have run while this was being made (a publish, a
1616
2010
  // quality change or a track swap under way).
1617
2011
  await this.keepProducer(producer, generation, 'camera');
2012
+ if (sent !== track)
2013
+ this.adoptVideoTrack(sent);
2014
+ if (before && before !== sent)
2015
+ before.stop();
2016
+ // A second publish brought another camera: the one the processor drew from before is no longer published.
2017
+ if (beforeSource && beforeSource !== source && beforeSource !== this.camRaw)
2018
+ beforeSource.stop();
2019
+ this.watchTrackEnded(producer);
2020
+ this.syncPublishProtection();
1618
2021
  this.cameraEncodings = encodings;
1619
2022
  this.frameRateLowSamples = 0;
1620
2023
  this.frameRateWarmupSamples = 2;
1621
2024
  await this.pauseOnServer(producer);
1622
2025
  await this.applyDegradationPreference(producer);
1623
2026
  this.syncLimitWatch();
2027
+ // A new camera producer starts with every layer: put the performance and viewer limits back on it.
2028
+ await this.syncCameraLayers();
1624
2029
  return producer;
1625
2030
  }
2031
+ /**
2032
+ * Lighten this device's work while it is overloaded (LiveKit's
2033
+ * `prioritizePerformance`): the camera's largest simulcast layer stops being
2034
+ * encoded -- the most expensive part of publishing -- and viewers get the
2035
+ * next one. `false` brings it back. Call it on `cpuConstrained`, together
2036
+ * with your app's own savings (effects off, smaller remote videos). Kept for
2037
+ * later publishes and reconnects until changed. A camera sent as one layer
2038
+ * is left as it is.
2039
+ */
2040
+ async prioritizePerformance(on = true) {
2041
+ this.performanceFirst = on;
2042
+ await this.cameraTask(() => this.syncCameraLayers());
2043
+ }
2044
+ /** Whether `prioritizePerformance()` is in force. */
2045
+ get performancePrioritized() {
2046
+ return this.performanceFirst;
2047
+ }
2048
+ /**
2049
+ * The camera's active simulcast layers: every layer, except the top one while
2050
+ * performance comes first, and none above the highest any viewer needs
2051
+ * (dynacast; the smallest always stays on, so a new viewer starts at once).
2052
+ */
2053
+ async syncCameraLayers() {
2054
+ const producer = this.cameraProducer();
2055
+ const count = this.cameraEncodings?.length ?? 0;
2056
+ if (!producer || producer.closed || count < 2)
2057
+ return;
2058
+ const top = count - 1;
2059
+ let max = top;
2060
+ if (this.performanceFirst)
2061
+ max = Math.min(max, top - 1);
2062
+ const needed = this.layersNeeded.get(producer.id);
2063
+ if (needed !== undefined)
2064
+ max = Math.min(max, Math.max(0, needed));
2065
+ // A producer just made sends every layer: nothing to ask of it until a limit applies.
2066
+ const applied = this.cameraMaxLayer?.producerId === producer.id ? this.cameraMaxLayer.layer : top;
2067
+ if (applied === max)
2068
+ return;
2069
+ try {
2070
+ await producer.setMaxSpatialLayer(max);
2071
+ this.cameraMaxLayer = { producerId: producer.id, layer: max };
2072
+ }
2073
+ catch (err) {
2074
+ if (!producer.closed)
2075
+ this.emit('error', err);
2076
+ }
2077
+ }
1626
2078
  /**
1627
2079
  * Keep a producer just made -- unless unpublish() ran while it was being
1628
2080
  * made. Kept, it would publish again what was just unpublished, so it is
@@ -1697,7 +2149,7 @@ export class StreamClient {
1697
2149
  }
1698
2150
  /** videoLimited sampling runs while a camera is published and someone listens. */
1699
2151
  syncLimitWatch() {
1700
- this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
2152
+ this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.listenerCount('cpuConstrained') > 0 || this.cameraFrameRate === 60));
1701
2153
  }
1702
2154
  /**
1703
2155
  * Re-establish this client's broadcast after a reconnect.
@@ -1935,7 +2387,8 @@ export class StreamClient {
1935
2387
  if (!isCurrent() || producer !== sampledProducer || this.frameRateFallback || !producer?.track || producer.closed)
1936
2388
  return;
1937
2389
  this.frameRateFallback = true;
1938
- await producer.track.applyConstraints?.({ ...producer.track.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
2390
+ const camera = this.cameraSource(producer.track);
2391
+ await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
1939
2392
  await this.reproduceCamera(producer, producer.track);
1940
2393
  this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
1941
2394
  }).catch((error) => this.emit('error', error));
@@ -2157,6 +2610,8 @@ export class StreamClient {
2157
2610
  const consumer = await transport.consume(params);
2158
2611
  current(consumer);
2159
2612
  this.consumers.set(remote.producerId, consumer);
2613
+ if (this.playoutDelayMs !== null)
2614
+ this.applyPlayoutDelay(consumer.rtpReceiver);
2160
2615
  this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
2161
2616
  // Layers asked for before a reconnect: the new consumer gets them before
2162
2617
  // its first frame instead of starting at the full picture. Not getting
@@ -2172,13 +2627,29 @@ export class StreamClient {
2172
2627
  this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
2173
2628
  }
2174
2629
  }
2630
+ // Adaptive stream: a video the app shows through attachVideo starts at the
2631
+ // size it is shown, and one nobody can see stays paused.
2632
+ const adaptive = consumer.kind === 'video' ? this.adaptive.current(remote.producerId) : null;
2633
+ if (adaptive)
2634
+ layers = this.effectiveLayers(remote.producerId, adaptive);
2635
+ // A camera arriving while the audio-only fallback holds cameras back starts held back too.
2636
+ const held = consumer.kind === 'video' && this.protection.active && (remote.source ?? 'camera') === 'camera';
2637
+ // Not waited for: the server applies them before the resume below (a socket's requests in order), so the first
2638
+ // frame is already the right size without a round trip in front of it (P3-6).
2175
2639
  if (layers) {
2176
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
2640
+ void this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
2177
2641
  }
2178
2642
  current(consumer);
2179
2643
  // The server created it paused. Resume only now that our end exists,
2180
2644
  // otherwise the first frames arrive before there is anything to decode them.
2181
- await this.rpc('resumeConsumer', { consumerId: consumer.id });
2645
+ this.adaptivePaused.delete(remote.producerId);
2646
+ if ((adaptive && !adaptive.visible) || (held && !this.pauseUnsupported))
2647
+ this.adaptivePaused.add(remote.producerId);
2648
+ else
2649
+ await this.rpc('resumeConsumer', { consumerId: consumer.id });
2650
+ if (held && this.pauseUnsupported) {
2651
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, spatialLayer: 0, temporalLayer: 0 }).catch(() => undefined);
2652
+ }
2182
2653
  current(consumer);
2183
2654
  const kind = consumer.kind;
2184
2655
  const source = remote.source ?? defaultSource(kind);
@@ -2191,6 +2662,812 @@ export class StreamClient {
2191
2662
  producerId: remote.producerId,
2192
2663
  source,
2193
2664
  });
2665
+ if (kind === 'video')
2666
+ this.adaptive.consumed(remote.producerId);
2667
+ if (held) {
2668
+ this.protectionHeld.add(remote.producerId);
2669
+ this.emitFallback(remote.producerId, true, 'audio-protection');
2670
+ }
2671
+ this.syncProtection();
2672
+ }
2673
+ /**
2674
+ * Adaptive stream (like LiveKit's adaptiveStream and Tencent TRTC's small
2675
+ * stream with receiveWhenViewVisible): tell the SDK which element shows a
2676
+ * remote video. It then receives the smallest layer that fills that element
2677
+ * sharply (its size on screen × devicePixelRatio, at most 2) and pauses the
2678
+ * video at the server while the element is not visible (scrolled away,
2679
+ * `display: none`, or the tab in the background), so a grid of small tiles
2680
+ * stops decoding full-size video. Attach the same video to several elements
2681
+ * and the largest visible one decides. Returns the function that detaches
2682
+ * it; detach when the element goes away. A ceiling set with
2683
+ * `setReceiveQuality` or `setPreferredLayers` still applies. Works with any
2684
+ * server; one without `pauseConsumer` gets the smallest layer instead of a
2685
+ * pause.
2686
+ */
2687
+ attachVideo(producerId, element) {
2688
+ return this.adaptive.attach(producerId, element);
2689
+ }
2690
+ /** The layers to ask for: the app's ceiling (setReceiveQuality / setPreferredLayers) and adaptive stream's, the lower. */
2691
+ effectiveLayers(producerId, want) {
2692
+ let manual = this.preferredLayers.get(producerId);
2693
+ const quality = this.receiveQualities.get(producerId);
2694
+ if (quality && quality !== 'auto') {
2695
+ try {
2696
+ manual = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) };
2697
+ }
2698
+ catch {
2699
+ manual = undefined;
2700
+ }
2701
+ }
2702
+ const adaptive = want?.visible ? want.spatialLayer : null;
2703
+ if (adaptive === null || adaptive === undefined)
2704
+ return manual ?? { spatialLayer: -1, temporalLayer: -1 };
2705
+ if (!manual || manual.spatialLayer < 0)
2706
+ return { spatialLayer: adaptive };
2707
+ return { ...manual, spatialLayer: Math.min(manual.spatialLayer, adaptive) };
2708
+ }
2709
+ // ------------------------------------------------------- audio processor
2710
+ /**
2711
+ * Send the microphone through `processor` first (LiveKit's setProcessor, Agora's extensions): above all noise
2712
+ * suppression -- `createNoiseSuppressor()` from `@voltkit-rtc/client/noise-suppression`. Applies to the microphone
2713
+ * published now (swapped in place, no gap for listeners) and to every one after it: another device, a reconnect, a
2714
+ * new quality. `null` sends the microphone as it is again and lets the processor go. A processor that fails never
2715
+ * keeps the microphone off: it is sent as it is, and `error` says why.
2716
+ */
2717
+ async setAudioProcessor(processor) {
2718
+ await this.micTask(async () => {
2719
+ const previous = this.audioProcessor;
2720
+ this.audioProcessor = processor;
2721
+ const producer = this.micProducer();
2722
+ const device = producer ? this.deviceTrackOf(producer) : null;
2723
+ if (producer && device && !producer.closed) {
2724
+ const sent = await this.processMic(device);
2725
+ await producer.replaceTrack({ track: sent });
2726
+ this.micRaw = sent !== device ? device : null;
2727
+ }
2728
+ if (previous && previous !== processor)
2729
+ await Promise.resolve(previous.destroy()).catch(() => undefined);
2730
+ });
2731
+ }
2732
+ /** The processor in use now, or null. */
2733
+ get audioProcessorInUse() {
2734
+ return this.audioProcessor;
2735
+ }
2736
+ /** The track to send for the microphone's device track `track`: processed when there is a processor that works. */
2737
+ async processMic(track) {
2738
+ const processor = this.audioProcessor;
2739
+ if (!processor)
2740
+ return track;
2741
+ try {
2742
+ return await processor.process(track);
2743
+ }
2744
+ catch (err) {
2745
+ this.emit('error', new Error(`audio processor ${processor.name} failed, sending the microphone as it is: ${err?.message ?? err}`));
2746
+ return track;
2747
+ }
2748
+ }
2749
+ /** The device's own track behind a producer: for the microphone, the one before any processor. */
2750
+ deviceTrackOf(producer) {
2751
+ const source = producer.appData.source;
2752
+ if (source === 'mic')
2753
+ return this.micRaw ?? producer.track;
2754
+ return source === 'camera' && producer.track ? this.cameraSource(producer.track) : producer.track;
2755
+ }
2756
+ /**
2757
+ * Send the camera through `processor` first (LiveKit's setProcessor, Agora's and TRTC's video extensions): a blurred
2758
+ * or replaced background, beauty -- `createVideoEffects()` from `@voltkit-rtc/client/video-effects`. The processed
2759
+ * picture is what viewers get and what `local` shows. Applies to the camera published now (swapped in place: the
2760
+ * same size, so no pause for viewers) and to every one after it: `switchDevice`, `replaceVideoTrack`, a reconnect, a
2761
+ * new quality. `null` sends the camera as it is again and lets the processor go. A processor that fails never keeps
2762
+ * the camera off: it is sent as it is, and `error` says why.
2763
+ */
2764
+ async setVideoProcessor(processor) {
2765
+ await this.cameraTask(async () => {
2766
+ const previous = this.videoProcessor;
2767
+ this.videoProcessor = processor;
2768
+ const producer = this.cameraProducer();
2769
+ const shown = producer?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
2770
+ if (shown && shown.readyState === 'live') {
2771
+ const source = this.cameraSource(shown);
2772
+ const before = this.camOut;
2773
+ const paused = producer ? producer.paused : !shown.enabled;
2774
+ // Made afresh, by the new processor.
2775
+ this.camRaw = null;
2776
+ this.camOut = null;
2777
+ const sent = await this.processCamera(source);
2778
+ sent.enabled = !paused;
2779
+ if (producer && !producer.closed && producer.track !== sent)
2780
+ await producer.replaceTrack({ track: sent });
2781
+ this.adoptVideoTrack(sent);
2782
+ if (before && before !== sent)
2783
+ before.stop();
2784
+ }
2785
+ if (previous && previous !== processor)
2786
+ await Promise.resolve(previous.destroy()).catch(() => undefined);
2787
+ });
2788
+ }
2789
+ /** The video processor in use now, or null. */
2790
+ get videoProcessorInUse() {
2791
+ return this.videoProcessor;
2792
+ }
2793
+ /** The camera picture behind `track`: the one a video processor draws from, when `track` is its picture. */
2794
+ cameraSource(track) {
2795
+ return track === this.camOut && this.camRaw ? this.camRaw : track;
2796
+ }
2797
+ /**
2798
+ * The picture to send for the camera picture `source`: a video processor's, when there is one that works. The one
2799
+ * already made from `source` is kept (a reconnect, a new quality); otherwise it is made afresh.
2800
+ */
2801
+ async processCamera(source) {
2802
+ const processor = this.videoProcessor;
2803
+ if (!processor) {
2804
+ this.camRaw = null;
2805
+ this.camOut = null;
2806
+ return source;
2807
+ }
2808
+ if (this.camRaw === source && this.camOut?.readyState === 'live')
2809
+ return this.camOut;
2810
+ try {
2811
+ const out = await processor.process(source);
2812
+ // The processor draws from the camera whether or not the camera is paused: its picture carries the pause.
2813
+ source.enabled = true;
2814
+ this.camRaw = source;
2815
+ this.camOut = out;
2816
+ return out;
2817
+ }
2818
+ catch (err) {
2819
+ this.emit('error', new Error(`video processor ${processor.name} failed, sending the camera as it is: ${err?.message ?? err}`));
2820
+ this.camRaw = null;
2821
+ this.camOut = null;
2822
+ return source;
2823
+ }
2824
+ }
2825
+ /** Stop the client's own tracks, and the camera behind a video processor's picture (not in the stream itself). */
2826
+ stopLocalTracks() {
2827
+ this.localStream?.getTracks().forEach((t) => t.stop());
2828
+ this.camRaw?.stop();
2829
+ this.camOut?.stop();
2830
+ this.camRaw = null;
2831
+ this.camOut = null;
2832
+ }
2833
+ // ------------------------------------------------------- diagnostics
2834
+ /**
2835
+ * A snapshot for a support ticket (what Agora's log upload and Twilio's Insights are used for): the SDK version and
2836
+ * browser, the session (room, identity, role, state), how each media transport is connected (UDP, TCP or relayed),
2837
+ * the round trip and upload now, what is published and received, and the last 200 notable events (state changes,
2838
+ * errors, recoveries, fallbacks, warnings) with their times. Plain JSON: `JSON.stringify` it and attach it. Holds
2839
+ * no tokens and no media.
2840
+ */
2841
+ async getDiagnostics() {
2842
+ const transport = async (t) => {
2843
+ if (!t || t.closed)
2844
+ return null;
2845
+ let path = null;
2846
+ try {
2847
+ path = connectionPath((await t.getStats()).values());
2848
+ }
2849
+ catch {
2850
+ // Best effort.
2851
+ }
2852
+ return { state: t.connectionState, path };
2853
+ };
2854
+ let network = null;
2855
+ try {
2856
+ const reports = await this.transportStats();
2857
+ const current = new Map([...reports].map(([key, report]) => [key, readTransportStats(report)]));
2858
+ if (current.size > 0) {
2859
+ const q = networkQualityOf(current, new Map(), this.producers.size > 0);
2860
+ network = { rttMs: q.rttMs, availableKbps: q.availableKbps };
2861
+ }
2862
+ }
2863
+ catch {
2864
+ // Best effort.
2865
+ }
2866
+ const join = this.joinResult;
2867
+ return {
2868
+ sdk: SDK_VERSION,
2869
+ at: Date.now(),
2870
+ userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
2871
+ connectionState: this.state,
2872
+ room: join?.roomId ?? null,
2873
+ identity: join?.identity ?? null,
2874
+ role: join?.role ?? null,
2875
+ transports: { send: await transport(this.sendTransport), recv: await transport(this.recvTransport) },
2876
+ network,
2877
+ published: [...this.producers.values()].filter((p) => !p.closed)
2878
+ .map((p) => ({ source: String(p.appData.source ?? p.kind), kind: p.kind, paused: p.paused })),
2879
+ received: [...this.consumers.entries()].filter(([, c]) => !c.closed).map(([producerId, c]) => ({
2880
+ identity: this.remoteInfo.get(producerId)?.identity ?? '',
2881
+ source: this.remoteSources.get(producerId) ?? (c.kind === 'audio' ? 'mic' : 'camera'),
2882
+ kind: c.kind,
2883
+ paused: c.paused || this.adaptivePaused.has(producerId),
2884
+ })),
2885
+ log: this.diagnosticsLog.list(),
2886
+ };
2887
+ }
2888
+ // ------------------------------------------------------- autoplay
2889
+ /**
2890
+ * Play a `<video>` or `<audio>` showing a remote track, the way autoplay rules allow (Agora's and Tencent TRTC's
2891
+ * autoplay handling). Resolves true when it plays. When the browser refuses until the viewer interacts with the
2892
+ * page, resolves false, emits `autoplayFailed` with a `resume()` for a "Tap to unmute" button, and plays it -- with
2893
+ * every other refused element -- on the next tap, click or key press by itself.
2894
+ */
2895
+ async play(element) {
2896
+ try {
2897
+ await element.play();
2898
+ this.autoplayBlocked.delete(element);
2899
+ return true;
2900
+ }
2901
+ catch (err) {
2902
+ if (err?.name !== 'NotAllowedError')
2903
+ throw err;
2904
+ this.autoplayBlocked.add(element);
2905
+ this.watchGesture(true);
2906
+ this.emit('autoplayFailed', { element, resume: async () => { await this.play(element); } });
2907
+ return false;
2908
+ }
2909
+ }
2910
+ watchGesture(on) {
2911
+ if (typeof document === 'undefined' || on === this.gestureWatchOn)
2912
+ return;
2913
+ this.gestureWatchOn = on;
2914
+ for (const type of ['pointerdown', 'keydown', 'touchend']) {
2915
+ if (on)
2916
+ document.addEventListener(type, this.onGesture, { capture: true });
2917
+ else
2918
+ document.removeEventListener(type, this.onGesture, { capture: true });
2919
+ }
2920
+ }
2921
+ async retryAutoplay() {
2922
+ for (const element of [...this.autoplayBlocked]) {
2923
+ try {
2924
+ await element.play();
2925
+ this.autoplayBlocked.delete(element);
2926
+ }
2927
+ catch {
2928
+ // Still refused (or the element went away); the next gesture tries again.
2929
+ if (!element.isConnected)
2930
+ this.autoplayBlocked.delete(element);
2931
+ }
2932
+ }
2933
+ if (this.autoplayBlocked.size === 0)
2934
+ this.watchGesture(false);
2935
+ }
2936
+ // ------------------------------------------------------- playout delay
2937
+ /**
2938
+ * Trade a little delay for smoother playback (Tencent TRTC's playoutDelay): every remote track is buffered about
2939
+ * `ms` before it plays, which rides out the jitter of a mobile network without freezes. For an audience watching a
2940
+ * stream, 300-500 ms is a good value; people talking with each other want `null` (the browser's own, as small as
2941
+ * it can keep). Applies to what is received now and later. Browsers without the setting (before Chrome 114) keep
2942
+ * their own; 0-4000 ms.
2943
+ */
2944
+ setPlayoutDelay(ms) {
2945
+ this.playoutDelayMs = ms === null ? null : Math.min(4000, Math.max(0, Math.round(ms)));
2946
+ for (const consumer of this.consumers.values())
2947
+ this.applyPlayoutDelay(consumer.rtpReceiver);
2948
+ }
2949
+ applyPlayoutDelay(receiver) {
2950
+ if (!receiver || !('jitterBufferTarget' in receiver))
2951
+ return;
2952
+ try {
2953
+ receiver.jitterBufferTarget = this.playoutDelayMs;
2954
+ }
2955
+ catch {
2956
+ // Out of the browser's range: it keeps its own.
2957
+ }
2958
+ }
2959
+ // ------------------------------------------------------- audio levels
2960
+ /** Levels are read only while someone listens and the session is connected. */
2961
+ syncLevelWatch() {
2962
+ const wanted = !this.closedByUs && this.state === 'connected' &&
2963
+ (this.listenerCount('audioLevels') > 0 || this.listenerCount('activeSpeakers') > 0);
2964
+ if (wanted && !this.levelTimer) {
2965
+ this.levelTimer = setInterval(() => void this.sampleAudioLevels(), AUDIO_LEVEL_INTERVAL_MS);
2966
+ this.levelTimer.unref?.();
2967
+ }
2968
+ else if (!wanted) {
2969
+ this.stopLevelWatch();
2970
+ }
2971
+ }
2972
+ stopLevelWatch() {
2973
+ if (this.levelTimer)
2974
+ clearInterval(this.levelTimer);
2975
+ this.levelTimer = null;
2976
+ this.speakers.reset();
2977
+ }
2978
+ /**
2979
+ * One reading: each remote audio from its receiver's synchronization sources (the most recent packet's level, no
2980
+ * statistics report needed), the own microphone from its sender's media-source statistics.
2981
+ */
2982
+ async sampleAudioLevels() {
2983
+ if (this.levelSampling)
2984
+ return;
2985
+ this.levelSampling = true;
2986
+ try {
2987
+ const levels = [];
2988
+ for (const [producerId, consumer] of this.consumers) {
2989
+ if (consumer.kind !== 'audio' || consumer.closed)
2990
+ continue;
2991
+ const info = this.remoteInfo.get(producerId);
2992
+ if (!info)
2993
+ continue;
2994
+ const level = info.paused ? 0 : receiverLevel(consumer.rtpReceiver);
2995
+ levels.push({ identity: info.identity, producerId, source: this.remoteSources.get(producerId) ?? 'mic', level, speaking: level > SPEAKING_LEVEL, local: false });
2996
+ }
2997
+ const mic = this.micProducer();
2998
+ const self = this.joinResult?.identity;
2999
+ if (mic && !mic.paused && self) {
3000
+ const level = await senderLevel(mic);
3001
+ levels.push({ identity: self, producerId: mic.id, source: 'mic', level, speaking: level > SPEAKING_LEVEL, local: true });
3002
+ }
3003
+ if (!this.levelTimer)
3004
+ return;
3005
+ if (this.listenerCount('audioLevels') > 0)
3006
+ this.emit('audioLevels', levels);
3007
+ const loudest = new Map();
3008
+ for (const l of levels)
3009
+ if (l.source === 'mic')
3010
+ loudest.set(l.identity, Math.max(loudest.get(l.identity) ?? 0, l.level));
3011
+ const speakers = this.speakers.update(loudest);
3012
+ if (speakers)
3013
+ this.emit('activeSpeakers', { speakers });
3014
+ }
3015
+ catch {
3016
+ // Best effort; the next reading tries again.
3017
+ }
3018
+ finally {
3019
+ this.levelSampling = false;
3020
+ }
3021
+ }
3022
+ // ------------------------------------------------------- media health
3023
+ /** The published devices are sampled only while someone listens and the session is connected. */
3024
+ syncHealthWatch() {
3025
+ const wanted = !this.closedByUs && this.state === 'connected' && this.listenerCount('mediaWarning') > 0;
3026
+ if (wanted && !this.healthTimer) {
3027
+ this.healthTimer = setInterval(() => void this.sampleHealth(), HEALTH_SAMPLE_MS);
3028
+ this.healthTimer.unref?.();
3029
+ }
3030
+ else if (!wanted) {
3031
+ this.stopHealthWatch(this.listenerCount('mediaWarning') > 0 && !this.closedByUs);
3032
+ }
3033
+ }
3034
+ /** Stop sampling; `report` says each active warning cleared (nothing is said after close). */
3035
+ stopHealthWatch(report) {
3036
+ if (this.healthTimer)
3037
+ clearInterval(this.healthTimer);
3038
+ this.healthTimer = null;
3039
+ for (const timer of this.interruptions.values())
3040
+ clearTimeout(timer);
3041
+ this.interruptions.clear();
3042
+ if (report)
3043
+ this.health.clearAll();
3044
+ }
3045
+ async sampleHealth() {
3046
+ if (this.healthSampling)
3047
+ return;
3048
+ this.healthSampling = true;
3049
+ try {
3050
+ const mic = this.micProducer();
3051
+ const camera = this.cameraProducer();
3052
+ // A processed microphone (noise suppression) is near-silent between words by design: no mic-silent then.
3053
+ const micLevel = mic && !mic.paused && !this.audioProcessor ? readMediaSource((await mic.getStats()).values()).level : null;
3054
+ // A processed picture runs at the processor's pace, not the camera's: no camera-fps-low then.
3055
+ const cameraFps = camera && !camera.paused && !this.videoProcessor ? readMediaSource((await camera.getStats()).values()).fps : null;
3056
+ if (!this.healthTimer)
3057
+ return;
3058
+ this.health.feed({ micLevel, cameraFps, askedFps: this.videoFrameRate });
3059
+ // An interrupted device that is no longer published never says it came back: that is over too.
3060
+ if (!mic)
3061
+ this.health.interrupted('mic-interrupted', false);
3062
+ if (!camera)
3063
+ this.health.interrupted('camera-interrupted', false);
3064
+ }
3065
+ catch {
3066
+ // Best effort; the next sample tries again.
3067
+ }
3068
+ finally {
3069
+ this.healthSampling = false;
3070
+ }
3071
+ }
3072
+ // ---------------------------------------------------------------- devices
3073
+ /**
3074
+ * The cameras, microphones and speakers the browser offers, optionally of one kind. Labels are empty until the
3075
+ * page has been granted camera or microphone access.
3076
+ */
3077
+ listDevices(kind) {
3078
+ return enumerate(kind);
3079
+ }
3080
+ /**
3081
+ * Publish from another camera or microphone (LiveKit's switchActiveDevice, Agora's setDevice). The device is
3082
+ * captured with the constraints publish was given (size, frame rate, echo cancellation) and its track replaces the
3083
+ * published one: a camera of the same size swaps with no pause for viewers, a microphone always does. The track it
3084
+ * replaces is stopped, releasing that device; the client's own stream follows (`localStreamChanged`), and a
3085
+ * reconnect captures the chosen device again. A camera or microphone that is switched off stays off.
3086
+ *
3087
+ * For a camera published as a canvas or another processed track, capture the new camera yourself and pass your
3088
+ * processed track to `replaceVideoTrack`; this rejects rather than replace your picture with the raw camera.
3089
+ * Rejects when that kind is not being published.
3090
+ */
3091
+ async switchDevice(kind, deviceId) {
3092
+ const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
3093
+ if (!md?.getUserMedia)
3094
+ throw new Error('switchDevice: this browser cannot capture media');
3095
+ if (kind === 'videoinput') {
3096
+ const shown = this.cameraProducer()?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
3097
+ // With a video processor, the camera it draws from.
3098
+ const current = shown ? this.cameraSource(shown) : null;
3099
+ if (!current)
3100
+ throw new Error('switchDevice: no camera is being published');
3101
+ if (!deviceIdOf(current)) {
3102
+ throw new Error('switchDevice: the published video is not a camera capture (a canvas or processed track); capture the camera yourself and call replaceVideoTrack');
3103
+ }
3104
+ const video = constraintsFor(this.publishConstraints?.video, deviceId);
3105
+ const track = (await md.getUserMedia({ video })).getVideoTracks()[0];
3106
+ if (!track)
3107
+ throw new Error('switchDevice: the camera gave no video');
3108
+ try {
3109
+ await this.replaceVideoTrack(track);
3110
+ }
3111
+ catch (err) {
3112
+ track.stop();
3113
+ throw err;
3114
+ }
3115
+ if (current !== track)
3116
+ current.stop();
3117
+ this.publishConstraints = { ...(this.publishConstraints ?? {}), video };
3118
+ return;
3119
+ }
3120
+ await this.micTask(async () => {
3121
+ const producer = this.micProducer();
3122
+ if (!producer)
3123
+ throw new Error('switchDevice: no microphone is being published');
3124
+ const audio = constraintsFor(this.publishConstraints?.audio, deviceId);
3125
+ const track = (await md.getUserMedia({ audio })).getAudioTracks()[0];
3126
+ if (!track)
3127
+ throw new Error('switchDevice: the microphone gave no audio');
3128
+ if (producer.closed || this.micProducer() !== producer) {
3129
+ track.stop();
3130
+ throw new Error('switchDevice: the microphone was unpublished meanwhile');
3131
+ }
3132
+ await this.adoptAudioTrack(producer, track);
3133
+ this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
3134
+ });
3135
+ }
3136
+ /** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
3137
+ async adoptAudioTrack(producer, track) {
3138
+ const previous = this.micRaw ?? producer.track;
3139
+ const sent = await this.processMic(track);
3140
+ await producer.replaceTrack({ track: sent });
3141
+ this.micRaw = sent !== track ? track : null;
3142
+ const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
3143
+ this.localStream = new MediaStream([track, ...video]);
3144
+ this.emit('localStreamChanged', this.localStream);
3145
+ if (previous && previous !== track)
3146
+ previous.stop();
3147
+ this.watchTrackEnded(producer);
3148
+ }
3149
+ /** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
3150
+ watchTrackEnded(producer) {
3151
+ // The device's track, not the processed one a processor sends: that is the one a device ends.
3152
+ const track = this.deviceTrackOf(producer);
3153
+ if (!track || typeof track.addEventListener !== 'function')
3154
+ return;
3155
+ track.addEventListener('ended', () => { void this.onTrackEnded(producer, track); }, { once: true });
3156
+ // The system taking the device (a call, another app) mutes the track; held a moment, it is reported.
3157
+ const which = producer.appData.source === 'mic' ? 'mic' : producer.appData.source === 'camera' ? 'camera' : null;
3158
+ if (!which)
3159
+ return;
3160
+ const code = which === 'mic' ? 'mic-interrupted' : 'camera-interrupted';
3161
+ const current = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track;
3162
+ track.addEventListener('mute', () => {
3163
+ if (!current() || this.interruptions.has(which))
3164
+ return;
3165
+ this.interruptions.set(which, setTimeout(() => {
3166
+ this.interruptions.delete(which);
3167
+ if (current() && track.muted)
3168
+ this.health.interrupted(code, true);
3169
+ }, INTERRUPTED_AFTER_MS));
3170
+ });
3171
+ track.addEventListener('unmute', () => {
3172
+ const timer = this.interruptions.get(which);
3173
+ if (timer)
3174
+ clearTimeout(timer);
3175
+ this.interruptions.delete(which);
3176
+ if (current())
3177
+ this.health.interrupted(code, false);
3178
+ });
3179
+ }
3180
+ /**
3181
+ * A published camera or microphone stopped from outside. The microphone comes back from the system default (Chime
3182
+ * keeps audio flowing, LiveKit moves to the first available device); a camera is switched off and reported, never
3183
+ * swapped for another camera on its own (Chime stops it the same way).
3184
+ */
3185
+ async onTrackEnded(producer, track) {
3186
+ const live = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track && this.producers.get(producer.id) === producer;
3187
+ if (!live())
3188
+ return;
3189
+ const deviceId = deviceIdOf(track);
3190
+ if (producer.appData.source === 'mic') {
3191
+ await this.micTask(async () => {
3192
+ if (!live())
3193
+ return;
3194
+ try {
3195
+ const md = navigator.mediaDevices;
3196
+ const audio = constraintsFor(this.publishConstraints?.audio, null);
3197
+ const next = (await md.getUserMedia({ audio })).getAudioTracks()[0];
3198
+ if (!next)
3199
+ throw new Error('no microphone');
3200
+ if (!live()) {
3201
+ next.stop();
3202
+ return;
3203
+ }
3204
+ await this.adoptAudioTrack(producer, next);
3205
+ this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
3206
+ this.emit('localTrackEnded', { kind: 'audio', deviceId, recovered: true });
3207
+ }
3208
+ catch {
3209
+ if (!live())
3210
+ return;
3211
+ await this.setMicEnabled(false).catch(() => undefined);
3212
+ this.emit('localTrackEnded', { kind: 'audio', deviceId, recovered: false });
3213
+ }
3214
+ });
3215
+ return;
3216
+ }
3217
+ if (producer.appData.source === 'camera') {
3218
+ await this.setCamEnabled(false).catch(() => undefined);
3219
+ if (!this.closedByUs)
3220
+ this.emit('localTrackEnded', { kind: 'video', deviceId, recovered: false });
3221
+ }
3222
+ }
3223
+ /** devicesChanged is watched only while someone listens. */
3224
+ syncDeviceWatch() {
3225
+ const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
3226
+ const wanted = !this.closedByUs && this.listenerCount('devicesChanged') > 0 && typeof md?.addEventListener === 'function';
3227
+ if (wanted && !this.deviceWatchOn) {
3228
+ this.deviceWatchOn = true;
3229
+ md.addEventListener('devicechange', this.onDeviceChange);
3230
+ void enumerate().then((list) => { if (this.deviceWatchOn)
3231
+ this.knownDevices = list; }).catch(() => undefined);
3232
+ }
3233
+ else if (!wanted) {
3234
+ this.stopDeviceWatch();
3235
+ }
3236
+ }
3237
+ stopDeviceWatch() {
3238
+ if (!this.deviceWatchOn)
3239
+ return;
3240
+ this.deviceWatchOn = false;
3241
+ navigator.mediaDevices?.removeEventListener?.('devicechange', this.onDeviceChange);
3242
+ }
3243
+ async refreshDevices() {
3244
+ let now;
3245
+ try {
3246
+ now = await enumerate();
3247
+ }
3248
+ catch {
3249
+ return;
3250
+ }
3251
+ if (!this.deviceWatchOn)
3252
+ return;
3253
+ const { added, removed } = diffDevices(this.knownDevices, now);
3254
+ this.knownDevices = now;
3255
+ if (added.length > 0 || removed.length > 0)
3256
+ this.emit('devicesChanged', { devices: now, added, removed });
3257
+ }
3258
+ /** Adaptive stream's decision for a video, with the audio-only fallback holding remote cameras back. */
3259
+ withFallback(producerId, want) {
3260
+ if (this.protection.active && this.remoteSources.get(producerId) === 'camera')
3261
+ return { visible: false, spatialLayer: null };
3262
+ return want;
3263
+ }
3264
+ /**
3265
+ * Audio-only fallback (like Agora's AUDIO_ONLY stream fallback): on unless `audioOnlyFallback: false` was given.
3266
+ * While this viewer's received sound breaks up badly -- a quarter of it lost over about 15 s -- remote camera
3267
+ * videos are paused at the server (screen shares stay) so what bandwidth there is goes to the audio; they come
3268
+ * back once it has recovered for a while, holding longer if it falls back again soon. Each held video is reported
3269
+ * by `streamFallback`. `false` brings any held video back now and stops watching.
3270
+ */
3271
+ setAudioOnlyFallback(enabled) {
3272
+ this.audioOnlyEnabled = enabled;
3273
+ if (!enabled)
3274
+ this.protection.release();
3275
+ this.syncProtection();
3276
+ }
3277
+ /**
3278
+ * The publisher side of the audio-only fallback (Agora's setLocalPublishFallbackOption(AUDIO_ONLY)), off by default:
3279
+ * while this client's own sound breaks up on the way to the server -- a quarter of it lost over about 15 s -- its
3280
+ * camera is paused (viewers see it paused) so the upload carries the voice, and resumed once it recovers, holding
3281
+ * longer after a relapse. Reported by `publishFallback`. A camera you or a moderator switch off meanwhile stays off.
3282
+ * `false` brings a held camera back now.
3283
+ */
3284
+ setPublishFallback(enabled) {
3285
+ this.publishFallbackEnabled = enabled;
3286
+ if (!enabled)
3287
+ this.publishProtection.release();
3288
+ this.syncPublishProtection();
3289
+ }
3290
+ syncPublishProtection() {
3291
+ const wanted = this.publishFallbackEnabled && !this.closedByUs && this.state === 'connected' &&
3292
+ this.micProducer() !== undefined && (this.cameraProducer() !== undefined || this.publishProtection.active);
3293
+ if (wanted && !this.publishProtectionTimer) {
3294
+ this.publishProtectionTimer = setInterval(() => void this.samplePublishProtection(), PROTECT_SAMPLE_MS);
3295
+ this.publishProtectionTimer.unref?.();
3296
+ }
3297
+ else if (!wanted && this.publishProtectionTimer) {
3298
+ clearInterval(this.publishProtectionTimer);
3299
+ this.publishProtectionTimer = null;
3300
+ }
3301
+ }
3302
+ async samplePublishProtection() {
3303
+ const transport = this.sendTransport;
3304
+ if (this.publishProtectionSampling || !transport || transport.closed)
3305
+ return;
3306
+ this.publishProtectionSampling = true;
3307
+ try {
3308
+ const report = await transport.getStats();
3309
+ if (transport !== this.sendTransport || !this.publishProtectionTimer)
3310
+ return;
3311
+ this.publishProtection.feed(readOutboundAudio(report.values()));
3312
+ }
3313
+ catch {
3314
+ // Best effort.
3315
+ }
3316
+ finally {
3317
+ this.publishProtectionSampling = false;
3318
+ }
3319
+ }
3320
+ /** The publisher-side fallback decided: hold the camera, or bring it back if it is still ours to bring back. */
3321
+ async onPublishProtection(active) {
3322
+ const camera = this.cameraProducer();
3323
+ if (active) {
3324
+ if (!camera || camera.paused) {
3325
+ this.syncPublishProtection();
3326
+ return;
3327
+ }
3328
+ this.cameraHeldByFallback = true;
3329
+ camera.pause();
3330
+ this.emit('publishFallback', { fallback: true });
3331
+ await this.rpc('pauseProducer', { producerId: camera.id, paused: true }).catch(() => undefined);
3332
+ this.emitLocalMedia();
3333
+ }
3334
+ else if (this.cameraHeldByFallback) {
3335
+ this.cameraHeldByFallback = false;
3336
+ if (camera && camera.paused && !this.forcedOff.video) {
3337
+ camera.resume();
3338
+ await this.rpc('pauseProducer', { producerId: camera.id, paused: false }).catch(() => undefined);
3339
+ this.emitLocalMedia();
3340
+ }
3341
+ this.emit('publishFallback', { fallback: false });
3342
+ }
3343
+ this.syncPublishProtection();
3344
+ }
3345
+ /** Whether the audio-only fallback is holding remote cameras back now. */
3346
+ get audioOnlyFallbackActive() {
3347
+ return this.protection.active;
3348
+ }
3349
+ /** The audio-only fallback decided: hold remote cameras back, or let them come back. */
3350
+ onAudioProtection(active) {
3351
+ for (const [producerId, consumer] of this.consumers) {
3352
+ if (consumer.kind !== 'video' || this.remoteSources.get(producerId) !== 'camera')
3353
+ continue;
3354
+ const want = this.withFallback(producerId, this.adaptive.current(producerId) ?? { visible: true, spatialLayer: null });
3355
+ void this.applyAdaptive(producerId, want);
3356
+ if (active && !this.protectionHeld.has(producerId)) {
3357
+ this.protectionHeld.add(producerId);
3358
+ this.emitFallback(producerId, true, 'audio-protection');
3359
+ }
3360
+ else if (!active && this.protectionHeld.delete(producerId)) {
3361
+ this.emitFallback(producerId, false, 'audio-protection');
3362
+ }
3363
+ }
3364
+ this.syncProtection();
3365
+ }
3366
+ emitFallback(producerId, fallback, reason) {
3367
+ const info = this.remoteInfo.get(producerId);
3368
+ this.emit('streamFallback', {
3369
+ producerId,
3370
+ identity: info?.identity ?? '',
3371
+ source: this.remoteSources.get(producerId) ?? 'camera',
3372
+ fallback,
3373
+ reason,
3374
+ });
3375
+ }
3376
+ /** Withdraw every held-back report (teardown; nothing is said after close) and forget the decisions. */
3377
+ clearFallbacks() {
3378
+ if (!this.closedByUs) {
3379
+ for (const producerId of this.bandwidthCut)
3380
+ this.emitFallback(producerId, false, 'bandwidth');
3381
+ for (const producerId of this.protectionHeld)
3382
+ this.emitFallback(producerId, false, 'audio-protection');
3383
+ }
3384
+ this.bandwidthCut.clear();
3385
+ this.protectionHeld.clear();
3386
+ this.protection.reset();
3387
+ this.stopProtection();
3388
+ }
3389
+ /**
3390
+ * The received audio is sampled only while the fallback could matter: enabled, connected, with remote audio to
3391
+ * judge by and a remote camera to hold back (or one held back already).
3392
+ */
3393
+ syncProtection() {
3394
+ let audio = false;
3395
+ let camera = false;
3396
+ for (const [producerId, consumer] of this.consumers) {
3397
+ if (consumer.closed)
3398
+ continue;
3399
+ if (consumer.kind === 'audio')
3400
+ audio = true;
3401
+ else if (this.remoteSources.get(producerId) === 'camera')
3402
+ camera = true;
3403
+ }
3404
+ const wanted = this.audioOnlyEnabled && !this.closedByUs && this.state === 'connected' && audio && (camera || this.protection.active);
3405
+ if (wanted && !this.protectionTimer) {
3406
+ this.protectionTimer = setInterval(() => void this.sampleProtection(), PROTECT_SAMPLE_MS);
3407
+ // Background sampling never keeps a server-side or test process alive (Node); browsers have no unref.
3408
+ this.protectionTimer.unref?.();
3409
+ }
3410
+ else if (!wanted) {
3411
+ this.stopProtection();
3412
+ }
3413
+ }
3414
+ stopProtection() {
3415
+ if (this.protectionTimer)
3416
+ clearInterval(this.protectionTimer);
3417
+ this.protectionTimer = null;
3418
+ }
3419
+ async sampleProtection() {
3420
+ const transport = this.recvTransport;
3421
+ if (this.protectionSampling || !transport || transport.closed)
3422
+ return;
3423
+ this.protectionSampling = true;
3424
+ try {
3425
+ const report = await transport.getStats();
3426
+ if (transport !== this.recvTransport || !this.protectionTimer)
3427
+ return;
3428
+ this.protection.feed(readInboundAudio(report.values()));
3429
+ }
3430
+ catch {
3431
+ // Statistics are best effort; the next sample tries again.
3432
+ }
3433
+ finally {
3434
+ this.protectionSampling = false;
3435
+ }
3436
+ }
3437
+ /** Apply adaptive stream's decision for one video: pause it, or resume it at its layer. */
3438
+ async applyAdaptive(producerId, want) {
3439
+ const consumer = this.consumers.get(producerId);
3440
+ // Not received (yet): a consumer made later starts from the decision in force.
3441
+ if (!consumer || consumer.kind !== 'video' || consumer.closed || this.closedByUs)
3442
+ return;
3443
+ try {
3444
+ if (!want.visible) {
3445
+ if (this.pauseUnsupported) {
3446
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, spatialLayer: 0, temporalLayer: 0 });
3447
+ return;
3448
+ }
3449
+ if (this.adaptivePaused.has(producerId))
3450
+ return;
3451
+ this.adaptivePaused.add(producerId);
3452
+ await this.rpc('pauseConsumer', { consumerId: consumer.id });
3453
+ return;
3454
+ }
3455
+ if (this.adaptivePaused.delete(producerId))
3456
+ await this.rpc('resumeConsumer', { consumerId: consumer.id });
3457
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...this.effectiveLayers(producerId, want) });
3458
+ }
3459
+ catch (err) {
3460
+ if (this.consumers.get(producerId) !== consumer || consumer.closed || this.closedByUs)
3461
+ return;
3462
+ if (!want.visible && err instanceof RpcError && err.code === 'unknown-method') {
3463
+ // An older server: drop to the smallest layer instead of pausing.
3464
+ this.adaptivePaused.delete(producerId);
3465
+ this.pauseUnsupported = true;
3466
+ await this.applyAdaptive(producerId, want);
3467
+ return;
3468
+ }
3469
+ this.emit('error', err);
3470
+ }
2194
3471
  }
2195
3472
  // ------------------------------------------------------------- moderation
2196
3473
  // ------------------------------------------------------------ local media
@@ -2238,6 +3515,12 @@ export class StreamClient {
2238
3515
  this.music?.setEnabled(false);
2239
3516
  }
2240
3517
  async setKindEnabled(kind, enabled) {
3518
+ // Your own choice about the camera ends the publisher-side fallback's hold on it: it never undoes your choice.
3519
+ if (kind === 'video' && this.cameraHeldByFallback) {
3520
+ this.cameraHeldByFallback = false;
3521
+ this.publishProtection.reset();
3522
+ this.emit('publishFallback', { fallback: false });
3523
+ }
2241
3524
  const generation = this.publishGeneration;
2242
3525
  // Unpublished or closed while waiting: nothing is left to switch, and the
2243
3526
  // call resolves, as it does with nothing published (V072-03).
@@ -2288,6 +3571,7 @@ export class StreamClient {
2288
3571
  /** Stop publishing entirely and release the local devices. */
2289
3572
  async unpublish() {
2290
3573
  void this.stopRecording().catch(error => this.emit('error', error));
3574
+ this.layersNeeded.clear();
2291
3575
  // All of it takes effect now; only telling the server is waited for. So
2292
3576
  // publish() and unpublish() act in the order they are called: a publish
2293
3577
  // called while this still waits is not undone by it, and one already
@@ -2307,7 +3591,7 @@ export class StreamClient {
2307
3591
  const cleared = this.limitWatch.reset();
2308
3592
  if (cleared)
2309
3593
  this.emit('videoLimited', cleared);
2310
- this.localStream?.getTracks().forEach((t) => t.stop());
3594
+ this.stopLocalTracks();
2311
3595
  this.localStream = null;
2312
3596
  // Forgotten with the stream: kept, a reconnect would ask for the camera
2313
3597
  // and microphone again and publish them, after the app unpublished them.