@voltkit-rtc/client 0.6.1 → 0.7.1

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 (64) hide show
  1. package/CHANGELOG.md +109 -2
  2. package/README.md +236 -11
  3. package/dist/cjs/broadcast.d.ts.map +1 -1
  4. package/dist/cjs/broadcast.js +16 -7
  5. package/dist/cjs/broadcast.js.map +1 -1
  6. package/dist/cjs/chat.d.ts +11 -9
  7. package/dist/cjs/chat.d.ts.map +1 -1
  8. package/dist/cjs/chat.js +56 -19
  9. package/dist/cjs/chat.js.map +1 -1
  10. package/dist/cjs/errors.d.ts +571 -0
  11. package/dist/cjs/errors.d.ts.map +1 -0
  12. package/dist/cjs/errors.js +155 -0
  13. package/dist/cjs/errors.js.map +1 -0
  14. package/dist/cjs/index.d.ts +320 -16
  15. package/dist/cjs/index.d.ts.map +1 -1
  16. package/dist/cjs/index.js +728 -123
  17. package/dist/cjs/index.js.map +1 -1
  18. package/dist/cjs/media-permissions.d.ts +101 -0
  19. package/dist/cjs/media-permissions.d.ts.map +1 -0
  20. package/dist/cjs/media-permissions.js +166 -0
  21. package/dist/cjs/media-permissions.js.map +1 -0
  22. package/dist/cjs/network.d.ts +80 -0
  23. package/dist/cjs/network.d.ts.map +1 -0
  24. package/dist/cjs/network.js +190 -0
  25. package/dist/cjs/network.js.map +1 -0
  26. package/dist/cjs/token.d.ts +17 -0
  27. package/dist/cjs/token.d.ts.map +1 -0
  28. package/dist/cjs/token.js +36 -0
  29. package/dist/cjs/token.js.map +1 -0
  30. package/dist/esm/broadcast.d.ts.map +1 -1
  31. package/dist/esm/broadcast.js +16 -7
  32. package/dist/esm/broadcast.js.map +1 -1
  33. package/dist/esm/chat.d.ts +11 -9
  34. package/dist/esm/chat.d.ts.map +1 -1
  35. package/dist/esm/chat.js +56 -19
  36. package/dist/esm/chat.js.map +1 -1
  37. package/dist/esm/errors.d.ts +571 -0
  38. package/dist/esm/errors.d.ts.map +1 -0
  39. package/dist/esm/errors.js +149 -0
  40. package/dist/esm/errors.js.map +1 -0
  41. package/dist/esm/index.d.ts +320 -16
  42. package/dist/esm/index.d.ts.map +1 -1
  43. package/dist/esm/index.js +707 -114
  44. package/dist/esm/index.js.map +1 -1
  45. package/dist/esm/media-permissions.d.ts +101 -0
  46. package/dist/esm/media-permissions.d.ts.map +1 -0
  47. package/dist/esm/media-permissions.js +160 -0
  48. package/dist/esm/media-permissions.js.map +1 -0
  49. package/dist/esm/network.d.ts +80 -0
  50. package/dist/esm/network.d.ts.map +1 -0
  51. package/dist/esm/network.js +183 -0
  52. package/dist/esm/network.js.map +1 -0
  53. package/dist/esm/token.d.ts +17 -0
  54. package/dist/esm/token.d.ts.map +1 -0
  55. package/dist/esm/token.js +31 -0
  56. package/dist/esm/token.js.map +1 -0
  57. package/package.json +1 -1
  58. package/src/broadcast.ts +16 -7
  59. package/src/chat.ts +44 -18
  60. package/src/errors.ts +162 -0
  61. package/src/index.ts +842 -114
  62. package/src/media-permissions.ts +220 -0
  63. package/src/network.ts +212 -0
  64. package/src/token.ts +31 -0
package/dist/esm/index.js CHANGED
@@ -7,6 +7,10 @@ import { CameraRecording } from './recording.js';
7
7
  * setup, consumer resume ordering and reconnection, each slightly wrong.
8
8
  */
9
9
  import { Device } from 'mediasoup-client';
10
+ import { isTerminalError, RpcError } from './errors.js';
11
+ import { above60fpsCap, AUDIO_SETTINGS, qualityAt, resolvePermissions, } from './media-permissions.js';
12
+ import { NetworkQualityWatch } from './network.js';
13
+ import { socketUrl, TOKEN_EXPIRING_LEAD_MS, tokenExpiresAt } from './token.js';
10
14
  import { chooseCodec, currentEnv, defaultLayerCount, findCodec, h264HardwareEncode, isAppleMobile, isPhoneOrTablet, normalizeCodec, } from './device.js';
11
15
  import { MusicSource } from './music.js';
12
16
  import { allowedQuality, DEFAULT_VIDEO_POLICY, sameLayout, describeVideoLayers, receiveSpatialLayer, screenEncodings, topLayerSize, videoEncodings, } from './quality.js';
@@ -15,36 +19,29 @@ export { CameraRecording, RecordingUploader, RecordingError } from './recording.
15
19
  export { BroadcastPublisher, WhipError, whipEnd, whipOffer } from './broadcast.js';
16
20
  export { AudioMixer, MusicSource } from './music.js';
17
21
  export { ChatClient, ChatRpcError } from './chat.js';
22
+ export { RpcError, ERROR_CODES, errorInfo, isTerminalError } from './errors.js';
23
+ export { AUDIO_QUALITIES, AUDIO_SETTINGS, DEFAULT_MEDIA_PERMISSIONS, resolvePermissions } from './media-permissions.js';
24
+ export { NETWORK_QUALITY_INTERVAL_MS, NETWORK_QUALITY_LEVELS, networkLevel } from './network.js';
25
+ export { TOKEN_EXPIRING_LEAD_MS, tokenExpiresAt } from './token.js';
18
26
  export { allowedQuality, layerBitrate, videoEncodings, VIDEO_QUALITIES } from './quality.js';
27
+ /** Where a session stands, in the order a session goes through them. `closed` is final; the `closed` event says why. */
28
+ export const CONNECTION_STATES = Object.freeze(['idle', 'connecting', 'connected', 'reconnecting', 'closed']);
19
29
  const defaultSource = (kind) => (kind === 'audio' ? 'mic' : 'camera');
20
- /** An error returned by the server, carrying its machine-readable code. */
21
- export class RpcError extends Error {
22
- code;
23
- constructor(code, message) {
24
- super(`${code}: ${message}`);
25
- this.code = code;
26
- this.name = 'RpcError';
27
- }
28
- }
29
- /**
30
- * Failures that reconnecting cannot fix. A room that has ended is not coming
31
- * back, and a token that is wrong will be wrong next time too -- retrying
32
- * these just floods the log and burns the attempt budget that a genuine
33
- * network blip needs.
34
- */
35
- const TERMINAL_JOIN_ERRORS = new Set([
36
- 'no-room',
37
- 'room-ended',
38
- 'wrong-tenant',
39
- 'bad-token',
40
- 'no-token',
41
- 'forbidden',
42
- // The account cannot use the service until it is topped up or restored.
43
- 'quota-exhausted',
44
- 'daily-limit-reached',
45
- 'monthly-limit-reached',
46
- 'tenant-suspended',
47
- ]);
30
+ // Every event a StreamClient emits. Typed against ClientEvents, so adding an
31
+ // event without listing it here (or listing one that does not exist) fails to
32
+ // compile; the contract check compares this list with docs/sdk-contract.json.
33
+ const EVENT_NAMES = {
34
+ connected: true, disconnected: true, connectionStateChanged: true, joined: true,
35
+ track: true, trackEnded: true, trackMuted: true,
36
+ participantJoined: true, participantLeft: true, participantMuted: true, participantUpdated: true,
37
+ roleChanged: true, handRaised: true, forceMuted: true, claimChanged: true, roomMetadataChanged: true,
38
+ mustReconnect: true, roomClosed: true, kicked: true, streamInterrupted: true, closed: true,
39
+ usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true,
40
+ screenShareStarted: true, screenShareEnded: true, error: true, localMediaChanged: true, localStreamChanged: true,
41
+ message: true, messageDeleted: true, chatMuted: true, typing: true,
42
+ };
43
+ /** The names of every event `StreamClient.on` accepts. */
44
+ export const CLIENT_EVENTS = Object.freeze(Object.keys(EVENT_NAMES));
48
45
  const RPC_TIMEOUT_MS = 15_000;
49
46
  const MAX_RECONNECT_ATTEMPTS = 8;
50
47
  /** Server close code meaning "another socket took over this identity". */
@@ -58,10 +55,32 @@ const isLayer = (value) => Number.isInteger(value) && value >= 0;
58
55
  * otherwise starts low and takes many seconds to climb to the top layer.
59
56
  */
60
57
  const VIDEO_START_BITRATE_KBPS = 2000;
