@voltkit-rtc/client 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +255 -4
  3. package/dist/cjs/adaptive.d.ts +104 -0
  4. package/dist/cjs/adaptive.d.ts.map +1 -0
  5. package/dist/cjs/adaptive.js +263 -0
  6. package/dist/cjs/adaptive.js.map +1 -0
  7. package/dist/cjs/devices.d.ts +42 -0
  8. package/dist/cjs/devices.d.ts.map +1 -0
  9. package/dist/cjs/devices.js +56 -0
  10. package/dist/cjs/devices.js.map +1 -0
  11. package/dist/cjs/diagnostics.d.ts +65 -0
  12. package/dist/cjs/diagnostics.d.ts.map +1 -0
  13. package/dist/cjs/diagnostics.js +69 -0
  14. package/dist/cjs/diagnostics.js.map +1 -0
  15. package/dist/cjs/fallback.d.ts +81 -0
  16. package/dist/cjs/fallback.d.ts.map +1 -0
  17. package/dist/cjs/fallback.js +148 -0
  18. package/dist/cjs/fallback.js.map +1 -0
  19. package/dist/cjs/health.d.ts +70 -0
  20. package/dist/cjs/health.d.ts.map +1 -0
  21. package/dist/cjs/health.js +116 -0
  22. package/dist/cjs/health.js.map +1 -0
  23. package/dist/cjs/index.d.ts +350 -0
  24. package/dist/cjs/index.d.ts.map +1 -1
  25. package/dist/cjs/index.js +1201 -36
  26. package/dist/cjs/index.js.map +1 -1
  27. package/dist/cjs/link.d.ts +99 -0
  28. package/dist/cjs/link.d.ts.map +1 -0
  29. package/dist/cjs/link.js +219 -0
  30. package/dist/cjs/link.js.map +1 -0
  31. package/dist/cjs/preflight.d.ts +79 -0
  32. package/dist/cjs/preflight.d.ts.map +1 -0
  33. package/dist/cjs/preflight.js +159 -0
  34. package/dist/cjs/preflight.js.map +1 -0
  35. package/dist/cjs/recording.d.ts +14 -0
  36. package/dist/cjs/recording.d.ts.map +1 -1
  37. package/dist/cjs/recording.js +51 -5
  38. package/dist/cjs/recording.js.map +1 -1
  39. package/dist/cjs/speakers.d.ts +34 -0
  40. package/dist/cjs/speakers.d.ts.map +1 -0
  41. package/dist/cjs/speakers.js +71 -0
  42. package/dist/cjs/speakers.js.map +1 -0
  43. package/dist/esm/adaptive.d.ts +104 -0
  44. package/dist/esm/adaptive.d.ts.map +1 -0
  45. package/dist/esm/adaptive.js +256 -0
  46. package/dist/esm/adaptive.js.map +1 -0
  47. package/dist/esm/devices.d.ts +42 -0
  48. package/dist/esm/devices.d.ts.map +1 -0
  49. package/dist/esm/devices.js +50 -0
  50. package/dist/esm/devices.js.map +1 -0
  51. package/dist/esm/diagnostics.d.ts +65 -0
  52. package/dist/esm/diagnostics.d.ts.map +1 -0
  53. package/dist/esm/diagnostics.js +65 -0
  54. package/dist/esm/diagnostics.js.map +1 -0
  55. package/dist/esm/fallback.d.ts +81 -0
  56. package/dist/esm/fallback.d.ts.map +1 -0
  57. package/dist/esm/fallback.js +142 -0
  58. package/dist/esm/fallback.js.map +1 -0
  59. package/dist/esm/health.d.ts +70 -0
  60. package/dist/esm/health.d.ts.map +1 -0
  61. package/dist/esm/health.js +111 -0
  62. package/dist/esm/health.js.map +1 -0
  63. package/dist/esm/index.d.ts +350 -0
  64. package/dist/esm/index.d.ts.map +1 -1
  65. package/dist/esm/index.js +1199 -36
  66. package/dist/esm/index.js.map +1 -1
  67. package/dist/esm/link.d.ts +99 -0
  68. package/dist/esm/link.d.ts.map +1 -0
  69. package/dist/esm/link.js +214 -0
  70. package/dist/esm/link.js.map +1 -0
  71. package/dist/esm/preflight.d.ts +79 -0
  72. package/dist/esm/preflight.d.ts.map +1 -0
  73. package/dist/esm/preflight.js +153 -0
  74. package/dist/esm/preflight.js.map +1 -0
  75. package/dist/esm/recording.d.ts +14 -0
  76. package/dist/esm/recording.d.ts.map +1 -1
  77. package/dist/esm/recording.js +51 -5
  78. package/dist/esm/recording.js.map +1 -1
  79. package/dist/esm/speakers.d.ts +34 -0
  80. package/dist/esm/speakers.d.ts.map +1 -0
  81. package/dist/esm/speakers.js +67 -0
  82. package/dist/esm/speakers.js.map +1 -0
  83. package/package.json +1 -1
  84. package/src/adaptive.ts +295 -0
  85. package/src/devices.ts +70 -0
  86. package/src/diagnostics.ts +94 -0
  87. package/src/fallback.ts +169 -0
  88. package/src/health.ts +127 -0
  89. package/src/index.ts +1192 -36
  90. package/src/link.ts +255 -0
  91. package/src/preflight.ts +191 -0
  92. package/src/recording.ts +59 -21
  93. package/src/speakers.ts +66 -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
@@ -58,6 +87,16 @@ const VIDEO_START_BITRATE_KBPS = 2000;
58
87
  /** Why a connect() gave up: close() ran while it was under way, and close wins. */
59
88
  const closedWhileConnecting = () => new RpcError('not-connected', 'the session was closed while connecting');
60
89
  /** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
90
+ /**
91
+ * The constraints that capture again what two publishes captured: each kind
92
+ * as the later publish asked for it, or as the earlier one did if the later
93
+ * one did not ask for it.
94
+ */
95
+ function mergeConstraints(earlier, later) {
96
+ if (!earlier)
97
+ return later;
98
+ return { ...earlier, ...later, audio: later.audio || earlier.audio, video: later.video || earlier.video };
99
+ }
61
100
  const unpublishedWhile = (what) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
62
101
  /**
63
102
  * mediasoup-client puts the microphone's Opus maxaveragebitrate in the answer
@@ -81,6 +120,78 @@ function declareAudioQuality(rtpParameters, appData) {
81
120
  export class StreamClient {
82
121
  wsUrl;
83
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
+ }
84
195
  ws = null;
85
196
  device = null;
86
197
  sendTransport = null;
@@ -105,6 +216,15 @@ export class StreamClient {
105
216
  // broadcaster that reconnects rejoins silently, which is worse than being
106
217
  // disconnected: the room looks healthy and shows nothing.
107
218
  wasPublishing = false;
219
+ /** The deadline of the socket being opened (openSocket). */
220
+ openDeadline;
221
+ /**
222
+ * This client's own moderation per kind: whether a moderator holds it off,
223
+ * and a revision every decision bumps. A switch-on that was waiting for the
224
+ * server when a decision arrived does not act on its reply (V072-06).
225
+ */
226
+ forcedOff = { audio: false, video: false };
227
+ moderationRevision = { audio: 0, video: 0 };
108
228
  publishConstraints = null;
109
229
  publishOptions = {};
110
230
  /** In-app music being published, kept across reconnects so it can be published again. */
@@ -144,6 +264,67 @@ export class StreamClient {
144
264
  * the full picture.
145
265
  */
146
266
  preferredLayers = new Map();
267
+ /**
268
+ * Adaptive stream (attachVideo): each attached remote video at the size it is
269
+ * shown, and paused at the server while no attached element is visible.
270
+ */
271
+ adaptive = new AdaptiveVideos({
272
+ layers: (id) => this.remoteVideoLayers.get(id) ?? [],
273
+ apply: (id, want) => { void this.applyAdaptive(id, this.withFallback(id, want)); },
274
+ pixelRatio: () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1),
275
+ pageVisible: pageIsVisible,
276
+ });
277
+ /** Audio-only fallback: whether it may happen, its decisions, and the sampling that feeds them. */
278
+ audioOnlyEnabled = true;
279
+ protection = new AudioProtection((active) => this.onAudioProtection(active));
280
+ protectionTimer = null;
281
+ protectionSampling = false;
282
+ /** Publisher-side fallback: off unless asked for; its decisions, timer, and whether it holds the camera now. */
283
+ publishFallbackEnabled = false;
284
+ publishProtection = new AudioProtection((active) => { void this.onPublishProtection(active); });
285
+ publishProtectionTimer = null;
286
+ publishProtectionSampling = false;
287
+ cameraHeldByFallback = false;
288
+ /** Remote videos the media server reports it cut for this viewer's bandwidth. */
289
+ bandwidthCut = new Set();
290
+ /** Remote cameras the audio-only fallback holds back now (reported with fallback: true). */
291
+ protectionHeld = new Set();
292
+ /** audioLevels / activeSpeakers: read only while someone listens. */
293
+ levelTimer = null;
294
+ levelSampling = false;
295
+ speakers = new ActiveSpeakers();
296
+ /** mediaWarning: decisions, the sampling timer, and the system interruptions being timed. */
297
+ health = new HealthDecisions((warning) => this.emit('mediaWarning', warning));
298
+ healthTimer = null;
299
+ healthSampling = false;
300
+ interruptions = new Map();
301
+ /** getDiagnostics(): the SDK's recent events, for a support ticket. */
302
+ diagnosticsLog = new DiagnosticsLog();
303
+ /** play(): elements the browser refused to play until the viewer interacts, retried on the next gesture. */
304
+ autoplayBlocked = new Set();
305
+ gestureWatchOn = false;
306
+ onGesture = () => { void this.retryAutoplay(); };
307
+ /** setPlayoutDelay: the receive buffer asked for every remote track, in ms (null: the browser's own). */
308
+ playoutDelayMs = null;
309
+ /** devicesChanged: the device list last seen, kept while someone listens. */
310
+ knownDevices = [];
311
+ deviceWatchOn = false;
312
+ onDeviceChange = () => { void this.refreshDevices(); };
313
+ /** Videos adaptive stream has paused at the server, by producer id. */
314
+ adaptivePaused = new Set();
315
+ /** The server has no pauseConsumer (an older controller): a hidden video drops to its smallest layer instead. */
316
+ pauseUnsupported = false;
317
+ /** The last cpuConstrained said. */
318
+ cpuConstrainedNow = false;
319
+ /** prioritizePerformance(): the camera's largest simulcast layer is not encoded while true. */
320
+ performanceFirst = false;
321
+ /**
322
+ * Dynacast (the server's `producerLayers`): the highest layer any viewer needs, by producer id (-1 = nobody
323
+ * watches, the smallest layer stays on). No entry: every layer, as before the server said anything.
324
+ */
325
+ layersNeeded = new Map();
326
+ /** The highest camera layer this client set active, and on which producer. */
327
+ cameraMaxLayer = null;
147
328
  /** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
148
329
  endRoomWaiters = new Set();
149
330
  closedByUs = false;
@@ -170,7 +351,14 @@ export class StreamClient {
170
351
  codecFallbackLogged = false;
171
352
  sendStatsBaseline = new Map();
172
353
  recvStatsBaseline = new Map();
173
- limitWatch = new VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) => this.emit('videoLimited', event), undefined, () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; });
354
+ limitWatch = new VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) => {
355
+ this.emit('videoLimited', event);
356
+ const constrained = event.reason === 'cpu';
357
+ if (constrained !== this.cpuConstrainedNow) {
358
+ this.cpuConstrainedNow = constrained;
359
+ this.emit('cpuConstrained', { constrained });
360
+ }
361
+ }, undefined, () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; });
174
362
  /** Where the session stands; see connectionStateChanged. */