58
+ /** Why a connect() gave up: close() ran while it was under way, and close wins. */
59
+ const closedWhileConnecting = () => new RpcError('not-connected', 'the session was closed while connecting');
61
60
  /** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
62
- const unpublishedWhile = (what) => `the ${what} was unpublished while it was being published`;
61
+ const unpublishedWhile = (what) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
62
+ /**
63
+ * mediasoup-client puts the microphone's Opus maxaveragebitrate in the answer
64
+ * SDP only, so the server would never see it. Declare it in the producer's
65
+ * parameters too: the media node bills HD on a declared value above 64,000
66
+ * (spec §4) and refuses one in a project limited to standard audio, so a
67
+ * standard microphone declares 64,000 and an HD one 128,000. Music and screen
68
+ * audio go out as they always did.
69
+ */
70
+ function declareAudioQuality(rtpParameters, appData) {
71
+ const quality = appData.audioQuality;
72
+ if (appData.source !== 'mic' || (quality !== 'std' && quality !== 'hd'))
73
+ return rtpParameters;
74
+ const maxaveragebitrate = AUDIO_SETTINGS[quality].maxBitrate;
75
+ return {
76
+ ...rtpParameters,
77
+ codecs: rtpParameters.codecs.map((codec) => (codec.mimeType.toLowerCase() === 'audio/opus'
78
+ ? { ...codec, parameters: { ...codec.parameters, maxaveragebitrate } } : codec)),
79
+ };
80
+ }
63
81
  export class StreamClient {
64
82
  wsUrl;
83
+ options;
65
84
  ws = null;
66
85
  device = null;
67
86
  sendTransport = null;
@@ -113,7 +132,15 @@ export class StreamClient {
113
132
  * the full picture.
114
133
  */
115
134
  preferredLayers = new Map();
135
+ /** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
136
+ endRoomWaiters = new Set();
116
137
  closedByUs = false;
138
+ /**
139
+ * Bumped when the session ends for good: close(), or finish(). A connect()
140
+ * under way when it did -- the app's, or a reconnect's -- sees the change
141
+ * after its next await and stops, so a closed session never comes back.
142
+ */
143
+ lifecycle = 0;
117
144
  reconnecting = false;
118
145
  reconnectAttempt = 0;
119
146
  /** Set once a connect() has joined and set up. Only such a session is reconnected. */
@@ -132,9 +159,30 @@ export class StreamClient {
132
159
  sendStatsBaseline = new Map();
133
160
  recvStatsBaseline = new Map();
134
161
  limitWatch = new VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) => this.emit('videoLimited', event), undefined, () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; });
135
- constructor(wsUrl, token) {
162
+ /** Where the session stands; see connectionStateChanged. */
163
+ state = 'idle';
164
+ /** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
165
+ tokenExpiry;
166
+ tokenTimer = null;
167
+ /** Whose each received track is and whether it is switched off, by producerId. */
168
+ remoteInfo = new Map();
169
+ /** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
170
+ skippedVideos = new Map();
171
+ /** Producers whose video this client receives, or is about to: the fan-out count. */
172
+ videoSlots = new Set();
173
+ /** The quality the microphone producer was made with. */
174
+ micQuality = 'std';
175
+ // Changes to the microphone producer run one at a time, as the camera's do.
176
+ micQueue = Promise.resolve();
177
+ // A request the project does not allow is reported once, until it is made again.
178
+ audioCapReported = false;
179
+ sizeCapReported = false;
180
+ networkWatch = new NetworkQualityWatch(() => this.transportStats(), () => this.producers.size > 0, (quality) => this.emit('networkQuality', quality));
181
+ constructor(wsUrl, token, options = {}) {
136
182
  this.wsUrl = wsUrl;
183
+ this.options = options;
137
184
  this.token = token;
185
+ this.tokenExpiry = tokenExpiresAt(token);
138
186
  }
139
187
  on(event, fn) {
140
188
  let set = this.listeners.get(event);
@@ -143,15 +191,46 @@ export class StreamClient {
143
191
  this.listeners.set(event, set);
144
192
  }
145
193
  set.add(fn);
146
- // videoLimited costs a getStats every 2 s, so it runs only while heard.
194
+ // videoLimited and networkQuality cost a getStats every few seconds, so
195
+ // they run only while heard.
147
196
  if (event === 'videoLimited')
148
197
  this.syncLimitWatch();
198
+ if (event === 'networkQuality')
199
+ this.syncNetworkWatch();
149
200
  return () => {
150
201
  set.delete(fn);
151
202
  if (event === 'videoLimited')
152
203
  this.syncLimitWatch();
204
+ if (event === 'networkQuality')
205
+ this.syncNetworkWatch();
153
206
  };
154
207
  }
208
+ /** Where the session stands: idle, connecting, connected, reconnecting or closed. */
209
+ get connectionState() {
210
+ return this.state;
211
+ }
212
+ setState(next) {
213
+ if (next === this.state)
214
+ return;
215
+ const previous = this.state;
216
+ this.state = next;
217
+ if (next === 'closed')
218
+ this.stopTimers();
219
+ this.emit('connectionStateChanged', { state: next, previous });
220
+ }
221
+ /** Over for good: the state, then the event that says why. */
222
+ finish(reason) {
223
+ this.closedByUs = true;
224
+ this.lifecycle += 1;
225
+ this.setState('closed');
226
+ this.emit('closed', reason);
227
+ }
228
+ stopTimers() {
229
+ if (this.tokenTimer)
230
+ clearTimeout(this.tokenTimer);
231
+ this.tokenTimer = null;
232
+ this.networkWatch.set(false);
233
+ }
155
234
  listenerCount(event) {
156
235
  return this.listeners.get(event)?.size ?? 0;
157
236
  }
@@ -167,20 +246,31 @@ export class StreamClient {
167
246
  }
168
247
  // ------------------------------------------------------------- connection
169
248
  async connect() {
249
+ const lifecycle = this.lifecycle;
250
+ const closed = () => lifecycle !== this.lifecycle;
170
251
  let result;
252
+ if (this.state === 'idle' || this.state === 'closed')
253
+ this.setState('connecting');
171
254
  try {
172
255
  await this.openSocket();
173
256
  result = (await this.rpc('join', { token: this.token }));
174
257
  this.joinResult = result;
175
258
  this.device = this.createDevice();
176
259
  await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
260
+ // close() ran while the Device loaded: going on would set the state
261
+ // back to connected, and emit joined, after closed.
262
+ if (closed())
263
+ throw closedWhileConnecting();
177
264
  }
178
265
  catch (err) {
179
266
  // A session that never got going: the app has the error. Left open,
180
267
  // the socket would be closed by the server's join deadline instead, and
181
268
  // that close would start reconnecting a session that never began.
182
- if (!this.joinedOnce)
269
+ if (!this.joinedOnce) {
183
270
  this.detachSocket();
271
+ if (this.state === 'connecting')
272
+ this.setState('idle');
273
+ }
184
274
  throw err;
185
275
  }
186
276
  // Joined and set up: the next drop gets every reconnect attempt again.
@@ -189,10 +279,15 @@ export class StreamClient {
189
279
  // retried about twice a second, forever.
190
280
  this.reconnectAttempt = 0;
191
281
  this.joinedOnce = true;
282
+ this.skippedVideos.clear();
283
+ this.setState('connected');
284
+ this.scheduleTokenExpiring();
192
285
  // A role or mute can change while the socket is away. The fresh roster
193
286
  // decides what may return; reconnecting never turns a blocked device on.
194
287
  if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
195
288
  await this.unpublish();
289
+ if (closed())
290
+ throw closedWhileConnecting();
196
291
  }
197
292
  const own = result.participants.find((p) => p.identity === result.identity);
198
293
  if (own?.muted)
@@ -214,20 +309,33 @@ export class StreamClient {
214
309
  if (!present.has(producerId))
215
310
  this.remoteVideoLayers.delete(producerId);
216
311
  // Subscribe to everything already publishing before we announce readiness.
312
+ // A close() part-way stops it: what is left would only fail, after closed.
217
313
  for (const p of result.producers) {
218
- await this.consume(p).catch((err) => this.emit('error', err));
314
+ if (closed())
315
+ throw closedWhileConnecting();
316
+ await this.consume(p).catch((err) => { if (!closed())
317
+ this.emit('error', err); });
219
318
  }
220
319
  // Restore our own broadcast. This is what makes an origin failover
221
320
  // survivable for the host: it reconnects onto the new origin and the
222
321
  // stream comes back on its own.
223
322
  if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
224
- await this.republish().catch((err) => this.emit('error', err));
323
+ await this.republish().catch((err) => { if (!closed())
324
+ this.emit('error', err); });
225
325
  }
326
+ if (closed())
327
+ throw closedWhileConnecting();
328
+ this.syncNetworkWatch();
226
329
  return result;
227
330
  }
228
- /** A new mediasoup Device for each join. Its own method so tests, which have no WebRTC, can stand one in. */
331
+ /**
332
+ * A new mediasoup Device for each join: the handler named in the options, or
333
+ * the one detected from the browser. Its own method so tests, which have no
334
+ * WebRTC, can stand one in.
335
+ */
229
336
  createDevice() {
230
- return new Device();
337
+ const { handlerName } = this.options;
338
+ return handlerName ? new Device({ handlerName }) : new Device();
231
339
  }
232
340
  openSocket() {
233
341
  // Abandon any previous socket first. Its close handler checks identity
@@ -235,15 +343,13 @@ export class StreamClient {
235
343
  // reconnect -- which is what let multiple reconnect chains pile up.
236
344
  this.detachSocket();
237
345
  return new Promise((resolve, reject) => {
238
- const url = new URL(this.wsUrl);
239
- url.searchParams.set('token', this.token);
240
- const ws = new WebSocket(url.toString());
346
+ const ws = new WebSocket(socketUrl(this.wsUrl, this.token));
241
347
  this.ws = ws;
242
348
  const onOpen = () => {
243
349
  this.emit('connected');
244
350
  resolve();
245
351
  };
246
- const onError = () => reject(new Error('websocket failed to open'));
352
+ const onError = () => reject(new RpcError('connect-failed', 'websocket failed to open'));
247
353
  ws.addEventListener('open', onOpen, { once: true });
248
354
  ws.addEventListener('error', onError, { once: true });
249
355
  ws.addEventListener('message', (ev) => {
@@ -256,49 +362,59 @@ export class StreamClient {
256
362
  if (this.ws !== ws)
257
363
  return;
258
364
  this.ws = null;
259
- this.failAllPending(new Error(`socket closed (${ev.code})`));
365
+ this.failAllPending(new RpcError('not-connected', `socket closed (${ev.code})`));
260
366
  this.emit('disconnected', ev.reason || String(ev.code));
261
367
  if (this.closedByUs)
262
368
  return;
263
369
  if (ev.code === CLOSE_REPLACED) {
264
370
  // Another session for this identity is live. Reconnecting would
265
371
  // displace it, and it would displace us right back, forever.
266
- this.emit('error', new Error('this identity connected from somewhere else'));
267
- this.closedByUs = true;
372
+ this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
268
373
  // The producers outlive this close until the app calls close(); the
269
374
  // videoLimited sampling must not, or it reports after `closed`.
270
375
  this.limitWatch.reset();
271
- this.emit('closed', 'replaced');
376
+ this.finish('replaced');
272
377
  return;
273
378
  }
274
379
  if (ev.code === CLOSE_ENDED) {
275
380
  // Normally roomClosed has already ended the session; this covers a
276
381
  // close that arrived without it.
277
- this.closedByUs = true;
278
382
  this.limitWatch.reset();
279
- this.emit('closed', 'room-closed');
383
+ this.finish('room-closed');
280
384
  return;
281
385
  }
282
386
  // Before a first successful join the app is still waiting on
283
387
  // connect(), which reports the failure; there is nothing to restore.
284
388
  if (!this.joinedOnce)
285
389
  return;
390
+ this.setState('reconnecting');
391
+ this.networkWatch.set(false);
286
392
  void this.scheduleReconnect();
287
393
  });
288
394
  });
289
395
  }
290
- detachSocket() {
396
+ detachSocket(reason = 'superseded') {
291
397
  const ws = this.ws;
292
398
  this.ws = null;
293
399
  if (ws && ws.readyState <= WebSocket.OPEN) {
294
400
  try {
295
- ws.close(1000, 'superseded');
401
+ ws.close(1000, reason);
296
402
  }
297
403
  catch {
298
404
  /* already closing */
299
405
  }
300
406
  }
301
407
  }
408
+ /**
409
+ * A reconnect that ends the session lets go of its socket. A refused join
410
+ * leaves that socket open (connect() keeps it once a session has joined),
411
+ * and the session would otherwise report closed with it still attached and
412
+ * still sending.
413
+ */
414
+ releaseSocket() {
415
+ this.detachSocket('gave up');
416
+ this.failAllPending(new RpcError('not-connected', 'the session has ended'));
417
+ }
302
418
  /**
303
419
  * Reconnection is mandatory, not optional: nodes get drained, relays die and
304
420
  * promotions move you to a different node. Always re-ask the orchestrator
@@ -315,9 +431,9 @@ export class StreamClient {
315
431
  while (!this.closedByUs) {
316
432
  this.reconnectAttempt += 1;
317
433
  if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
318
- this.emit('error', new Error(`gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
319
- this.closedByUs = true;
320
- this.emit('closed', 'gave-up');
434
+ this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
435
+ this.releaseSocket();
436
+ this.finish('gave-up');
321
437
  return;
322
438
  }
323
439
  const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
@@ -335,13 +451,17 @@ export class StreamClient {
335
451
  return;
336
452
  }
337
453
  catch (err) {
454
+ // Ended while this attempt was under way -- by close(), or by the
455
+ // server -- and that has been reported: nothing follows closed.
456
+ if (this.closedByUs)
457
+ return;
338
458
  this.emit('error', err);
339
- // Stop on failures a retry cannot resolve. Hammering a room that has
340
- // ended produces one error per attempt forever and drowns out
341
- // whatever actually went wrong.
342
- if (err instanceof RpcError && TERMINAL_JOIN_ERRORS.has(err.code)) {
343
- this.closedByUs = true;
344
- this.emit('closed', 'gave-up');
459
+ // Stop on failures a retry cannot resolve (the registry's terminal
460
+ // codes). Hammering a room that has ended produces one error per
461
+ // attempt forever and drowns out whatever actually went wrong.
462
+ if (err instanceof RpcError && isTerminalError(err.code)) {
463
+ this.releaseSocket();
464
+ this.finish('gave-up');
345
465
  return;
346
466
  }
347
467
  }
@@ -357,6 +477,7 @@ export class StreamClient {
357
477
  // others have already reported themselves and set closedByUs.
358
478
  const wasOpen = !this.closedByUs;
359
479
  this.closedByUs = true;
480
+ this.lifecycle += 1;
360
481
  this.publishGeneration += 1;
361
482
  this.wasPublishing = false;
362
483
  this.teardownMedia();
@@ -368,6 +489,7 @@ export class StreamClient {
368
489
  this.music = null;
369
490
  // Nothing is emitted after closing, so not even a clearing videoLimited.
370
491
  this.limitWatch.reset();
492
+ this.skippedVideos.clear();
371
493
  // Producers no longer stop their tracks, so releasing the camera and
372
494
  // microphone is ours to do -- otherwise the device light stays on.
373
495
  this.localStream?.getTracks().forEach((t) => t.stop());
@@ -375,6 +497,11 @@ export class StreamClient {
375
497
  const ws = this.ws;
376
498
  this.ws = null;
377
499
  ws?.close(1000, 'client closed');
500
+ // Detached first, so the socket's own close handler no longer fails what
501
+ // was waiting on it: done here, or a join or a request in flight would
502
+ // wait out its 15 s timeout.
503
+ this.failAllPending(new RpcError('not-connected', 'the session was closed'));
504
+ this.setState('closed');
378
505
  if (wasOpen)
379
506
  this.emit('closed', 'left');
380
507
  }
@@ -382,6 +509,9 @@ export class StreamClient {
382
509
  for (const c of this.consumers.values())
383
510
  c.close();
384
511
  this.consumers.clear();
512
+ this.remoteInfo.clear();
513
+ this.skippedVideos.clear();
514
+ this.videoSlots.clear();
385
515
  for (const p of this.producers.values())
386
516
  p.close();
387
517
  this.producers.clear();
@@ -398,22 +528,137 @@ export class StreamClient {
398
528
  this.sendTransportPending = null;
399
529
  this.recvTransportPending = null;
400
530
  this.device = null;
531
+ this.syncNetworkWatch();
401
532
  }
402
- /** Everything currently known about the room, for rendering. */
533
+ /**
534
+ * Everything currently known about the room, for rendering: the join
535
+ * result, kept current -- participants, roles, hands, mutes, display
536
+ * metadata, slots and room metadata follow the events.
537
+ */
403
538
  get info() {
404
539
  return this.joinResult;
405
540
  }
541
+ /** Who is in the room now, this client included; kept current from the roster events. */
542
+ get participants() {
543
+ return [...(this.joinResult?.participants ?? [])];
544
+ }
545
+ /** Every track this client is receiving now. */
546
+ get remoteTracks() {
547
+ const out = [];
548
+ for (const [producerId, consumer] of this.consumers) {
549
+ const info = this.remoteInfo.get(producerId);
550
+ if (!info || consumer.closed)
551
+ continue;
552
+ out.push({
553
+ producerId, identity: info.identity, kind: info.kind, track: consumer.track,
554
+ source: this.remoteSources.get(producerId) ?? defaultSource(info.kind), paused: info.paused,
555
+ });
556
+ }
557
+ return out;
558
+ }
559
+ /**
560
+ * What this project lets this client publish: from the join result's
561
+ * `media` (servers with wallet billing), else its `video` policy, else
562
+ * everything before a join. Offer only these in your UI; the SDK refuses
563
+ * or caps anything else with the server's own codes.
564
+ */
565
+ get mediaPermissions() {
566
+ return resolvePermissions(this.joinResult);
567
+ }
568
+ /** How this session is billed: `legacy` (minutes) or `wallet`; null before a join or on older servers. */
569
+ get billingMode() {
570
+ return this.joinResult?.billing?.mode ?? null;
571
+ }
572
+ // ------------------------------------------------------------ token refresh
573
+ /**
574
+ * Hand the session a newer room token, minted by your backend for the same
575
+ * room and identity (`POST /v1/rooms/{roomId}/tokens`), typically on
576
+ * `tokenExpiring`. On a live connection the server checks it like a join
577
+ * token (`bad-token`, or `token-mismatch` for another room or identity; the
578
+ * session then keeps its token); otherwise it is kept for the next join,
579
+ * which checks it there. Resolves to when it expires (unix ms), when known.
580
+ */
581
+ async updateToken(token) {
582
+ if (typeof token !== 'string' || token.trim() === '')
583
+ throw new TypeError('updateToken needs a token');
584
+ if (this.state === 'closed')
585
+ throw new RpcError('not-connected', 'this session is closed; create a new client');
586
+ if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
587
+ try {
588
+ const res = (await this.rpc('refreshToken', { token }));
589
+ const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
590
+ this.setToken(token, at);
591
+ return { expiresAt: this.tokenExpiry };
592
+ }
593
+ catch (err) {
594
+ // An older server has no refreshToken, and a socket that dropped
595
+ // during the call is reconnecting: either way keep the token for the
596
+ // next join, which checks it.
597
+ if (!(err instanceof RpcError) || (err.code !== 'unknown-method' && err.code !== 'not-connected'))
598
+ throw err;
599
+ }
600
+ }
601
+ this.setToken(token, null);
602
+ return { expiresAt: this.tokenExpiry };
603
+ }
604
+ /** The token reconnects use from now on, with its expiry (read from the token when not given). */
605
+ setToken(token, expiresAt) {
606
+ this.token = token;
607
+ this.tokenExpiry = expiresAt ?? tokenExpiresAt(token);
608
+ this.scheduleTokenExpiring();
609
+ }
610
+ /**
611
+ * Warn TOKEN_EXPIRING_LEAD_MS before the token held for reconnects
612
+ * expires. Every new token -- the server's refresh, a promotion's, the
613
+ * app's -- reschedules it, so it fires only when nobody refreshed it.
614
+ */
615
+ scheduleTokenExpiring() {
616
+ if (this.tokenTimer)
617
+ clearTimeout(this.tokenTimer);
618
+ this.tokenTimer = null;
619
+ const expiresAt = this.tokenExpiry;
620
+ if (expiresAt === null || this.state === 'idle' || this.state === 'closed')
621
+ return;
622
+ // setTimeout cannot wait longer than about 24.8 days.
623
+ const delay = Math.min(Math.max(0, expiresAt - TOKEN_EXPIRING_LEAD_MS - Date.now()), 2_147_000_000);
624
+ this.tokenTimer = setTimeout(() => {
625
+ this.tokenTimer = null;
626
+ if (this.state === 'closed' || this.tokenExpiry !== expiresAt)
627
+ return;
628
+ if (expiresAt - TOKEN_EXPIRING_LEAD_MS > Date.now()) {
629
+ this.scheduleTokenExpiring(); // woke early from a clamped wait
630
+ return;
631
+ }
632
+ this.emit('tokenExpiring', { expiresAt, secondsLeft: Math.max(0, Math.round((expiresAt - Date.now()) / 1000)) });
633
+ }, delay);
634
+ }
635
+ // ------------------------------------------------------------ network quality
636
+ /** networkQuality sampling runs while someone listens and the session is connected. */
637
+ syncNetworkWatch() {
638
+ this.networkWatch.set(!this.closedByUs && this.state === 'connected' && this.listenerCount('networkQuality') > 0);
639
+ }
640
+ /** Each live media transport's statistics, keyed by transport so a new one starts fresh. */
641
+ async transportStats() {
642
+ const out = new Map();
643
+ for (const transport of [this.sendTransport, this.recvTransport]) {
644
+ if (!transport || transport.closed)
645
+ continue;
646
+ const report = await transport.getStats();
647
+ out.set(transport.id, report.values());
648
+ }
649
+ return out;
650
+ }
406
651
  // -------------------------------------------------------------------- rpc
407
652
  rpc(method, params = {}) {
408
653
  return new Promise((resolve, reject) => {
409
654
  if (this.ws?.readyState !== WebSocket.OPEN) {
410
- reject(new Error('socket is not open'));
655
+ reject(new RpcError('not-connected', 'socket is not open'));
411
656
  return;
412
657
  }
413
658
  const id = this.nextId++;
414
659
  const timer = setTimeout(() => {
415
660
  this.pending.delete(id);
416
- reject(new Error(`${method} timed out`));
661
+ reject(new RpcError('timeout', `${method} timed out`));
417
662
  }, RPC_TIMEOUT_MS);
418
663
  this.pending.set(id, { resolve, reject, timer });
419
664
  this.ws.send(JSON.stringify({ id, method, params }));
@@ -434,6 +679,10 @@ export class StreamClient {
434
679
  catch {
435
680
  return;
436
681
  }
682
+ // Valid JSON that is not a message object ("null" would throw below) is
683
+ // ignored like any other frame that is not a message.
684
+ if (typeof msg !== 'object' || msg === null)
685
+ return;
437
686
  if (typeof msg.id === 'number') {
438
687
  const p = this.pending.get(msg.id);
439
688
  if (!p)
@@ -444,7 +693,7 @@ export class StreamClient {
444
693
  p.resolve(msg.result);
445
694
  else {
446
695
  const e = msg.error;
447
- p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown'));
696
+ p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown', e?.data));
448
697
  }
449
698
  return;
450
699
  }
@@ -462,17 +711,27 @@ export class StreamClient {
462
711
  this.preferredLayers.delete(producerId);
463
712
  this.receiveQualities.delete(producerId);
464
713
  this.remoteVideoLayers.delete(producerId);
714
+ this.skippedVideos.delete(producerId);
465
715
  const consumer = this.consumers.get(producerId);
466
716
  if (!consumer)
467
717
  break; // never consumed it, or already cleaned up
718
+ this.videoSlots.delete(producerId);
468
719
  consumer.close();
469
720
  this.consumers.delete(producerId);
470
721
  this.remoteSources.delete(producerId);
722
+ this.remoteInfo.delete(producerId);
471
723
  this.emit('trackEnded', { producerId, identity });
724
+ // A video slot opened under the fan-out limit: the oldest video left
725
+ // waiting takes it.
726
+ if (consumer.kind === 'video')
727
+ void this.consumeSkipped().catch((err) => this.emit('error', err));
472
728
  break;
473
729
  }
474
730
  case 'producerPaused': {
475
731
  const p = data;
732
+ const info = this.remoteInfo.get(p.producerId);
733
+ if (info)
734
+ info.paused = Boolean(p.paused);
476
735
  this.emit('trackMuted', {
477
736
  identity: p.identity,
478
737
  kind: p.kind,
@@ -482,27 +741,40 @@ export class StreamClient {
482
741
  });
483
742
  break;
484
743
  }
485
- case 'participantJoined':
486
- this.emit('participantJoined', data);
744
+ case 'participantJoined': {
745
+ const p = data;
746
+ // A reconnecting participant joins again: replace, never duplicate.
747
+ this.updateRoster((list) => [...list.filter((q) => q.identity !== p.identity), p]);
748
+ this.emit('participantJoined', p);
487
749
  break;
750
+ }
488
751
  case 'participantMuted': {
489
752
  const wire = data;
490
753
  const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
491
- if (this.joinResult)
492
- this.joinResult.participants = this.joinResult.participants.map((p) => p.identity !== e.identity ? p : { ...p,
493
- ...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
494
- ...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
495
- });
754
+ this.updateRoster((list) => list.map((p) => p.identity !== e.identity ? p : { ...p,
755
+ ...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
756
+ ...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
757
+ }));
496
758
  this.emit('participantMuted', e);
497
759
  break;
498
760
  }
499
- case 'participantLeft':
500
- this.emit('participantLeft', data);
761
+ case 'participantUpdated': {
762
+ const e = data;
763
+ this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, metadata: e.metadata })));
764
+ this.emit('participantUpdated', { identity: e.identity, metadata: e.metadata ?? null });
765
+ break;
766
+ }
767
+ case 'participantLeft': {
768
+ const e = data;
769
+ this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
770
+ this.emit('participantLeft', e);
501
771
  break;
772
+ }
502
773
  case 'roleChanged': {
503
774
  const e = data;
504
775
  if (e.identity === this.joinResult?.identity)
505
776
  this.joinResult.role = e.role;
777
+ this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, role: e.role })));
506
778
  this.emit('roleChanged', e);
507
779
  // Demoted: the server has already closed our producers. Release the
508
780
  // camera and microphone too, instead of capturing for nobody.
@@ -513,9 +785,13 @@ export class StreamClient {
513
785
  break;
514
786
  }
515
787
  case 'handRaised':
516
- case 'handLowered':
788
+ case 'handLowered': {
789
+ const e = data;
790
+ this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p
791
+ : { ...p, handRaised: Boolean(e.handRaised), ...(e.handRaised ? { handMetadata: e.metadata } : { handMetadata: undefined }) })));
517
792
  this.emit('handRaised', data);