175
363
  state = 'idle';
176
364
  /** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
@@ -201,11 +389,55 @@ export class StreamClient {
201
389
  audioCapReported = false;
202
390
  sizeCapReported = false;
203
391
  networkWatch = new NetworkQualityWatch(() => this.transportStats(), () => this.producers.size > 0, (quality) => this.emit('networkQuality', quality));
392
+ /**
393
+ * A pre-call check (Twilio's runPreflight, LiveKit's ConnectionCheck, Agora's last-mile probe): joins the room the
394
+ * token names, publishes `options.media` (cloned, your tracks keep running) or a tone and a moving picture it makes,
395
+ * measures for `durationMs` (5 s) and leaves. Mint a host token for a test room nobody else is in: the session is
396
+ * real, and billed like any other for its few seconds. Resolves with each step, how media travelled (UDP is best;
397
+ * TCP or a relay means a firewall in the way), the round trip, loss and upload, and any camera or microphone warning
398
+ * seen. Never rejects: a failure is a step with `ok: false` and its error.
399
+ */
400
+ static async preflight(wsUrl, token, options = {}) {
401
+ const client = new StreamClient(wsUrl, token, { audioOnlyFallback: false });
402
+ const synthetic = options.media ? null : syntheticMedia();
403
+ const stream = options.media ? new MediaStream(options.media.getTracks().map((t) => t.clone())) : synthetic.stream;
404
+ try {
405
+ return await runPreflight({
406
+ connect: () => client.connect(),
407
+ publish: (media) => client.publish(media),
408
+ sendState: () => client.sendTransport?.connectionState ?? null,
409
+ sendStats: async () => (client.sendTransport ? (await client.sendTransport.getStats()).values() : null),
410
+ onWarning: (listener) => client.on('mediaWarning', (w) => listener(w.code, w.active)),
411
+ close: () => client.close(),
412
+ }, stream, options);
413
+ }
414
+ finally {
415
+ synthetic?.stop();
416
+ }
417
+ }
418
+ /**
419
+ * Warm the connection before `connect()` (LiveKit's prepareConnection): a HEAD request to the server, so the
420
+ * name lookup and the TLS handshake are done by the time the socket opens. Call it as the user heads for the
421
+ * room (opening the room screen, say). Never rejects; it only saves time.
422
+ */
423
+ static async prepareConnection(wsUrl) {
424
+ try {
425
+ const url = new URL(wsUrl);
426
+ url.protocol = url.protocol === 'ws:' ? 'http:' : 'https:';
427
+ url.pathname = '/';
428
+ url.search = '';
429
+ await fetch(url.toString(), { method: 'HEAD', mode: 'no-cors', cache: 'no-store' });
430
+ }
431
+ catch {
432
+ // Only a warm-up: connect() does the real work either way.
433
+ }
434
+ }
204
435
  constructor(wsUrl, token, options = {}) {
205
436
  this.wsUrl = wsUrl;
206
437
  this.options = options;
207
438
  this.token = token;
208
439
  this.tokenExpiry = tokenExpiresAt(token);
440
+ this.audioOnlyEnabled = options.audioOnlyFallback !== false;
209
441
  }