518
793
  break;
794
+ }
519
795
  case 'forceMuted': {
520
796
  // The server has already paused that kind (an audio mute includes
521
797
  // in-app music: it travels as audio). Reflect it locally; a release
@@ -528,20 +804,33 @@ export class StreamClient {
528
804
  this.emit('forceMuted', e);
529
805
  break;
530
806
  }
807
+ case 'claimChanged': {
808
+ const e = data;
809
+ this.applyClaim(e);
810
+ this.emit('claimChanged', { key: e.key, identity: e.identity || null, state: e.state, reason: e.reason ?? '' });
811
+ break;
812
+ }
813
+ case 'roomMetadataChanged': {
814
+ const e = data;
815
+ if (this.joinResult)
816
+ this.joinResult.metadata = e.metadata;
817
+ this.emit('roomMetadataChanged', { metadata: e.metadata ?? null, ...(e.by ? { by: e.by } : {}) });
818
+ break;
819
+ }
531
820
  case 'tokenRefreshed': {
532
821
  // A room outlives its join token many times over. The server hands us a
533
822
  // fresh one periodically so a reconnect at any point still works --
534
823
  // without this, any disconnect after the token's lifetime is terminal.
535
824
  const e = data;
536
825
  if (e.token)
537
- this.token = e.token;
826
+ this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
538
827
  break;
539
828
  }
540
829
  case 'mustReconnect': {
541
830
  const e = data;
542
831
  // A promotion hands us a fresh token carrying the new role.
543
832
  if (e.token)
544
- this.token = e.token;
833
+ this.setToken(e.token, null);
545
834
  this.emit('mustReconnect', e);
546
835
  if (e.retry) {
547
836
  this.teardownMedia();
@@ -557,16 +846,27 @@ export class StreamClient {
557
846
  case 'usageLimit':
558
847
  this.emit('usageLimit', d);
559
848
  break;
849
+ case 'walletLow': {
850
+ const e = data;
851
+ const remainingSeconds = typeof e?.remainingSeconds === 'number' && Number.isFinite(e.remainingSeconds) ? e.remainingSeconds : 0;
852
+ this.emit('walletLow', e?.cleared === true ? { remainingSeconds, cleared: true } : { remainingSeconds });
853
+ break;
854
+ }
560
855
  case 'roomClosed':
561
856
  this.closedByUs = true;
562
857
  this.emit('roomClosed', d);
563
- this.emit('closed', 'room-closed');
858
+ this.finish('room-closed');
859
+ // Before close() fails what is pending: an endRoom() this event
860
+ // answers succeeded.
861
+ for (const ended of this.endRoomWaiters)
862
+ ended();
863
+ this.endRoomWaiters.clear();
564
864
  this.close();
565
865
  break;
566
866
  case 'kicked':
567
867
  this.closedByUs = true;
568
868
  this.emit('kicked', d);
569
- this.emit('closed', 'kicked');
869
+ this.finish('kicked');
570
870
  this.close();
571
871
  break;
572
872
  case 'message':
@@ -585,6 +885,25 @@ export class StreamClient {
585
885
  break;
586
886
  }
587
887
  }
888
+ /** Change the roster the join result holds; a new array each time, so a renderer sees the change. */
889
+ updateRoster(change) {
890
+ if (this.joinResult)
891
+ this.joinResult.participants = change(this.joinResult.participants ?? []);
892
+ }
893
+ /** Keep the join result's slots current: claims held or away, and the moderator's locks. */
894
+ applyClaim(e) {
895
+ const join = this.joinResult;
896
+ if (!join)
897
+ return;
898
+ const claims = (join.claims ?? []).filter((c) => c.key !== e.key);
899
+ let blocked = (join.blockedSlots ?? []).filter((key) => key !== e.key);
900
+ if ((e.state === 'held' || e.state === 'away') && e.identity)
901
+ claims.push({ key: e.key, identity: e.identity, state: e.state });
902
+ if (e.state === 'blocked')
903
+ blocked = [...blocked, e.key];
904
+ join.claims = claims;
905
+ join.blockedSlots = blocked;
906
+ }
588
907
  // ------------------------------------------------------------------ media
589
908
  ensureSendTransport() {
590
909
  if (this.sendTransport)
@@ -598,7 +917,7 @@ export class StreamClient {
598
917
  }
599
918
  async createSendTransport() {
600
919
  if (!this.device)
601
- throw new Error('not connected');
920
+ throw new RpcError('not-connected', 'not connected');
602
921
  const params = (await this.rpc('createTransport', { direction: 'send' }));
603
922
  const transport = this.device.createSendTransport({
604
923
  ...params,
@@ -610,7 +929,10 @@ export class StreamClient {
610
929
  .catch(fail);
611
930
  });
612
931
  transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
613
- this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source, videoLayers: appData.videoLayers })
932
+ this.rpc('produce', {
933
+ transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
934
+ appData, source: appData.source, videoLayers: appData.videoLayers,
935
+ })
614
936
  .then((res) => ok({ id: res.producerId }))
615
937
  .catch(fail);
616
938
  });
@@ -629,7 +951,7 @@ export class StreamClient {
629
951
  }
630
952
  async createRecvTransport() {
631
953
  if (!this.device)
632
- throw new Error('not connected');
954
+ throw new RpcError('not-connected', 'not connected');
633
955
  const params = (await this.rpc('createTransport', { direction: 'recv' }));
634
956
  const transport = this.device.createRecvTransport({
635
957
  ...params,
@@ -656,12 +978,24 @@ export class StreamClient {
656
978
  * kept for a reconnect's republish.
657
979
  */
658
980
  async publish(stream, constraints, options) {
981
+ // A kind the project switched off is refused here, before anything is
982
+ // produced, with the code the controller would answer with.
983
+ const permissions = this.mediaPermissions;
984
+ if (!permissions.audio && stream.getAudioTracks().length > 0) {
985
+ throw new RpcError('media-disabled', 'audio is switched off for this project');
986
+ }
987
+ if (!permissions.video && stream.getVideoTracks().length > 0) {
988
+ throw new RpcError('media-disabled', 'video is switched off for this project');
989
+ }
990
+ await this.publishStream(stream, constraints, options);
991
+ }
992
+ async publishStream(stream, constraints, options) {
659
993
  const generation = this.publishGeneration;
660
994
  const transport = await this.ensureSendTransport();
661
995
  // unpublish() ran first: this publish is over before it began, and must
662
996
  // leave nothing behind that a reconnect would publish again.
663
997
  if (generation !== this.publishGeneration)
664
- throw new Error(unpublishedWhile('stream'));
998
+ throw unpublishedWhile('stream');
665
999
  this.localStream = stream;
666
1000
  this.wasPublishing = true;
667
1001
  if (constraints)
@@ -670,35 +1004,140 @@ export class StreamClient {
670
1004
  this.publishOptions = { ...options };
671
1005
  this.frameRateFallback = false;
672
1006
  this.frameRateLowSamples = 0;
1007
+ this.audioCapReported = false;
1008
+ this.sizeCapReported = false;
673
1009
  }
1010
+ // Only a reconnect's republish gets here with a kind switched off since:
1011
+ // what is still allowed goes out, and the rest is reported.
1012
+ const permissions = this.mediaPermissions;
674
1013
  const audio = stream.getAudioTracks()[0];
675
- if (audio) {
676
- const producer = await transport.produce({
677
- track: audio,
678
- // Keep the track alive when the producer closes: a reconnect closes
679
- // every producer, and a stopped track cannot be republished.
680
- stopTracks: false,
681
- codecOptions: { opusStereo: true, opusDtx: true, opusFec: true },
682
- appData: { source: 'mic' },
683
- });
684
- await this.keepProducer(producer, generation, 'microphone');
685
- // A microphone handed over switched off, or muted before a reconnect.
686
- await this.pauseOnServer(producer);
1014
+ if (audio && !permissions.audio) {
1015
+ this.emit('error', new RpcError('media-disabled', 'audio is switched off for this project; the microphone is not published'));
1016
+ }
1017
+ else if (audio) {
1018
+ await this.micTask(() => this.produceMic(transport, this.localStream?.getAudioTracks()[0] ?? audio, generation));
687
1019
  }
688
1020
  const video = stream.getVideoTracks()[0];
689
- if (video) {
1021
+ if (video && !permissions.video) {
1022
+ this.emit('error', new RpcError('media-disabled', 'video is switched off for this project; the camera is not published'));
1023
+ }
1024
+ else if (video) {
690
1025
  // The track is read when the camera's turn comes: one handed to
691
1026
  // replaceVideoTrack while this publish was under way is the one to send.
692
1027
  await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
693
1028
  }
694
1029
  }
1030
+ // ------------------------------------------------------------ audio quality
1031
+ /**
1032
+ * The microphone's audio quality: what it is being sent at while
1033
+ * published, else what the next publish will use, after the project's limit.
1034
+ */
1035
+ get audioQuality() {
1036
+ if (this.micProducer())
1037
+ return this.micQuality;
1038
+ return this.publishOptions.audioQuality === 'hd' && this.mediaPermissions.audioQualityMax === 'hd' ? 'hd' : 'std';
1039
+ }
1040
+ /**
1041
+ * Switch the microphone between standard (mono, up to 64 kbps) and HD
1042
+ * (stereo, up to 128 kbps) while live. The codec settings are fixed per
1043
+ * producer, so the microphone's producer is replaced: listeners hear a gap
1044
+ * of a fraction of a second, and a microphone that is off stays off. Rejects
1045
+ * with `audio-quality-not-allowed` when the project allows standard audio
1046
+ * only. Resolves to the quality in use.
1047
+ */
1048
+ async setAudioQuality(quality) {
1049
+ if (quality !== 'std' && quality !== 'hd')
1050
+ throw new RangeError("setAudioQuality: expected 'std' or 'hd'");
1051
+ if (quality === 'hd' && this.mediaPermissions.audioQualityMax !== 'hd') {
1052
+ throw new RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project');
1053
+ }
1054
+ this.publishOptions = { ...this.publishOptions, audioQuality: quality };
1055
+ this.audioCapReported = false;
1056
+ await this.micTask(async () => {
1057
+ const current = this.micProducer();
1058
+ const track = current?.track;
1059
+ if (!current || !track || track.readyState !== 'live' || this.micQuality === quality)
1060
+ return;
1061
+ await this.reproduceMic(current, track);
1062
+ });
1063
+ return this.audioQuality;
1064
+ }
1065
+ micProducer() {
1066
+ for (const p of this.producers.values()) {
1067
+ if (p.kind === 'audio' && p.appData.source === 'mic')
1068
+ return p;
1069
+ }
1070
+ return undefined;
1071
+ }
1072
+ micTask(task) {
1073
+ const run = this.micQueue.then(task, task);
1074
+ this.micQueue = run.catch(() => undefined);
1075
+ return run;
1076
+ }
1077
+ /** The audio quality to send: the request, capped to the project's, with the cap reported once. */
1078
+ audioQualityFor() {
1079
+ if (this.publishOptions.audioQuality !== 'hd')
1080
+ return 'std';
1081
+ if (this.mediaPermissions.audioQualityMax === 'hd')
1082
+ return 'hd';
1083
+ if (!this.audioCapReported) {
1084
+ this.audioCapReported = true;
1085
+ this.emit('error', new RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project; publishing standard audio'));
1086
+ }
1087
+ return 'std';
1088
+ }
1089
+ /**
1090
+ * Produce the microphone at the audio quality in force. A track handed over
1091
+ * switched off (or paused before a reconnect or a quality change) starts
1092
+ * paused, on both ends.
1093
+ */
1094
+ async produceMic(transport, track, generation) {
1095
+ if (generation !== this.publishGeneration)
1096
+ throw unpublishedWhile('microphone');
1097
+ const quality = this.audioQualityFor();
1098
+ const settings = AUDIO_SETTINGS[quality];
1099
+ const producer = await transport.produce({
1100
+ track,
1101
+ // Keep the track alive when the producer closes: a reconnect closes
1102
+ // every producer, and a stopped track cannot be republished.
1103
+ stopTracks: false,
1104
+ // The answer's maxaveragebitrate sets the encoder's rate and the
1105
+ // sender's maxBitrate holds it there: standard must stay at or under
1106
+ // 64 kbps, which is what the server measures to bill it, so its sender
1107
+ // is held a little under the declared rate (Opus VBR overshoots).
1108
+ encodings: [{ maxBitrate: settings.senderMaxBitrate }],
1109
+ codecOptions: { opusStereo: settings.stereo, opusDtx: true, opusFec: true, opusMaxAverageBitrate: settings.maxBitrate },
1110
+ appData: { source: 'mic', audioQuality: quality },
1111
+ });
1112
+ await this.keepProducer(producer, generation, 'microphone');
1113
+ this.micQuality = quality;
1114
+ // A microphone handed over switched off, or muted before a reconnect.
1115
+ await this.pauseOnServer(producer);
1116
+ return producer;
1117
+ }
1118
+ /** Close the microphone producer and produce its track again at the quality now in force. */
1119
+ async reproduceMic(current, track) {
1120
+ const generation = this.publishGeneration;
1121
+ current.close();
1122
+ this.producers.delete(current.id);
1123
+ // The server allows one producer per source: the old one goes first.
1124
+ await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
1125
+ return this.produceMic(await this.ensureSendTransport(), track, generation);
1126
+ }
695
1127
  /** What this project may publish. Known once joined; 1080p before that. */
696
1128
  get videoPolicy() {
697
1129
  return this.joinResult?.video ?? DEFAULT_VIDEO_POLICY;
698
1130
  }
699
- /** The quality video is being sent at, after the project's limit. */
1131
+ /**
1132
+ * The quality video is being sent at, after the project's limit: at 60 fps
1133
+ * the largest size the project allows at 60, otherwise its 30 fps maximum.
1134
+ */
700
1135
  get videoQuality() {
701
- return allowedQuality(this.publishOptions.quality, this.videoPolicy);
1136
+ return qualityAt(this.publishOptions.quality, this.videoFrameRate, this.mediaPermissions);
1137
+ }
1138
+ /** The camera's frame rate: what it is being sent at while published, else 30 until a publish decides. */
1139
+ get videoFrameRate() {
1140
+ return this.cameraProducer() ? this.cameraFrameRate : 30;
702
1141
  }
703
1142
  /**
704
1143
  * Change the video quality while publishing. The camera's producer is
@@ -708,6 +1147,7 @@ export class StreamClient {
708
1147
  */
709
1148
  async setVideoQuality(quality) {
710
1149
  this.publishOptions = { ...this.publishOptions, quality };
1150
+ this.sizeCapReported = false;
711
1151
  await this.cameraTask(async () => {
712
1152
  const current = this.cameraProducer();
713
1153
  const track = current?.track;
@@ -717,13 +1157,25 @@ export class StreamClient {
717
1157
  });
718
1158
  return this.videoQuality;
719
1159
  }
720
- /** Change the camera frame rate without stopping screen, microphone or music. */
1160
+ /**
1161
+ * Change the camera frame rate without stopping screen, microphone or music.
1162
+ * 60 where the project allows none stays at 30 and warns on `error` with
1163
+ * `frame-rate-not-allowed`, without touching the camera; a camera or device
1164
+ * that cannot keep 60 up falls back with `frame-rate-fallback`. Resolves to
1165
+ * the frame rate used.
1166
+ */
721
1167
  async setVideoFrameRate(frameRate) {
722
1168
  if (frameRate !== 30 && frameRate !== 60)
723
1169
  throw new RangeError('setVideoFrameRate: expected 30 or 60');
724
1170
  this.publishOptions = { ...this.publishOptions, frameRate };
725
1171
  this.frameRateFallback = false;
726
1172
  this.frameRateLowSamples = 0;
1173
+ this.sizeCapReported = false;
1174
+ if (frameRate === 60 && !this.allows60()) {
1175
+ this.frameRateFallback = true;
1176
+ this.emit('error', new RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
1177
+ return this.videoFrameRate;
1178
+ }
727
1179
  await this.cameraTask(async () => {
728
1180
  const current = this.cameraProducer();
729
1181
  const track = current?.track;
@@ -735,7 +1187,24 @@ export class StreamClient {
735
1187
  catch { /* Existing capture settings decide whether 60 fps is possible. */ }
736
1188
  await this.reproduceCamera(current, track);
737
1189
  });
738
- return this.cameraProducer() ? this.cameraFrameRate : this.videoPolicy.maxFrameRate === 30 ? 30 : frameRate;
1190
+ return this.cameraProducer() ? this.cameraFrameRate : this.allows60() ? frameRate : 30;
1191
+ }
1192
+ /** Whether this project allows any 60 fps mode. */
1193
+ allows60() {
1194
+ const permissions = this.mediaPermissions;
1195
+ return permissions.maxFrameRate === 60 && permissions.maxVideoQuality60 !== null;
1196
+ }
1197
+ /**
1198
+ * The quality to send at a frame rate. A request above what 60 fps allows
1199
+ * is sent at the largest size it does allow, and said so once.
1200
+ */
1201
+ cameraQuality(frameRate) {
1202
+ const permissions = this.mediaPermissions;
1203
+ if (frameRate === 60 && !this.sizeCapReported && above60fpsCap(this.publishOptions.quality, permissions)) {
1204
+ this.sizeCapReported = true;
1205
+ this.emit('error', new RpcError('video-quality-not-allowed', `${this.publishOptions.quality} at 60 fps is not enabled for this project; publishing ${permissions.maxVideoQuality60} at 60 fps`));
1206
+ }
1207
+ return qualityAt(this.publishOptions.quality, frameRate, permissions);
739
1208
  }
740
1209
  /**
741
1210
  * Send a different video track from now on: a canvas with effects drawn on
@@ -781,7 +1250,8 @@ export class StreamClient {
781
1250
  const paused = current.paused;
782
1251
  try {
783
1252
  const { width = 0, height = 0 } = track.getSettings();
784
- const next = videoEncodings(width, height, this.videoQuality, this.layerCount(), this.frameRateFor(track));
1253
+ const frameRate = this.frameRateFor(track);
1254
+ const next = videoEncodings(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
785
1255
  const previousSize = current.track?.getSettings();
786
1256
  if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
787
1257
  previousSize?.width === width && previousSize?.height === height) {
@@ -974,7 +1444,7 @@ export class StreamClient {
974
1444
  */
975
1445
  async produceCamera(transport, track, generation, paused = !track.enabled) {
976
1446
  if (generation !== this.publishGeneration)
977
- throw new Error(unpublishedWhile('camera'));
1447
+ throw unpublishedWhile('camera');
978
1448
  // mediasoup-client starts a producer paused exactly when its track is
979
1449
  // off. Setting the track to the camera's state first makes the producer
980
1450
  // start in it, and a camera that is off never sends a frame of the new
@@ -985,8 +1455,9 @@ export class StreamClient {
985
1455
  // the device, so the browser sends every layer asked for (see
986
1456
  // videoEncodings).
987
1457
  const { width = 0, height = 0 } = track.getSettings();
988
- const quality = this.videoQuality;
1458
+ // The frame rate first: the size the project allows depends on it.
989
1459
  this.cameraFrameRate = this.frameRateFor(track);
1460
+ const quality = this.cameraQuality(this.cameraFrameRate);
990
1461
  const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
991
1462
  let codecs;
992
1463
  try {
@@ -1036,7 +1507,7 @@ export class StreamClient {
1036
1507
  if (generation !== this.publishGeneration) {
1037
1508
  producer.close();
1038
1509
  await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1039
- throw new Error(unpublishedWhile(what));
1510
+ throw unpublishedWhile(what);
1040
1511
  }
1041
1512
  this.producers.set(producer.id, producer);
1042
1513
  }
@@ -1075,7 +1546,7 @@ export class StreamClient {
1075
1546
  /** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
1076
1547
  reportCodecFallback(message) {
1077
1548
  if (normalizeCodec(this.publishOptions.codec) === 'h264') {
1078
- this.emit('error', new Error(message));
1549
+ this.emit('error', new RpcError('codec-fallback', message));
1079
1550
  }
1080
1551
  else if (!this.codecFallbackLogged) {
1081
1552
  this.codecFallbackLogged = true;
@@ -1133,7 +1604,7 @@ export class StreamClient {
1133
1604
  const live = stream?.getTracks().some((t) => t.readyState === 'live') ?? false;
1134
1605
  if (!live) {
1135
1606
  if (!this.publishConstraints) {
1136
- this.emit('error', new Error('cannot restore the broadcast: the devices are gone'));
1607
+ this.emit('error', new RpcError('devices-gone', 'cannot restore the broadcast: the devices are gone'));
1137
1608
  return;
1138
1609
  }
1139
1610
  const ended = stream;
@@ -1155,13 +1626,15 @@ export class StreamClient {
1155
1626
  }
1156
1627
  if (!stream)
1157
1628
  return;
1158
- await this.publish(stream);
1629
+ await this.publishStream(stream);
1159
1630
  }
1160
1631
  // ------------------------------------------------------------- screen sharing
1161
1632
  /** Publish a screen beside the camera. Call from a click so capture permission can open. */
1162
1633
  async publishScreen(options = {}) {
1163
1634
  if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost')
1164
1635
  throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
1636
+ if (!this.mediaPermissions.video)
1637
+ throw new RpcError('media-disabled', 'video is switched off for this project');
1165
1638
  if (this.screenStream || this.screenPending)
1166
1639
  throw new RpcError('screen-share-active', 'a screen share is already active');
1167
1640
  if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion')
@@ -1182,7 +1655,7 @@ export class StreamClient {
1182
1655
  if (!video || video.readyState !== 'live')
1183
1656
  throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
1184
1657
  if (generation !== this.screenGeneration || this.closedByUs)
1185
- throw new Error(unpublishedWhile('screen'));
1658
+ throw unpublishedWhile('screen');
1186
1659
  this.screenStream = stream;
1187
1660
  this.screenMode = mode;
1188
1661
  this.screenAudioEnabled = options.audio !== false;
@@ -1192,7 +1665,7 @@ export class StreamClient {
1192
1665
  video.addEventListener('ended', onEnded, { once: true });
1193
1666
  await this.restoreScreen();
1194
1667
  if (generation !== this.screenGeneration || video.readyState !== 'live')
1195
- throw new Error(unpublishedWhile('screen'));
1668
+ throw unpublishedWhile('screen');
1196
1669
  this.screenStarted = true;
1197
1670
  this.wasPublishing = true;
1198
1671
  this.emit('screenShareStarted', { stream, mode });
@@ -1256,7 +1729,7 @@ export class StreamClient {
1256
1729
  const { width = 0, height = 0 } = video.getSettings();
1257
1730
  const encodings = screenEncodings(width, height, this.screenMode);
1258
1731
  // A project's explicit safety restriction also applies to screen video.
1259
- const cap = topLayerSize(width, height, this.videoPolicy.maxQuality);
1732
+ const cap = topLayerSize(width, height, this.mediaPermissions.maxVideoQuality);
1260
1733
  const screenScale = encodings.at(-1).scaleResolutionDownBy;
1261
1734
  if (cap.scale > screenScale)
1262
1735
  encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
@@ -1264,7 +1737,7 @@ export class StreamClient {
1264
1737
  if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
1265
1738
  producer.close();
1266
1739
  await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1267
- throw new Error(unpublishedWhile('screen'));
1740
+ throw unpublishedWhile('screen');
1268
1741
  }
1269
1742
  this.producers.set(producer.id, producer);
1270
1743
  this.screenProducerIds.add(producer.id);
@@ -1275,7 +1748,12 @@ export class StreamClient {
1275
1748
  codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
1276
1749
  appData: { source: 'screen', videoLayers: describeVideoLayers(width, height, encodings) },
1277
1750
  }));
1278
- const audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
1751
+ let audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
1752
+ if (audio && this.screenAudioEnabled && !this.mediaPermissions.audio) {
1753
+ // The screen goes out; its sound cannot in a project without audio.
1754
+ this.emit('error', new RpcError('media-disabled', 'audio is switched off for this project; the screen is shared without its sound'));
1755
+ audio = undefined;
1756
+ }
1279
1757
  if (audio && this.screenAudioEnabled && generation === this.screenGeneration)
1280
1758
  await keep(await transport.produce({
1281
1759
  track: audio, stopTracks: false,
@@ -1283,13 +1761,25 @@ export class StreamClient {
1283
1761
  appData: { source: 'screen-audio' },
1284
1762
  }));
1285
1763
  }
1764
+ /**
1765
+ * 60 only when asked for, allowed by the project, captured at 50 or more and
1766
+ * not fallen back from. The project's refusal and the camera's are reported
1767
+ * apart, each once: `frame-rate-not-allowed` is the server's own code for a
1768
+ * 60 fps producer the project does not allow, `frame-rate-fallback` means
1769
+ * the camera or device cannot provide it.
1770
+ */
1286
1771
  frameRateFor(track) {
1287
1772
  if (this.publishOptions.frameRate !== 60 || this.frameRateFallback)
1288
1773
  return 30;
1774
+ if (!this.allows60()) {
1775
+ this.frameRateFallback = true;
1776
+ this.emit('error', new RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
1777
+ return 30;
1778
+ }
1289
1779
  const captured = track.getSettings().frameRate;
1290
- if (this.videoPolicy.maxFrameRate === 30 || (typeof captured === 'number' && captured < 50)) {
1780
+ if (typeof captured === 'number' && captured < 50) {
1291
1781
  this.frameRateFallback = true;
1292
- this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera or project; publishing at 30 fps'));
1782
+ this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera; publishing at 30 fps'));
1293
1783
  return 30;
1294
1784
  }
1295
1785
  return 60;
@@ -1336,6 +1826,8 @@ export class StreamClient {
1336
1826
  * the user has not interacted with.
1337
1827
  */
1338
1828
  async publishMusic(input, options) {
1829
+ if (!this.mediaPermissions.audio)
1830
+ throw new RpcError('media-disabled', 'audio is switched off for this project');
1339
1831
  const generation = this.publishGeneration;
1340
1832
  if (this.music)
1341
1833
  await this.stopMusic();
@@ -1346,7 +1838,7 @@ export class StreamClient {
1346
1838
  // already closed it, and the music must not be remembered for a
1347
1839
  // reconnect to publish again.
1348
1840
  if (generation !== this.publishGeneration)
1349
- throw new Error(unpublishedWhile('music'));
1841
+ throw unpublishedWhile('music');
1350
1842
  }
1351
1843
  catch (err) {
1352
1844
  music.close();
@@ -1359,7 +1851,7 @@ export class StreamClient {
1359
1851
  async produceMusic(music, generation) {
1360
1852
  const transport = await this.ensureSendTransport();
1361
1853
  if (generation !== this.publishGeneration)
1362
- throw new Error(unpublishedWhile('music'));
1854
+ throw unpublishedWhile('music');
1363
1855
  const producer = await transport.produce({
1364
1856
  track: music.track,
1365
1857
  stopTracks: false,
@@ -1419,13 +1911,49 @@ export class StreamClient {
1419
1911
  }
1420
1912
  async consume(remote) {
1421
1913
  if (!this.device)
1422
- throw new Error('not connected');
1914
+ throw new RpcError('not-connected', 'not connected');
1423
1915
  if (this.consumers.has(remote.producerId))
1424
1916
  return;
1425
1917
  // Your own camera is already local; pulling it back off the SFU wastes a
1426
1918
  // consumer and shows you a delayed copy of yourself.
1427
1919
  if (remote.identity === this.joinResult?.identity)
1428
1920
  return;
1921
+ // A wallet session receives at most media.fanout.maxVideoSubscriptions
1922
+ // videos; the media node refuses the next. Wait for a slot instead of
1923
+ // asking for a refusal, and say so once per video.
1924
+ if (remote.kind === 'video' && !this.videoSlots.has(remote.producerId)) {
1925
+ const limit = this.mediaPermissions.maxVideoSubscriptions;
1926
+ if (limit !== null && this.videoSlots.size >= limit) {
1927
+ if (!this.skippedVideos.has(remote.producerId)) {
1928
+ this.skippedVideos.set(remote.producerId, remote);
1929
+ this.emit('error', new RpcError('video-subscription-limit', `already receiving ${limit} videos, the most this project allows; ${remote.identity}'s video waits for one to end`));
1930
+ }
1931
+ return;
1932
+ }
1933
+ this.videoSlots.add(remote.producerId);
1934
+ }
1935
+ try {
1936
+ await this.consumeNow(remote);
1937
+ }
1938
+ catch (err) {
1939
+ if (!this.consumers.has(remote.producerId))
1940
+ this.videoSlots.delete(remote.producerId);
1941
+ throw err;
1942
+ }
1943
+ }
1944
+ /** The oldest videos left waiting at the fan-out limit take the slots that opened. */
1945
+ async consumeSkipped() {
1946
+ for (const [producerId, remote] of this.skippedVideos) {
1947
+ const limit = this.mediaPermissions.maxVideoSubscriptions;
1948
+ if (limit !== null && this.videoSlots.size >= limit)
1949
+ return;
1950
+ this.skippedVideos.delete(producerId);
1951
+ await this.consume(remote);
1952
+ }
1953
+ }
1954
+ async consumeNow(remote) {
1955
+ if (!this.device)
1956
+ throw new RpcError('not-connected', 'not connected');
1429
1957
  const transport = await this.ensureRecvTransport();
1430
1958
  const params = (await this.rpc('consume', {
1431
1959
  transportId: transport.id,
@@ -1458,6 +1986,7 @@ export class StreamClient {
1458
1986
  const kind = consumer.kind;
1459
1987
  const source = remote.source ?? defaultSource(kind);
1460
1988
  this.remoteSources.set(remote.producerId, source);
1989
+ this.remoteInfo.set(remote.producerId, { identity: remote.identity, kind, paused: Boolean(remote.paused) });
1461
1990
  this.emit('track', {
1462
1991
  identity: remote.identity,
1463
1992
  kind,
@@ -1600,8 +2129,72 @@ export class StreamClient {
1600
2129
  }
1601
2130
  promote(identity, role) { return this.rpc('promote', { identity, role }); }
1602
2131
  kick(identity, reason) { return this.rpc('kick', { identity, reason }); }
1603
- endRoom() { return this.rpc('endRoom'); }
2132
+ /**
2133
+ * End the room for everyone. The server tells the room it has closed
2134
+ * before it replies, and that roomClosed closes this session, so the reply
2135
+ * may never be read: roomClosed is then the answer, and endRoom() resolves
2136
+ * as the reply would have, with `{ ok: true }`. It still fails when the
2137
+ * server refuses, or the connection drops with neither.
2138
+ */
2139
+ endRoom() {
2140
+ return new Promise((resolve, reject) => {
2141
+ const ended = () => resolve({ ok: true });
2142
+ this.endRoomWaiters.add(ended);
2143
+ this.rpc('endRoom').then(resolve, reject).finally(() => this.endRoomWaiters.delete(ended));
2144
+ });
2145
+ }
1604
2146
  getRoomState() { return this.rpc('getRoomState'); }
2147
+ // ------------------------------------------------------- slots and metadata
2148
+ //
2149
+ // A slot is a named thing in the room that at most one participant holds at
2150
+ // a time: a seat on a board, a guest spot. Keys are yours; the server never
2151
+ // learns that `seat-13` is a seat. `info.claims` and `info.blockedSlots`
2152
+ // seed a board on join and follow claimChanged.
2153
+ /**
2154
+ * Take a slot, or (moderators) grant one by naming `identity`. One claim
2155
+ * per identity: a second moves you. Refused with `slot-taken` when someone
2156
+ * holds it (away included) and `slot-blocked` when a moderator locked it.
2157
+ * Claim first, then promote: if the slot has gone, nothing has happened yet.
2158
+ */
2159
+ async claim(key, identity) {
2160
+ await this.rpc('claim', identity ? { key, identity } : { key });
2161
+ }
2162
+ /** Give up a slot. Moderators may release anyone's. */
2163
+ async release(key) {
2164
+ await this.rpc('release', { key });
2165
+ }
2166
+ /**
2167
+ * Lock a slot so nobody may claim it. Moderators only. Refused with
2168
+ * `slot-occupied` while someone holds it: taking a seat from a person using
2169
+ * it is two deliberate acts, not a side effect of one tap.
2170
+ */
2171
+ async blockSlot(key) {
2172
+ await this.rpc('blockSlot', { key });
2173
+ }
2174
+ /** Unlock a slot. Moderators only. */
2175
+ async unblockSlot(key) {
2176
+ await this.rpc('unblockSlot', { key });
2177
+ }
2178
+ /** Replace your own display metadata; the room hears it as participantUpdated. */
2179
+ async setMetadata(metadata) {
2180
+ await this.rpc('setMetadata', { metadata });
2181
+ }
2182
+ /**
2183
+ * Change room-scoped metadata. Moderators only, and persisted, so a client
2184
+ * joining later reads it from `info.metadata`. Merges one key deep by
2185
+ * default (a null value deletes its key); `replace` swaps the whole object.
2186
+ * `requireFree` names slots that must be unheld for the change to apply,
2187
+ * checked and applied together: the refusal (`slot-occupied`) names the
2188
+ * offending keys in `RpcError.data.slots`. Resolves to the metadata now.
2189
+ */
2190
+ async setRoomMetadata(metadata, options = {}) {
2191
+ const res = (await this.rpc('setRoomMetadata', {
2192
+ metadata,
2193
+ ...(options.replace ? { replace: true } : {}),
2194
+ ...(options.requireFree ? { requireFree: options.requireFree } : {}),
2195
+ }));
2196
+ return res?.metadata ?? null;
2197
+ }
1605
2198
  // ------------------------------------------------------------------- chat
1606
2199
  //
1607
2200
  // The room's chat rides this same socket. Someone on a chat-only socket