210
442
  on(event, fn) {
211
443
  let set = this.listeners.get(event);
@@ -216,16 +448,28 @@ export class StreamClient {
216
448
  set.add(fn);
217
449
  // videoLimited and networkQuality cost a getStats every few seconds, so
218
450
  // they run only while heard.
219
- if (event === 'videoLimited')
451
+ if (event === 'videoLimited' || event === 'cpuConstrained')
220
452
  this.syncLimitWatch();
221
453
  if (event === 'networkQuality')
222
454
  this.syncNetworkWatch();
455
+ if (event === 'devicesChanged')
456
+ this.syncDeviceWatch();
457
+ if (event === 'audioLevels' || event === 'activeSpeakers')
458
+ this.syncLevelWatch();
459
+ if (event === 'mediaWarning')
460
+ this.syncHealthWatch();
223
461
  return () => {
224
462
  set.delete(fn);
225
- if (event === 'videoLimited')
463
+ if (event === 'videoLimited' || event === 'cpuConstrained')
226
464
  this.syncLimitWatch();
227
465
  if (event === 'networkQuality')
228
466
  this.syncNetworkWatch();
467
+ if (event === 'devicesChanged')
468
+ this.syncDeviceWatch();
469
+ if (event === 'audioLevels' || event === 'activeSpeakers')
470
+ this.syncLevelWatch();
471
+ if (event === 'mediaWarning')
472
+ this.syncHealthWatch();
229
473
  };
230
474
  }
231
475
  /** Where the session stands: idle, connecting, connected, reconnecting or closed. */
@@ -239,6 +483,10 @@ export class StreamClient {
239
483
  this.state = next;
240
484
  if (next === 'closed')
241
485
  this.stopTimers();
486
+ else {
487
+ this.syncLevelWatch();
488
+ this.syncHealthWatch();
489
+ }
242
490
  this.emit('connectionStateChanged', { state: next, previous });
243
491
  }
244
492
  /** Over for good: the state, then the event that says why. */
@@ -260,6 +508,20 @@ export class StreamClient {
260
508
  this.close();
261
509
  }
262
510
  stopTimers() {
511
+ if (this.publishProtectionTimer)
512
+ clearInterval(this.publishProtectionTimer);
513
+ this.publishProtectionTimer = null;
514
+ this.autoplayBlocked.clear();
515
+ this.watchGesture(false);
516
+ this.stopProtection();
517
+ this.stopLevelWatch();
518
+ this.stopHealthWatch(false);
519
+ this.stopDeviceWatch();
520
+ this.socketLiveness.stop();
521
+ this.mediaPaths.reset();
522
+ this.backoffWake?.();
523
+ this.onlineTarget?.removeEventListener('online', this.online);
524
+ this.onlineTarget = null;
263
525
  if (this.tokenTimer)
264
526
  clearTimeout(this.tokenTimer);
265
527
  this.tokenTimer = null;
@@ -269,6 +531,7 @@ export class StreamClient {
269
531
  return this.listeners.get(event)?.size ?? 0;
270
532
  }
271
533
  emit(event, ...args) {
534
+ this.diagnosticsLog.record(event, args);
272
535
  for (const fn of this.listeners.get(event) ?? []) {
273
536
  try {
274
537
  fn(...args);
@@ -306,7 +569,10 @@ export class StreamClient {
306
569
  // the socket would be closed by the server's join deadline instead, and
307
570
  // that close would start reconnecting a session that never began.
308
571
  const superseded = this.ws !== null && this.ws !== socket;
309
- if (!this.joinedOnce && !superseded) {
572
+ // A reconnect attempt that failed (its join timed out, say) lets its
573
+ // socket go now too, so nothing it says later reaches the session while
574
+ // the next attempt waits its backoff (V072-05).
575
+ if ((!this.joinedOnce || this.reconnecting) && !superseded) {
310
576
  this.detachSocket();
311
577
  if (this.state === 'connecting')
312
578
  this.setState('idle');
@@ -321,6 +587,11 @@ export class StreamClient {
321
587
  this.joinedOnce = true;
322
588
  this.skippedVideos.clear();
323
589
  this.setState('connected');
590
+ this.socketLiveness.start();
591
+ if (!this.onlineTarget && typeof window !== 'undefined' && window.addEventListener) {
592
+ this.onlineTarget = window;
593
+ window.addEventListener('online', this.online);
594
+ }
324
595
  this.scheduleTokenExpiring();
325
596
  // A role or mute can change while the socket is away. The fresh roster
326
597
  // decides what may return; reconnecting never turns a blocked device on.
@@ -330,6 +601,8 @@ export class StreamClient {
330
601
  throw closedWhileConnecting();
331
602
  }
332
603
  const own = result.participants.find((p) => p.identity === result.identity);
604
+ this.moderate('audio', Boolean(own?.muted), true);
605
+ this.moderate('video', Boolean(own?.videoBlocked), true);
333
606
  if (own?.muted)
334
607
  this.applyLocalModeration('audio');
335
608
  if (own?.videoBlocked)
@@ -350,12 +623,13 @@ export class StreamClient {
350
623
  this.remoteVideoLayers.delete(producerId);
351
624
  // Subscribe to everything already publishing before we announce readiness.
352
625
  // A close() part-way stops it: what is left would only fail, after closed.
353
- for (const p of result.producers) {
354
- if (closed())
355
- throw closedWhileConnecting();
356
- await this.consume(p).catch((err) => { if (!closed())
357
- this.emit('error', err); });
358
- }
626
+ // All at once, as newProducer events already are (and the Flutter SDK does): each subscription reserves its slot
627
+ // before its first await, in this order, and the receive transport is made once. A room with six publishers
628
+ // starts six videos together instead of one after another, two round trips each.
629
+ if (closed())
630
+ throw closedWhileConnecting();
631
+ await Promise.all(result.producers.map((p) => this.consume(p).catch((err) => { if (!closed())
632
+ this.emit('error', err); })));
359
633
  // Restore our own broadcast. This is what makes an origin failover
360
634
  // survivable for the host: it reconnects onto the new origin and the
361
635
  // stream comes back on its own.
@@ -366,6 +640,9 @@ export class StreamClient {
366
640
  if (closed())
367
641
  throw closedWhileConnecting();
368
642
  this.syncNetworkWatch();
643
+ this.syncProtection();
644
+ this.syncLevelWatch();
645
+ this.syncPublishProtection();
369
646
  return result;
370
647
  }
371
648
  /**
@@ -385,22 +662,38 @@ export class StreamClient {
385
662
  return new Promise((resolve, reject) => {
386
663
  const ws = new WebSocket(socketUrl(this.wsUrl, this.token));
387
664
  this.ws = ws;
665
+ // Bounded like every request (V072-05): a socket that does not open in
666
+ // time fails the attempt with `timeout` and is closed, instead of
667
+ // holding connect() -- or a reconnect attempt -- indefinitely.
668
+ this.openDeadline = setTimeout(() => {
669
+ reject(new RpcError('timeout', `the connection did not open in ${RPC_TIMEOUT_MS / 1000} s`));
670
+ if (this.ws === ws)
671
+ this.detachSocket('open timed out', 'the connection did not open in time');
672
+ }, RPC_TIMEOUT_MS);
388
673
  const onOpen = () => {
674
+ clearTimeout(this.openDeadline);
389
675
  this.emit('connected');
390
676
  resolve();
391
677
  };
392
- const onError = () => reject(new RpcError('connect-failed', 'websocket failed to open'));
678
+ const onError = () => {
679
+ clearTimeout(this.openDeadline);
680
+ reject(new RpcError('connect-failed', 'websocket failed to open'));
681
+ };
393
682
  ws.addEventListener('open', onOpen, { once: true });
394
683
  ws.addEventListener('error', onError, { once: true });
395
684
  ws.addEventListener('message', (ev) => {
396
- if (this.ws === ws)
685
+ if (this.ws === ws) {
686
+ this.socketLiveness.received();
397
687
  this.onMessage(ev.data);
688
+ }
398
689
  });
399
690
  ws.addEventListener('close', (ev) => {
400
691
  ws.removeEventListener('error', onError);
401
692
  // Only the socket we currently consider ours may drive reconnection.
402
693
  if (this.ws !== ws)
403
694
  return;
695
+ this.socketLiveness.stop();
696
+ this.mediaPaths.reset();
404
697
  this.ws = null;
405
698
  this.failAllPending(new RpcError('not-connected', `socket closed (${ev.code})`));
406
699
  this.emit('disconnected', ev.reason || String(ev.code));
@@ -433,6 +726,8 @@ export class StreamClient {
433
726
  });
434
727
  }
435
728
  detachSocket(reason = 'superseded', why = 'the connection was replaced') {
729
+ this.socketLiveness.stop();
730
+ clearTimeout(this.openDeadline);
436
731
  const ws = this.ws;
437
732
  this.ws = null;
438
733
  if (!ws)
@@ -482,7 +777,7 @@ export class StreamClient {
482
777
  return;
483
778
  }
484
779
  const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
485
- await sleep(backoff + Math.random() * 400);
780
+ await this.waitBackoff(backoff + Math.random() * 400);
486
781
  if (this.closedByUs)
487
782
  return;
488
783
  try {
@@ -518,6 +813,9 @@ export class StreamClient {
518
813
  }
519
814
  close() {
520
815
  void this.stopRecording().catch(() => undefined);
816
+ // Adaptive stream stops watching elements: nothing is received any more.
817
+ this.adaptive.dispose();
818
+ this.adaptivePaused.clear();
521
819
  // Only an app closing a live session is 'left'; kicked, room-closed and the
522
820
  // others have already reported themselves and set closedByUs.
523
821
  const wasOpen = !this.closedByUs;
@@ -551,7 +849,15 @@ export class StreamClient {
551
849
  this.emit('closed', 'left');
552
850
  }
553
851
  teardownMedia() {
852
+ this.mediaPaths.reset();
853
+ // The videos go with their consumers; a reconnect decides afresh. What was reported held back is withdrawn, so an
854
+ // app's "poor connection" overlay does not outlive it.
855
+ this.clearFallbacks();
554
856
  this.subscriptions.clear();
857
+ // Producers made after this are new: the server says again what their viewers need.
858
+ this.layersNeeded.clear();
859
+ // New consumers start paused at the server; adaptive stream decides again for each.
860
+ this.adaptivePaused.clear();
555
861
  for (const c of this.consumers.values())
556
862
  c.close();
557
863
  this.consumers.clear();
@@ -771,6 +1077,35 @@ export class StreamClient {
771
1077
  case 'newProducer':
772
1078
  await this.consume(data).catch((err) => this.emit('error', err));
773
1079
  break;
1080
+ case 'streamFallback': {
1081
+ // The media server cut (or resumed) one of this viewer's videos for its bandwidth.
1082
+ const e = data;
1083
+ if (typeof e.producerId !== 'string' || !this.consumers.has(e.producerId))
1084
+ break;
1085
+ const cut = e.fallback === true;
1086
+ if (cut === this.bandwidthCut.has(e.producerId))
1087
+ break;
1088
+ if (cut)
1089
+ this.bandwidthCut.add(e.producerId);
1090
+ else
1091
+ this.bandwidthCut.delete(e.producerId);
1092
+ this.emitFallback(e.producerId, cut, 'bandwidth');
1093
+ break;
1094
+ }
1095
+ case 'producerLayers': {
1096
+ // Dynacast: what this client's viewers need from one of its producers. Layers nobody receives are not
1097
+ // encoded (the smallest always is, so a new viewer starts at once); null means every layer.
1098
+ const e = data;
1099
+ if (typeof e.producerId !== 'string')
1100
+ break;
1101
+ if (typeof e.spatialLayer === 'number' && Number.isInteger(e.spatialLayer))
1102
+ this.layersNeeded.set(e.producerId, e.spatialLayer);
1103
+ else
1104
+ this.layersNeeded.delete(e.producerId);
1105
+ if (this.cameraProducer()?.id === e.producerId)
1106
+ void this.cameraTask(() => this.syncCameraLayers());
1107
+ break;
1108
+ }
774
1109
  case 'producerClosed': {
775
1110
  const { producerId, identity } = data;
776
1111
  this.subscriptions.delete(producerId);
@@ -778,6 +1113,9 @@ export class StreamClient {
778
1113
  this.receiveQualities.delete(producerId);
779
1114
  this.remoteVideoLayers.delete(producerId);
780
1115
  this.skippedVideos.delete(producerId);
1116
+ this.adaptivePaused.delete(producerId);
1117
+ this.bandwidthCut.delete(producerId);
1118
+ this.protectionHeld.delete(producerId);
781
1119
  const consumer = this.consumers.get(producerId);
782
1120
  consumer?.close();
783
1121
  this.consumers.delete(producerId);
@@ -814,6 +1152,8 @@ export class StreamClient {
814
1152
  case 'participantMuted': {
815
1153
  const wire = data;
816
1154
  const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
1155
+ if (e.identity && e.identity === this.joinResult?.identity)
1156
+ this.moderate(e.kind, e.paused, true);
817
1157
  this.updateRoster((list) => list.map((p) => p.identity !== e.identity ? p : { ...p,
818
1158
  ...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
819
1159
  ...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
@@ -831,6 +1171,9 @@ export class StreamClient {
831
1171
  const e = data;
832
1172
  this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
833
1173
  this.emit('participantLeft', e);
1174
+ const speakers = this.speakers.remove(e.identity);
1175
+ if (speakers)
1176
+ this.emit('activeSpeakers', { speakers });
834
1177
  break;
835
1178
  }
836
1179
  case 'roleChanged': {
@@ -860,6 +1203,7 @@ export class StreamClient {
860
1203
  // in-app music: it travels as audio). Reflect it locally; a release
861
1204
  // changes nothing here -- switching back on is the user's choice.
862
1205
  const e = data;
1206
+ this.moderate(e.kind, Boolean(e.paused));
863
1207
  if (e.paused) {
864
1208
  this.applyLocalModeration(e.kind);
865
1209
  this.emitLocalMedia();
@@ -977,16 +1321,21 @@ export class StreamClient {
977
1321
  return Promise.resolve(this.sendTransport);
978
1322
  if (this.sendTransportPending)
979
1323
  return this.sendTransportPending;
980
- this.sendTransportPending = this.createSendTransport().finally(() => {
981
- this.sendTransportPending = null;
1324
+ const pending = this.createSendTransport().finally(() => {
1325
+ if (this.sendTransportPending === pending)
1326
+ this.sendTransportPending = null;
982
1327
  });
983
- return this.sendTransportPending;
1328
+ return this.sendTransportPending = pending;
984
1329
  }
985
1330
  async createSendTransport() {
986
1331
  if (!this.device)
987
1332
  throw new RpcError('not-connected', 'not connected');
1333
+ const device = this.device;
1334
+ const socket = this.ws;
988
1335
  const params = (await this.rpc('createTransport', { direction: 'send' }));
989
- const transport = this.device.createSendTransport({
1336
+ if (this.closedByUs || device !== this.device || socket !== this.ws)
1337
+ throw closedWhileConnecting();
1338
+ const transport = device.createSendTransport({
990
1339
  ...params,
991
1340
  iceServers: this.joinResult?.iceServers,
992
1341
  });
@@ -1004,6 +1353,7 @@ export class StreamClient {
1004
1353
  .catch(fail);
1005
1354
  });
1006
1355
  this.sendTransport = transport;
1356
+ this.watchTransport(transport);
1007
1357
  return transport;
1008
1358
  }
1009
1359
  ensureRecvTransport() {
@@ -1011,16 +1361,21 @@ export class StreamClient {
1011
1361
  return Promise.resolve(this.recvTransport);
1012
1362
  if (this.recvTransportPending)
1013
1363
  return this.recvTransportPending;
1014
- this.recvTransportPending = this.createRecvTransport().finally(() => {
1015
- this.recvTransportPending = null;
1364
+ const pending = this.createRecvTransport().finally(() => {
1365
+ if (this.recvTransportPending === pending)
1366
+ this.recvTransportPending = null;
1016
1367
  });
1017
- return this.recvTransportPending;
1368
+ return this.recvTransportPending = pending;
1018
1369
  }
1019
1370
  async createRecvTransport() {
1020
1371
  if (!this.device)
1021
1372
  throw new RpcError('not-connected', 'not connected');
1373
+ const device = this.device;
1374
+ const socket = this.ws;
1022
1375
  const params = (await this.rpc('createTransport', { direction: 'recv' }));
1023
- const transport = this.device.createRecvTransport({
1376
+ if (this.closedByUs || device !== this.device || socket !== this.ws)
1377
+ throw closedWhileConnecting();
1378
+ const transport = device.createRecvTransport({
1024
1379
  ...params,
1025
1380
  iceServers: this.joinResult?.iceServers,
1026
1381
  });
@@ -1030,6 +1385,7 @@ export class StreamClient {
1030
1385
  .catch(fail);
1031
1386
  });
1032
1387
  this.recvTransport = transport;
1388
+ this.watchTransport(transport);
1033
1389
  return transport;
1034
1390
  }
1035
1391
  /**
@@ -1063,10 +1419,28 @@ export class StreamClient {
1063
1419
  // leave nothing behind that a reconnect would publish again.
1064
1420
  if (generation !== this.publishGeneration)
1065
1421
  throw unpublishedWhile('stream');
1066
- this.localStream = stream;
1422
+ // A second publish adds to the first (the microphone, then the camera):
1423
+ // one local stream carries every live track, so unpublish() and close()
1424
+ // stop them all and a reconnect publishes each again. Replacing it
1425
+ // orphaned the first stream's tracks, still capturing (V072-01).
1426
+ const previous = this.localStream;
1427
+ const merged = previous !== null && previous !== stream && previous.getTracks().some((t) => t.readyState === 'live');
1428
+ if (merged) {
1429
+ const ids = new Set(stream.getTracks().map((t) => t.id));
1430
+ // The new stream's tracks first: of one kind, the newer track is the one
1431
+ // published (as before), and the older one is still stopped with it.
1432
+ this.localStream = new MediaStream([
1433
+ ...stream.getTracks(),
1434
+ ...previous.getTracks().filter((t) => t.readyState === 'live' && !ids.has(t.id)),
1435
+ ]);
1436
+ this.emit('localStreamChanged', this.localStream);
1437
+ }
1438
+ else {
1439
+ this.localStream = stream;
1440
+ }
1067
1441
  this.wasPublishing = true;
1068
1442
  if (constraints)
1069
- this.publishConstraints = constraints;
1443
+ this.publishConstraints = merged ? mergeConstraints(this.publishConstraints, constraints) : constraints;
1070
1444
  if (options) {
1071
1445
  this.publishOptions = { ...options };
1072
1446
  this.frameRateFallback = false;
@@ -1177,6 +1551,7 @@ export class StreamClient {
1177
1551
  appData: { source: 'mic', audioQuality: quality },
1178
1552
  });
1179
1553
  await this.keepProducer(producer, generation, 'microphone');
1554
+ this.watchTrackEnded(producer);
1180
1555
  this.micQuality = quality;
1181
1556
  // A microphone handed over switched off, or muted before a reconnect.
1182
1557
  await this.pauseOnServer(producer);
@@ -1323,6 +1698,7 @@ export class StreamClient {
1323
1698
  if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
1324
1699
  previousSize?.width === width && previousSize?.height === height) {
1325
1700
  await current.replaceTrack({ track });
1701
+ this.watchTrackEnded(current);
1326
1702
  await this.applyLayerBitrates(current, next);
1327
1703
  return;
1328
1704
  }
@@ -1426,7 +1802,9 @@ export class StreamClient {
1426
1802
  this.receiveQualities.delete(producerId);
1427
1803
  // Kept first: a reconnect that interrupts the request still applies it.
1428
1804
  this.preferredLayers.set(producerId, layers);
1429
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
1805
+ // Shown through attachVideo: the lower of this and the size it is shown at.
1806
+ const adaptive = this.adaptive.current(producerId);
1807
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
1430
1808
  }
1431
1809
  async collectVideoStats(baseline) {
1432
1810
  const producer = this.cameraProducer();
@@ -1465,7 +1843,8 @@ export class StreamClient {
1465
1843
  }
1466
1844
  this.receiveQualities.set(producerId, quality);
1467
1845
  this.preferredLayers.set(producerId, layers);
1468
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
1846
+ const adaptive = this.adaptive.current(producerId);
1847
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
1469
1848
  }
1470
1849
  cameraProducer() {
1471
1850
  for (const p of this.producers.values()) {
@@ -1557,14 +1936,65 @@ export class StreamClient {
1557
1936
  // unpublish() may have run while this was being made (a publish, a
1558
1937
  // quality change or a track swap under way).
1559
1938
  await this.keepProducer(producer, generation, 'camera');
1939
+ this.watchTrackEnded(producer);
1940
+ this.syncPublishProtection();
1560
1941
  this.cameraEncodings = encodings;
1561
1942
  this.frameRateLowSamples = 0;
1562
1943
  this.frameRateWarmupSamples = 2;
1563
1944
  await this.pauseOnServer(producer);
1564
1945
  await this.applyDegradationPreference(producer);
1565
1946
  this.syncLimitWatch();
1947
+ // A new camera producer starts with every layer: put the performance and viewer limits back on it.
1948
+ await this.syncCameraLayers();
1566
1949
  return producer;
1567
1950
  }
1951
+ /**
1952
+ * Lighten this device's work while it is overloaded (LiveKit's
1953
+ * `prioritizePerformance`): the camera's largest simulcast layer stops being
1954
+ * encoded -- the most expensive part of publishing -- and viewers get the
1955
+ * next one. `false` brings it back. Call it on `cpuConstrained`, together
1956
+ * with your app's own savings (effects off, smaller remote videos). Kept for
1957
+ * later publishes and reconnects until changed. A camera sent as one layer
1958
+ * is left as it is.
1959
+ */
1960
+ async prioritizePerformance(on = true) {
1961
+ this.performanceFirst = on;
1962
+ await this.cameraTask(() => this.syncCameraLayers());
1963
+ }
1964
+ /** Whether `prioritizePerformance()` is in force. */
1965
+ get performancePrioritized() {
1966
+ return this.performanceFirst;
1967
+ }
1968
+ /**
1969
+ * The camera's active simulcast layers: every layer, except the top one while
1970
+ * performance comes first, and none above the highest any viewer needs
1971
+ * (dynacast; the smallest always stays on, so a new viewer starts at once).
1972
+ */
1973
+ async syncCameraLayers() {
1974
+ const producer = this.cameraProducer();
1975
+ const count = this.cameraEncodings?.length ?? 0;
1976
+ if (!producer || producer.closed || count < 2)
1977
+ return;
1978
+ const top = count - 1;
1979
+ let max = top;
1980
+ if (this.performanceFirst)
1981
+ max = Math.min(max, top - 1);
1982
+ const needed = this.layersNeeded.get(producer.id);
1983
+ if (needed !== undefined)
1984
+ max = Math.min(max, Math.max(0, needed));
1985
+ // A producer just made sends every layer: nothing to ask of it until a limit applies.
1986
+ const applied = this.cameraMaxLayer?.producerId === producer.id ? this.cameraMaxLayer.layer : top;
1987
+ if (applied === max)
1988
+ return;
1989
+ try {
1990
+ await producer.setMaxSpatialLayer(max);
1991
+ this.cameraMaxLayer = { producerId: producer.id, layer: max };
1992
+ }
1993
+ catch (err) {
1994
+ if (!producer.closed)
1995
+ this.emit('error', err);
1996
+ }
1997
+ }
1568
1998
  /**
1569
1999
  * Keep a producer just made -- unless unpublish() ran while it was being
1570
2000
  * made. Kept, it would publish again what was just unpublished, so it is
@@ -1639,7 +2069,7 @@ export class StreamClient {
1639
2069
  }
1640
2070
  /** videoLimited sampling runs while a camera is published and someone listens. */
1641
2071
  syncLimitWatch() {
1642
- this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
2072
+ this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.listenerCount('cpuConstrained') > 0 || this.cameraFrameRate === 60));
1643
2073
  }
1644
2074
  /**
1645
2075
  * Re-establish this client's broadcast after a reconnect.
@@ -2099,6 +2529,8 @@ export class StreamClient {
2099
2529
  const consumer = await transport.consume(params);
2100
2530
  current(consumer);
2101
2531
  this.consumers.set(remote.producerId, consumer);
2532
+ if (this.playoutDelayMs !== null)
2533
+ this.applyPlayoutDelay(consumer.rtpReceiver);
2102
2534
  this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
2103
2535
  // Layers asked for before a reconnect: the new consumer gets them before
2104
2536
  // its first frame instead of starting at the full picture. Not getting
@@ -2114,13 +2546,27 @@ export class StreamClient {
2114
2546
  this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
2115
2547
  }
2116
2548
  }
2549
+ // Adaptive stream: a video the app shows through attachVideo starts at the
2550
+ // size it is shown, and one nobody can see stays paused.
2551
+ const adaptive = consumer.kind === 'video' ? this.adaptive.current(remote.producerId) : null;
2552
+ if (adaptive)
2553
+ layers = this.effectiveLayers(remote.producerId, adaptive);
2554
+ // A camera arriving while the audio-only fallback holds cameras back starts held back too.
2555
+ const held = consumer.kind === 'video' && this.protection.active && (remote.source ?? 'camera') === 'camera';
2117
2556
  if (layers) {
2118
2557
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
2119
2558
  }
2120
2559
  current(consumer);
2121
2560
  // The server created it paused. Resume only now that our end exists,
2122
2561
  // otherwise the first frames arrive before there is anything to decode them.
2123
- await this.rpc('resumeConsumer', { consumerId: consumer.id });
2562
+ this.adaptivePaused.delete(remote.producerId);
2563
+ if ((adaptive && !adaptive.visible) || (held && !this.pauseUnsupported))
2564
+ this.adaptivePaused.add(remote.producerId);
2565
+ else
2566
+ await this.rpc('resumeConsumer', { consumerId: consumer.id });
2567
+ if (held && this.pauseUnsupported) {
2568
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, spatialLayer: 0, temporalLayer: 0 }).catch(() => undefined);
2569
+ }
2124
2570
  current(consumer);
2125
2571
  const kind = consumer.kind;
2126
2572
  const source = remote.source ?? defaultSource(kind);
@@ -2133,6 +2579,681 @@ export class StreamClient {
2133
2579
  producerId: remote.producerId,
2134
2580
  source,
2135
2581
  });
2582
+ if (kind === 'video')
2583
+ this.adaptive.consumed(remote.producerId);
2584
+ if (held) {
2585
+ this.protectionHeld.add(remote.producerId);
2586
+ this.emitFallback(remote.producerId, true, 'audio-protection');
2587
+ }
2588
+ this.syncProtection();
2589
+ }
2590
+ /**
2591
+ * Adaptive stream (like LiveKit's adaptiveStream and Tencent TRTC's small
2592
+ * stream with receiveWhenViewVisible): tell the SDK which element shows a
2593
+ * remote video. It then receives the smallest layer that fills that element
2594
+ * sharply (its size on screen × devicePixelRatio, at most 2) and pauses the
2595
+ * video at the server while the element is not visible (scrolled away,
2596
+ * `display: none`, or the tab in the background), so a grid of small tiles
2597
+ * stops decoding full-size video. Attach the same video to several elements
2598
+ * and the largest visible one decides. Returns the function that detaches
2599
+ * it; detach when the element goes away. A ceiling set with
2600
+ * `setReceiveQuality` or `setPreferredLayers` still applies. Works with any
2601
+ * server; one without `pauseConsumer` gets the smallest layer instead of a
2602
+ * pause.
2603
+ */
2604
+ attachVideo(producerId, element) {
2605
+ return this.adaptive.attach(producerId, element);
2606
+ }
2607
+ /** The layers to ask for: the app's ceiling (setReceiveQuality / setPreferredLayers) and adaptive stream's, the lower. */
2608
+ effectiveLayers(producerId, want) {
2609
+ let manual = this.preferredLayers.get(producerId);
2610
+ const quality = this.receiveQualities.get(producerId);
2611
+ if (quality && quality !== 'auto') {
2612
+ try {
2613
+ manual = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) };
2614
+ }
2615
+ catch {
2616
+ manual = undefined;
2617
+ }
2618
+ }
2619
+ const adaptive = want?.visible ? want.spatialLayer : null;
2620
+ if (adaptive === null || adaptive === undefined)
2621
+ return manual ?? { spatialLayer: -1, temporalLayer: -1 };
2622
+ if (!manual || manual.spatialLayer < 0)
2623
+ return { spatialLayer: adaptive };
2624
+ return { ...manual, spatialLayer: Math.min(manual.spatialLayer, adaptive) };
2625
+ }
2626
+ // ------------------------------------------------------- diagnostics
2627
+ /**
2628
+ * A snapshot for a support ticket (what Agora's log upload and Twilio's Insights are used for): the SDK version and
2629
+ * browser, the session (room, identity, role, state), how each media transport is connected (UDP, TCP or relayed),
2630
+ * the round trip and upload now, what is published and received, and the last 200 notable events (state changes,
2631
+ * errors, recoveries, fallbacks, warnings) with their times. Plain JSON: `JSON.stringify` it and attach it. Holds
2632
+ * no tokens and no media.
2633
+ */
2634
+ async getDiagnostics() {
2635
+ const transport = async (t) => {
2636
+ if (!t || t.closed)
2637
+ return null;
2638
+ let path = null;
2639
+ try {
2640
+ path = connectionPath((await t.getStats()).values());
2641
+ }
2642
+ catch {
2643
+ // Best effort.
2644
+ }
2645
+ return { state: t.connectionState, path };
2646
+ };
2647
+ let network = null;
2648
+ try {
2649
+ const reports = await this.transportStats();
2650
+ const current = new Map([...reports].map(([key, report]) => [key, readTransportStats(report)]));
2651
+ if (current.size > 0) {
2652
+ const q = networkQualityOf(current, new Map(), this.producers.size > 0);
2653
+ network = { rttMs: q.rttMs, availableKbps: q.availableKbps };
2654
+ }
2655
+ }
2656
+ catch {
2657
+ // Best effort.
2658
+ }
2659
+ const join = this.joinResult;
2660
+ return {
2661
+ sdk: SDK_VERSION,
2662
+ at: Date.now(),
2663
+ userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
2664
+ connectionState: this.state,
2665
+ room: join?.roomId ?? null,
2666
+ identity: join?.identity ?? null,
2667
+ role: join?.role ?? null,
2668
+ transports: { send: await transport(this.sendTransport), recv: await transport(this.recvTransport) },
2669
+ network,
2670
+ published: [...this.producers.values()].filter((p) => !p.closed)
2671
+ .map((p) => ({ source: String(p.appData.source ?? p.kind), kind: p.kind, paused: p.paused })),
2672
+ received: [...this.consumers.entries()].filter(([, c]) => !c.closed).map(([producerId, c]) => ({
2673
+ identity: this.remoteInfo.get(producerId)?.identity ?? '',
2674
+ source: this.remoteSources.get(producerId) ?? (c.kind === 'audio' ? 'mic' : 'camera'),
2675
+ kind: c.kind,
2676
+ paused: c.paused || this.adaptivePaused.has(producerId),
2677
+ })),
2678
+ log: this.diagnosticsLog.list(),
2679
+ };
2680
+ }
2681
+ // ------------------------------------------------------- autoplay
2682
+ /**
2683
+ * Play a `<video>` or `<audio>` showing a remote track, the way autoplay rules allow (Agora's and Tencent TRTC's
2684
+ * autoplay handling). Resolves true when it plays. When the browser refuses until the viewer interacts with the
2685
+ * page, resolves false, emits `autoplayFailed` with a `resume()` for a "Tap to unmute" button, and plays it -- with
2686
+ * every other refused element -- on the next tap, click or key press by itself.
2687
+ */
2688
+ async play(element) {
2689
+ try {
2690
+ await element.play();
2691
+ this.autoplayBlocked.delete(element);
2692
+ return true;
2693
+ }
2694
+ catch (err) {
2695
+ if (err?.name !== 'NotAllowedError')
2696
+ throw err;
2697
+ this.autoplayBlocked.add(element);
2698
+ this.watchGesture(true);
2699
+ this.emit('autoplayFailed', { element, resume: async () => { await this.play(element); } });
2700
+ return false;
2701
+ }
2702
+ }
2703
+ watchGesture(on) {
2704
+ if (typeof document === 'undefined' || on === this.gestureWatchOn)
2705
+ return;
2706
+ this.gestureWatchOn = on;
2707
+ for (const type of ['pointerdown', 'keydown', 'touchend']) {
2708
+ if (on)
2709
+ document.addEventListener(type, this.onGesture, { capture: true });
2710
+ else
2711
+ document.removeEventListener(type, this.onGesture, { capture: true });
2712
+ }
2713
+ }
2714
+ async retryAutoplay() {
2715
+ for (const element of [...this.autoplayBlocked]) {
2716
+ try {
2717
+ await element.play();
2718
+ this.autoplayBlocked.delete(element);
2719
+ }
2720
+ catch {
2721
+ // Still refused (or the element went away); the next gesture tries again.
2722
+ if (!element.isConnected)
2723
+ this.autoplayBlocked.delete(element);
2724
+ }
2725
+ }
2726
+ if (this.autoplayBlocked.size === 0)
2727
+ this.watchGesture(false);
2728
+ }
2729
+ // ------------------------------------------------------- playout delay
2730
+ /**
2731
+ * Trade a little delay for smoother playback (Tencent TRTC's playoutDelay): every remote track is buffered about
2732
+ * `ms` before it plays, which rides out the jitter of a mobile network without freezes. For an audience watching a
2733
+ * stream, 300-500 ms is a good value; people talking with each other want `null` (the browser's own, as small as
2734
+ * it can keep). Applies to what is received now and later. Browsers without the setting (before Chrome 114) keep
2735
+ * their own; 0-4000 ms.
2736
+ */
2737
+ setPlayoutDelay(ms) {
2738
+ this.playoutDelayMs = ms === null ? null : Math.min(4000, Math.max(0, Math.round(ms)));
2739
+ for (const consumer of this.consumers.values())
2740
+ this.applyPlayoutDelay(consumer.rtpReceiver);
2741
+ }
2742
+ applyPlayoutDelay(receiver) {
2743
+ if (!receiver || !('jitterBufferTarget' in receiver))
2744
+ return;
2745
+ try {
2746
+ receiver.jitterBufferTarget = this.playoutDelayMs;
2747
+ }
2748
+ catch {
2749
+ // Out of the browser's range: it keeps its own.
2750
+ }
2751
+ }
2752
+ // ------------------------------------------------------- audio levels
2753
+ /** Levels are read only while someone listens and the session is connected. */
2754
+ syncLevelWatch() {
2755
+ const wanted = !this.closedByUs && this.state === 'connected' &&
2756
+ (this.listenerCount('audioLevels') > 0 || this.listenerCount('activeSpeakers') > 0);
2757
+ if (wanted && !this.levelTimer) {
2758
+ this.levelTimer = setInterval(() => void this.sampleAudioLevels(), AUDIO_LEVEL_INTERVAL_MS);
2759
+ this.levelTimer.unref?.();
2760
+ }
2761
+ else if (!wanted) {
2762
+ this.stopLevelWatch();
2763
+ }
2764
+ }
2765
+ stopLevelWatch() {
2766
+ if (this.levelTimer)
2767
+ clearInterval(this.levelTimer);
2768
+ this.levelTimer = null;
2769
+ this.speakers.reset();
2770
+ }
2771
+ /**
2772
+ * One reading: each remote audio from its receiver's synchronization sources (the most recent packet's level, no
2773
+ * statistics report needed), the own microphone from its sender's media-source statistics.
2774
+ */
2775
+ async sampleAudioLevels() {
2776
+ if (this.levelSampling)
2777
+ return;
2778
+ this.levelSampling = true;
2779
+ try {
2780
+ const levels = [];
2781
+ for (const [producerId, consumer] of this.consumers) {
2782
+ if (consumer.kind !== 'audio' || consumer.closed)
2783
+ continue;
2784
+ const info = this.remoteInfo.get(producerId);
2785
+ if (!info)
2786
+ continue;
2787
+ const level = info.paused ? 0 : receiverLevel(consumer.rtpReceiver);
2788
+ levels.push({ identity: info.identity, producerId, source: this.remoteSources.get(producerId) ?? 'mic', level, speaking: level > SPEAKING_LEVEL, local: false });
2789
+ }
2790
+ const mic = this.micProducer();
2791
+ const self = this.joinResult?.identity;
2792
+ if (mic && !mic.paused && self) {
2793
+ const level = await senderLevel(mic);
2794
+ levels.push({ identity: self, producerId: mic.id, source: 'mic', level, speaking: level > SPEAKING_LEVEL, local: true });
2795
+ }
2796
+ if (!this.levelTimer)
2797
+ return;
2798
+ if (this.listenerCount('audioLevels') > 0)
2799
+ this.emit('audioLevels', levels);
2800
+ const loudest = new Map();
2801
+ for (const l of levels)
2802
+ if (l.source === 'mic')
2803
+ loudest.set(l.identity, Math.max(loudest.get(l.identity) ?? 0, l.level));
2804
+ const speakers = this.speakers.update(loudest);
2805
+ if (speakers)
2806
+ this.emit('activeSpeakers', { speakers });
2807
+ }
2808
+ catch {
2809
+ // Best effort; the next reading tries again.
2810
+ }
2811
+ finally {
2812
+ this.levelSampling = false;
2813
+ }
2814
+ }
2815
+ // ------------------------------------------------------- media health
2816
+ /** The published devices are sampled only while someone listens and the session is connected. */
2817
+ syncHealthWatch() {
2818
+ const wanted = !this.closedByUs && this.state === 'connected' && this.listenerCount('mediaWarning') > 0;
2819
+ if (wanted && !this.healthTimer) {
2820
+ this.healthTimer = setInterval(() => void this.sampleHealth(), HEALTH_SAMPLE_MS);
2821
+ this.healthTimer.unref?.();
2822
+ }
2823
+ else if (!wanted) {
2824
+ this.stopHealthWatch(this.listenerCount('mediaWarning') > 0 && !this.closedByUs);
2825
+ }
2826
+ }
2827
+ /** Stop sampling; `report` says each active warning cleared (nothing is said after close). */
2828
+ stopHealthWatch(report) {
2829
+ if (this.healthTimer)
2830
+ clearInterval(this.healthTimer);
2831
+ this.healthTimer = null;
2832
+ for (const timer of this.interruptions.values())
2833
+ clearTimeout(timer);
2834
+ this.interruptions.clear();
2835
+ if (report)
2836
+ this.health.clearAll();
2837
+ }
2838
+ async sampleHealth() {
2839
+ if (this.healthSampling)
2840
+ return;
2841
+ this.healthSampling = true;
2842
+ try {
2843
+ const mic = this.micProducer();
2844
+ const camera = this.cameraProducer();
2845
+ const micLevel = mic && !mic.paused ? readMediaSource((await mic.getStats()).values()).level : null;
2846
+ const cameraFps = camera && !camera.paused ? readMediaSource((await camera.getStats()).values()).fps : null;
2847
+ if (!this.healthTimer)
2848
+ return;
2849
+ this.health.feed({ micLevel, cameraFps, askedFps: this.videoFrameRate });
2850
+ // An interrupted device that is no longer published never says it came back: that is over too.
2851
+ if (!mic)
2852
+ this.health.interrupted('mic-interrupted', false);
2853
+ if (!camera)
2854
+ this.health.interrupted('camera-interrupted', false);
2855
+ }
2856
+ catch {
2857
+ // Best effort; the next sample tries again.
2858
+ }
2859
+ finally {
2860
+ this.healthSampling = false;
2861
+ }
2862
+ }
2863
+ // ---------------------------------------------------------------- devices
2864
+ /**
2865
+ * The cameras, microphones and speakers the browser offers, optionally of one kind. Labels are empty until the
2866
+ * page has been granted camera or microphone access.
2867
+ */
2868
+ listDevices(kind) {
2869
+ return enumerate(kind);
2870
+ }
2871
+ /**
2872
+ * Publish from another camera or microphone (LiveKit's switchActiveDevice, Agora's setDevice). The device is
2873
+ * captured with the constraints publish was given (size, frame rate, echo cancellation) and its track replaces the
2874
+ * published one: a camera of the same size swaps with no pause for viewers, a microphone always does. The track it
2875
+ * replaces is stopped, releasing that device; the client's own stream follows (`localStreamChanged`), and a
2876
+ * reconnect captures the chosen device again. A camera or microphone that is switched off stays off.
2877
+ *
2878
+ * For a camera published as a canvas or another processed track, capture the new camera yourself and pass your
2879
+ * processed track to `replaceVideoTrack`; this rejects rather than replace your picture with the raw camera.
2880
+ * Rejects when that kind is not being published.
2881
+ */
2882
+ async switchDevice(kind, deviceId) {
2883
+ const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
2884
+ if (!md?.getUserMedia)
2885
+ throw new Error('switchDevice: this browser cannot capture media');
2886
+ if (kind === 'videoinput') {
2887
+ const current = this.cameraProducer()?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
2888
+ if (!current)
2889
+ throw new Error('switchDevice: no camera is being published');
2890
+ if (!deviceIdOf(current)) {
2891
+ throw new Error('switchDevice: the published video is not a camera capture (a canvas or processed track); capture the camera yourself and call replaceVideoTrack');
2892
+ }
2893
+ const video = constraintsFor(this.publishConstraints?.video, deviceId);
2894
+ const track = (await md.getUserMedia({ video })).getVideoTracks()[0];
2895
+ if (!track)
2896
+ throw new Error('switchDevice: the camera gave no video');
2897
+ try {
2898
+ await this.replaceVideoTrack(track);
2899
+ }
2900
+ catch (err) {
2901
+ track.stop();
2902
+ throw err;
2903
+ }
2904
+ if (current !== track)
2905
+ current.stop();
2906
+ this.publishConstraints = { ...(this.publishConstraints ?? {}), video };
2907
+ return;
2908
+ }
2909
+ await this.micTask(async () => {
2910
+ const producer = this.micProducer();
2911
+ if (!producer)
2912
+ throw new Error('switchDevice: no microphone is being published');
2913
+ const audio = constraintsFor(this.publishConstraints?.audio, deviceId);
2914
+ const track = (await md.getUserMedia({ audio })).getAudioTracks()[0];
2915
+ if (!track)
2916
+ throw new Error('switchDevice: the microphone gave no audio');
2917
+ if (producer.closed || this.micProducer() !== producer) {
2918
+ track.stop();
2919
+ throw new Error('switchDevice: the microphone was unpublished meanwhile');
2920
+ }
2921
+ await this.adoptAudioTrack(producer, track);
2922
+ this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
2923
+ });
2924
+ }
2925
+ /** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
2926
+ async adoptAudioTrack(producer, track) {
2927
+ const previous = producer.track;
2928
+ await producer.replaceTrack({ track });
2929
+ const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
2930
+ this.localStream = new MediaStream([track, ...video]);
2931
+ this.emit('localStreamChanged', this.localStream);
2932
+ if (previous && previous !== track)
2933
+ previous.stop();
2934
+ this.watchTrackEnded(producer);
2935
+ }
2936
+ /** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
2937
+ watchTrackEnded(producer) {
2938
+ const track = producer.track;
2939
+ if (!track || typeof track.addEventListener !== 'function')
2940
+ return;
2941
+ track.addEventListener('ended', () => { void this.onTrackEnded(producer, track); }, { once: true });
2942
+ // The system taking the device (a call, another app) mutes the track; held a moment, it is reported.
2943
+ const which = producer.appData.source === 'mic' ? 'mic' : producer.appData.source === 'camera' ? 'camera' : null;
2944
+ if (!which)
2945
+ return;
2946
+ const code = which === 'mic' ? 'mic-interrupted' : 'camera-interrupted';
2947
+ const current = () => !this.closedByUs && !producer.closed && producer.track === track;
2948
+ track.addEventListener('mute', () => {
2949
+ if (!current() || this.interruptions.has(which))
2950
+ return;
2951
+ this.interruptions.set(which, setTimeout(() => {
2952
+ this.interruptions.delete(which);
2953
+ if (current() && track.muted)
2954
+ this.health.interrupted(code, true);
2955
+ }, INTERRUPTED_AFTER_MS));
2956
+ });
2957
+ track.addEventListener('unmute', () => {
2958
+ const timer = this.interruptions.get(which);
2959
+ if (timer)
2960
+ clearTimeout(timer);
2961
+ this.interruptions.delete(which);
2962
+ if (current())
2963
+ this.health.interrupted(code, false);
2964
+ });
2965
+ }
2966
+ /**
2967
+ * A published camera or microphone stopped from outside. The microphone comes back from the system default (Chime
2968
+ * keeps audio flowing, LiveKit moves to the first available device); a camera is switched off and reported, never
2969
+ * swapped for another camera on its own (Chime stops it the same way).
2970
+ */
2971
+ async onTrackEnded(producer, track) {
2972
+ const live = () => !this.closedByUs && !producer.closed && producer.track === track && this.producers.get(producer.id) === producer;
2973
+ if (!live())
2974
+ return;
2975
+ const deviceId = deviceIdOf(track);
2976
+ if (producer.appData.source === 'mic') {
2977
+ await this.micTask(async () => {
2978
+ if (!live())
2979
+ return;
2980
+ try {
2981
+ const md = navigator.mediaDevices;
2982
+ const audio = constraintsFor(this.publishConstraints?.audio, null);
2983
+ const next = (await md.getUserMedia({ audio })).getAudioTracks()[0];
2984
+ if (!next)
2985
+ throw new Error('no microphone');
2986
+ if (!live()) {
2987
+ next.stop();
2988
+ return;
2989
+ }
2990
+ await this.adoptAudioTrack(producer, next);
2991
+ this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
2992
+ this.emit('localTrackEnded', { kind: 'audio', deviceId, recovered: true });
2993
+ }
2994
+ catch {
2995
+ if (!live())
2996
+ return;
2997
+ await this.setMicEnabled(false).catch(() => undefined);
2998
+ this.emit('localTrackEnded', { kind: 'audio', deviceId, recovered: false });
2999
+ }
3000
+ });
3001
+ return;
3002
+ }
3003
+ if (producer.appData.source === 'camera') {
3004
+ await this.setCamEnabled(false).catch(() => undefined);
3005
+ if (!this.closedByUs)
3006
+ this.emit('localTrackEnded', { kind: 'video', deviceId, recovered: false });
3007
+ }
3008
+ }
3009
+ /** devicesChanged is watched only while someone listens. */
3010
+ syncDeviceWatch() {
3011
+ const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
3012
+ const wanted = !this.closedByUs && this.listenerCount('devicesChanged') > 0 && typeof md?.addEventListener === 'function';
3013
+ if (wanted && !this.deviceWatchOn) {
3014
+ this.deviceWatchOn = true;
3015
+ md.addEventListener('devicechange', this.onDeviceChange);
3016
+ void enumerate().then((list) => { if (this.deviceWatchOn)
3017
+ this.knownDevices = list; }).catch(() => undefined);
3018
+ }
3019
+ else if (!wanted) {
3020
+ this.stopDeviceWatch();
3021
+ }
3022
+ }
3023
+ stopDeviceWatch() {
3024
+ if (!this.deviceWatchOn)
3025
+ return;
3026
+ this.deviceWatchOn = false;
3027
+ navigator.mediaDevices?.removeEventListener?.('devicechange', this.onDeviceChange);
3028
+ }
3029
+ async refreshDevices() {
3030
+ let now;
3031
+ try {
3032
+ now = await enumerate();
3033
+ }
3034
+ catch {
3035
+ return;
3036
+ }
3037
+ if (!this.deviceWatchOn)
3038
+ return;
3039
+ const { added, removed } = diffDevices(this.knownDevices, now);
3040
+ this.knownDevices = now;
3041
+ if (added.length > 0 || removed.length > 0)
3042
+ this.emit('devicesChanged', { devices: now, added, removed });
3043
+ }
3044
+ /** Adaptive stream's decision for a video, with the audio-only fallback holding remote cameras back. */
3045
+ withFallback(producerId, want) {
3046
+ if (this.protection.active && this.remoteSources.get(producerId) === 'camera')
3047
+ return { visible: false, spatialLayer: null };
3048
+ return want;
3049
+ }
3050
+ /**
3051
+ * Audio-only fallback (like Agora's AUDIO_ONLY stream fallback): on unless `audioOnlyFallback: false` was given.
3052
+ * While this viewer's received sound breaks up badly -- a quarter of it lost over about 15 s -- remote camera
3053
+ * videos are paused at the server (screen shares stay) so what bandwidth there is goes to the audio; they come
3054
+ * back once it has recovered for a while, holding longer if it falls back again soon. Each held video is reported
3055
+ * by `streamFallback`. `false` brings any held video back now and stops watching.
3056
+ */
3057
+ setAudioOnlyFallback(enabled) {
3058
+ this.audioOnlyEnabled = enabled;
3059
+ if (!enabled)
3060
+ this.protection.release();
3061
+ this.syncProtection();
3062
+ }
3063
+ /**
3064
+ * The publisher side of the audio-only fallback (Agora's setLocalPublishFallbackOption(AUDIO_ONLY)), off by default:
3065
+ * while this client's own sound breaks up on the way to the server -- a quarter of it lost over about 15 s -- its
3066
+ * camera is paused (viewers see it paused) so the upload carries the voice, and resumed once it recovers, holding
3067
+ * longer after a relapse. Reported by `publishFallback`. A camera you or a moderator switch off meanwhile stays off.
3068
+ * `false` brings a held camera back now.
3069
+ */
3070
+ setPublishFallback(enabled) {
3071
+ this.publishFallbackEnabled = enabled;
3072
+ if (!enabled)
3073
+ this.publishProtection.release();
3074
+ this.syncPublishProtection();
3075
+ }
3076
+ syncPublishProtection() {
3077
+ const wanted = this.publishFallbackEnabled && !this.closedByUs && this.state === 'connected' &&
3078
+ this.micProducer() !== undefined && (this.cameraProducer() !== undefined || this.publishProtection.active);
3079
+ if (wanted && !this.publishProtectionTimer) {
3080
+ this.publishProtectionTimer = setInterval(() => void this.samplePublishProtection(), PROTECT_SAMPLE_MS);
3081
+ this.publishProtectionTimer.unref?.();
3082
+ }
3083
+ else if (!wanted && this.publishProtectionTimer) {
3084
+ clearInterval(this.publishProtectionTimer);
3085
+ this.publishProtectionTimer = null;
3086
+ }
3087
+ }
3088
+ async samplePublishProtection() {
3089
+ const transport = this.sendTransport;
3090
+ if (this.publishProtectionSampling || !transport || transport.closed)
3091
+ return;
3092
+ this.publishProtectionSampling = true;
3093
+ try {
3094
+ const report = await transport.getStats();
3095
+ if (transport !== this.sendTransport || !this.publishProtectionTimer)
3096
+ return;
3097
+ this.publishProtection.feed(readOutboundAudio(report.values()));
3098
+ }
3099
+ catch {
3100
+ // Best effort.
3101
+ }
3102
+ finally {
3103
+ this.publishProtectionSampling = false;
3104
+ }
3105
+ }
3106
+ /** The publisher-side fallback decided: hold the camera, or bring it back if it is still ours to bring back. */
3107
+ async onPublishProtection(active) {
3108
+ const camera = this.cameraProducer();
3109
+ if (active) {
3110
+ if (!camera || camera.paused) {
3111
+ this.syncPublishProtection();
3112
+ return;
3113
+ }
3114
+ this.cameraHeldByFallback = true;
3115
+ camera.pause();
3116
+ this.emit('publishFallback', { fallback: true });
3117
+ await this.rpc('pauseProducer', { producerId: camera.id, paused: true }).catch(() => undefined);
3118
+ this.emitLocalMedia();
3119
+ }
3120
+ else if (this.cameraHeldByFallback) {
3121
+ this.cameraHeldByFallback = false;
3122
+ if (camera && camera.paused && !this.forcedOff.video) {
3123
+ camera.resume();
3124
+ await this.rpc('pauseProducer', { producerId: camera.id, paused: false }).catch(() => undefined);
3125
+ this.emitLocalMedia();
3126
+ }
3127
+ this.emit('publishFallback', { fallback: false });
3128
+ }
3129
+ this.syncPublishProtection();
3130
+ }
3131
+ /** Whether the audio-only fallback is holding remote cameras back now. */
3132
+ get audioOnlyFallbackActive() {
3133
+ return this.protection.active;
3134
+ }
3135
+ /** The audio-only fallback decided: hold remote cameras back, or let them come back. */
3136
+ onAudioProtection(active) {
3137
+ for (const [producerId, consumer] of this.consumers) {
3138
+ if (consumer.kind !== 'video' || this.remoteSources.get(producerId) !== 'camera')
3139
+ continue;
3140
+ const want = this.withFallback(producerId, this.adaptive.current(producerId) ?? { visible: true, spatialLayer: null });
3141
+ void this.applyAdaptive(producerId, want);
3142
+ if (active && !this.protectionHeld.has(producerId)) {
3143
+ this.protectionHeld.add(producerId);
3144
+ this.emitFallback(producerId, true, 'audio-protection');
3145
+ }
3146
+ else if (!active && this.protectionHeld.delete(producerId)) {
3147
+ this.emitFallback(producerId, false, 'audio-protection');
3148
+ }
3149
+ }
3150
+ this.syncProtection();
3151
+ }
3152
+ emitFallback(producerId, fallback, reason) {
3153
+ const info = this.remoteInfo.get(producerId);
3154
+ this.emit('streamFallback', {
3155
+ producerId,
3156
+ identity: info?.identity ?? '',
3157
+ source: this.remoteSources.get(producerId) ?? 'camera',
3158
+ fallback,
3159
+ reason,
3160
+ });
3161
+ }
3162
+ /** Withdraw every held-back report (teardown; nothing is said after close) and forget the decisions. */
3163
+ clearFallbacks() {
3164
+ if (!this.closedByUs) {
3165
+ for (const producerId of this.bandwidthCut)
3166
+ this.emitFallback(producerId, false, 'bandwidth');
3167
+ for (const producerId of this.protectionHeld)
3168
+ this.emitFallback(producerId, false, 'audio-protection');
3169
+ }
3170
+ this.bandwidthCut.clear();
3171
+ this.protectionHeld.clear();
3172
+ this.protection.reset();
3173
+ this.stopProtection();
3174
+ }
3175
+ /**
3176
+ * The received audio is sampled only while the fallback could matter: enabled, connected, with remote audio to
3177
+ * judge by and a remote camera to hold back (or one held back already).
3178
+ */
3179
+ syncProtection() {
3180
+ let audio = false;
3181
+ let camera = false;
3182
+ for (const [producerId, consumer] of this.consumers) {
3183
+ if (consumer.closed)
3184
+ continue;
3185
+ if (consumer.kind === 'audio')
3186
+ audio = true;
3187
+ else if (this.remoteSources.get(producerId) === 'camera')
3188
+ camera = true;
3189
+ }
3190
+ const wanted = this.audioOnlyEnabled && !this.closedByUs && this.state === 'connected' && audio && (camera || this.protection.active);
3191
+ if (wanted && !this.protectionTimer) {
3192
+ this.protectionTimer = setInterval(() => void this.sampleProtection(), PROTECT_SAMPLE_MS);
3193
+ // Background sampling never keeps a server-side or test process alive (Node); browsers have no unref.
3194
+ this.protectionTimer.unref?.();
3195
+ }
3196
+ else if (!wanted) {
3197
+ this.stopProtection();
3198
+ }
3199
+ }
3200
+ stopProtection() {
3201
+ if (this.protectionTimer)
3202
+ clearInterval(this.protectionTimer);
3203
+ this.protectionTimer = null;
3204
+ }
3205
+ async sampleProtection() {
3206
+ const transport = this.recvTransport;
3207
+ if (this.protectionSampling || !transport || transport.closed)
3208
+ return;
3209
+ this.protectionSampling = true;
3210
+ try {
3211
+ const report = await transport.getStats();
3212
+ if (transport !== this.recvTransport || !this.protectionTimer)
3213
+ return;
3214
+ this.protection.feed(readInboundAudio(report.values()));
3215
+ }
3216
+ catch {
3217
+ // Statistics are best effort; the next sample tries again.
3218
+ }
3219
+ finally {
3220
+ this.protectionSampling = false;
3221
+ }
3222
+ }
3223
+ /** Apply adaptive stream's decision for one video: pause it, or resume it at its layer. */
3224
+ async applyAdaptive(producerId, want) {
3225
+ const consumer = this.consumers.get(producerId);
3226
+ // Not received (yet): a consumer made later starts from the decision in force.
3227
+ if (!consumer || consumer.kind !== 'video' || consumer.closed || this.closedByUs)
3228
+ return;
3229
+ try {
3230
+ if (!want.visible) {
3231
+ if (this.pauseUnsupported) {
3232
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, spatialLayer: 0, temporalLayer: 0 });
3233
+ return;
3234
+ }
3235
+ if (this.adaptivePaused.has(producerId))
3236
+ return;
3237
+ this.adaptivePaused.add(producerId);
3238
+ await this.rpc('pauseConsumer', { consumerId: consumer.id });
3239
+ return;
3240
+ }
3241
+ if (this.adaptivePaused.delete(producerId))
3242
+ await this.rpc('resumeConsumer', { consumerId: consumer.id });
3243
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...this.effectiveLayers(producerId, want) });
3244
+ }
3245
+ catch (err) {
3246
+ if (this.consumers.get(producerId) !== consumer || consumer.closed || this.closedByUs)
3247
+ return;
3248
+ if (!want.visible && err instanceof RpcError && err.code === 'unknown-method') {
3249
+ // An older server: drop to the smallest layer instead of pausing.
3250
+ this.adaptivePaused.delete(producerId);
3251
+ this.pauseUnsupported = true;
3252
+ await this.applyAdaptive(producerId, want);
3253
+ return;
3254
+ }
3255
+ this.emit('error', err);
3256
+ }
2136
3257
  }
2137
3258
  // ------------------------------------------------------------- moderation
2138
3259
  // ------------------------------------------------------------ local media
@@ -2180,21 +3301,63 @@ export class StreamClient {
2180
3301
  this.music?.setEnabled(false);
2181
3302
  }
2182
3303
  async setKindEnabled(kind, enabled) {
2183
- for (const producer of this.producers.values()) {
2184
- // The music has its own switch. Muting the microphone must not stop the song.
2185
- if (producer.kind !== kind || producer.appData.source !== defaultSource(kind))
3304
+ // Your own choice about the camera ends the publisher-side fallback's hold on it: it never undoes your choice.
3305
+ if (kind === 'video' && this.cameraHeldByFallback) {
3306
+ this.cameraHeldByFallback = false;
3307
+ this.publishProtection.reset();
3308
+ this.emit('publishFallback', { fallback: false });
3309
+ }
3310
+ const generation = this.publishGeneration;
3311
+ // Unpublished or closed while waiting: nothing is left to switch, and the
3312
+ // call resolves, as it does with nothing published (V072-03).
3313
+ const ended = () => generation !== this.publishGeneration || this.closedByUs;
3314
+ // The music has its own switch. Muting the microphone must not stop the song.
3315
+ const targets = [...this.producers.values()].filter((p) => p.kind === kind && p.appData.source === defaultSource(kind));
3316
+ for (const producer of targets) {
3317
+ if (ended())
3318
+ return;
3319
+ if (this.producers.get(producer.id) !== producer || producer.closed)
2186
3320
  continue;
3321
+ const revision = this.moderationRevision[kind];
2187
3322
  if (!enabled)
2188
3323
  producer.pause();
2189
- await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
2190
- if (enabled)
2191
- producer.resume();
3324
+ try {
3325
+ await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
3326
+ }
3327
+ catch (err) {
3328
+ if (ended())
3329
+ return;
3330
+ throw err;
3331
+ }
3332
+ if (ended())
3333
+ return;
3334
+ if (!enabled || this.producers.get(producer.id) !== producer || producer.closed)
3335
+ continue;
3336
+ if (revision !== this.moderationRevision[kind] || this.forcedOff[kind]) {
3337
+ // A moderator decided while this waited: the older request does not
3338
+ // undo it. It stays off here, and the server is told so too, since
3339
+ // which of the two it applied last cannot be known from here.
3340
+ void this.rpc('pauseProducer', { producerId: producer.id, paused: true }).catch(() => undefined);
3341
+ this.emitLocalMedia();
3342
+ throw new RpcError('force-muted', `a moderator changed your ${kind} while this request waited; it stays off`);
3343
+ }
3344
+ producer.resume();
2192
3345
  }
2193
3346
  this.emitLocalMedia();
2194
3347
  }
3348
+ /** Record a moderation decision ('' = both kinds). A roster snapshot counts only where it changes what is known. */
3349
+ moderate(kind, paused, snapshot = false) {
3350
+ for (const k of kind ? [kind] : ['audio', 'video']) {
3351
+ if (snapshot && this.forcedOff[k] === paused)
3352
+ continue;
3353
+ this.forcedOff[k] = paused;
3354
+ this.moderationRevision[k] += 1;
3355
+ }
3356
+ }
2195
3357
  /** Stop publishing entirely and release the local devices. */
2196
3358
  async unpublish() {
2197
3359
  void this.stopRecording().catch(error => this.emit('error', error));
3360
+ this.layersNeeded.clear();
2198
3361
  // All of it takes effect now; only telling the server is waited for. So
2199
3362
  // publish() and unpublish() act in the order they are called: a publish
2200
3363
  // called while this still waits is not undone by it, and one already