@voltkit-rtc/client 0.6.0 → 0.7.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 (66) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/README.md +222 -11
  3. package/dist/cjs/chat.d.ts +0 -9
  4. package/dist/cjs/chat.d.ts.map +1 -1
  5. package/dist/cjs/chat.js +16 -14
  6. package/dist/cjs/chat.js.map +1 -1
  7. package/dist/cjs/errors.d.ts +571 -0
  8. package/dist/cjs/errors.d.ts.map +1 -0
  9. package/dist/cjs/errors.js +155 -0
  10. package/dist/cjs/errors.js.map +1 -0
  11. package/dist/cjs/index.d.ts +298 -16
  12. package/dist/cjs/index.d.ts.map +1 -1
  13. package/dist/cjs/index.js +663 -124
  14. package/dist/cjs/index.js.map +1 -1
  15. package/dist/cjs/media-permissions.d.ts +101 -0
  16. package/dist/cjs/media-permissions.d.ts.map +1 -0
  17. package/dist/cjs/media-permissions.js +166 -0
  18. package/dist/cjs/media-permissions.js.map +1 -0
  19. package/dist/cjs/network.d.ts +80 -0
  20. package/dist/cjs/network.d.ts.map +1 -0
  21. package/dist/cjs/network.js +190 -0
  22. package/dist/cjs/network.js.map +1 -0
  23. package/dist/cjs/stats.d.ts +16 -2
  24. package/dist/cjs/stats.d.ts.map +1 -1
  25. package/dist/cjs/stats.js +53 -5
  26. package/dist/cjs/stats.js.map +1 -1
  27. package/dist/cjs/token.d.ts +17 -0
  28. package/dist/cjs/token.d.ts.map +1 -0
  29. package/dist/cjs/token.js +36 -0
  30. package/dist/cjs/token.js.map +1 -0
  31. package/dist/esm/chat.d.ts +0 -9
  32. package/dist/esm/chat.d.ts.map +1 -1
  33. package/dist/esm/chat.js +16 -14
  34. package/dist/esm/chat.js.map +1 -1
  35. package/dist/esm/errors.d.ts +571 -0
  36. package/dist/esm/errors.d.ts.map +1 -0
  37. package/dist/esm/errors.js +149 -0
  38. package/dist/esm/errors.js.map +1 -0
  39. package/dist/esm/index.d.ts +298 -16
  40. package/dist/esm/index.d.ts.map +1 -1
  41. package/dist/esm/index.js +642 -115
  42. package/dist/esm/index.js.map +1 -1
  43. package/dist/esm/media-permissions.d.ts +101 -0
  44. package/dist/esm/media-permissions.d.ts.map +1 -0
  45. package/dist/esm/media-permissions.js +160 -0
  46. package/dist/esm/media-permissions.js.map +1 -0
  47. package/dist/esm/network.d.ts +80 -0
  48. package/dist/esm/network.d.ts.map +1 -0
  49. package/dist/esm/network.js +183 -0
  50. package/dist/esm/network.js.map +1 -0
  51. package/dist/esm/stats.d.ts +16 -2
  52. package/dist/esm/stats.d.ts.map +1 -1
  53. package/dist/esm/stats.js +53 -5
  54. package/dist/esm/stats.js.map +1 -1
  55. package/dist/esm/token.d.ts +17 -0
  56. package/dist/esm/token.d.ts.map +1 -0
  57. package/dist/esm/token.js +31 -0
  58. package/dist/esm/token.js.map +1 -0
  59. package/package.json +1 -1
  60. package/src/chat.ts +7 -14
  61. package/src/errors.ts +162 -0
  62. package/src/index.ts +785 -115
  63. package/src/media-permissions.ts +220 -0
  64. package/src/network.ts +212 -0
  65. package/src/stats.ts +51 -6
  66. 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". */
@@ -59,9 +56,29 @@ const isLayer = (value) => Number.isInteger(value) && value >= 0;
59
56
  */
60
57
  const VIDEO_START_BITRATE_KBPS = 2000;
61
58
  /** 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`;
59
+ const unpublishedWhile = (what) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
60
+ /**
61
+ * mediasoup-client puts the microphone's Opus maxaveragebitrate in the answer
62
+ * SDP only, so the server would never see it. Declare it in the producer's
63
+ * parameters too: the media node bills HD on a declared value above 64,000
64
+ * (spec §4) and refuses one in a project limited to standard audio, so a
65
+ * standard microphone declares 64,000 and an HD one 128,000. Music and screen
66
+ * audio go out as they always did.
67
+ */
68
+ function declareAudioQuality(rtpParameters, appData) {
69
+ const quality = appData.audioQuality;
70
+ if (appData.source !== 'mic' || (quality !== 'std' && quality !== 'hd'))
71
+ return rtpParameters;
72
+ const maxaveragebitrate = AUDIO_SETTINGS[quality].maxBitrate;
73
+ return {
74
+ ...rtpParameters,
75
+ codecs: rtpParameters.codecs.map((codec) => (codec.mimeType.toLowerCase() === 'audio/opus'
76
+ ? { ...codec, parameters: { ...codec.parameters, maxaveragebitrate } } : codec)),
77
+ };
78
+ }
63
79
  export class StreamClient {
64
80
  wsUrl;
81
+ options;
65
82
  ws = null;
66
83
  device = null;
67
84
  sendTransport = null;
@@ -131,10 +148,31 @@ export class StreamClient {
131
148
  codecFallbackLogged = false;
132
149
  sendStatsBaseline = new Map();
133
150
  recvStatsBaseline = new Map();
134
- limitWatch = new VideoLimitWatch((baseline) => this.sampleCamera(baseline), (event) => this.emit('videoLimited', event));
135
- constructor(wsUrl, token) {
151
+ limitWatch = new VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) => this.emit('videoLimited', event), undefined, () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; });
152
+ /** Where the session stands; see connectionStateChanged. */
153
+ state = 'idle';
154
+ /** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
155
+ tokenExpiry;
156
+ tokenTimer = null;
157
+ /** Whose each received track is and whether it is switched off, by producerId. */
158
+ remoteInfo = new Map();
159
+ /** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
160
+ skippedVideos = new Map();
161
+ /** Producers whose video this client receives, or is about to: the fan-out count. */
162
+ videoSlots = new Set();
163
+ /** The quality the microphone producer was made with. */
164
+ micQuality = 'std';
165
+ // Changes to the microphone producer run one at a time, as the camera's do.
166
+ micQueue = Promise.resolve();
167
+ // A request the project does not allow is reported once, until it is made again.
168
+ audioCapReported = false;
169
+ sizeCapReported = false;
170
+ networkWatch = new NetworkQualityWatch(() => this.transportStats(), () => this.producers.size > 0, (quality) => this.emit('networkQuality', quality));
171
+ constructor(wsUrl, token, options = {}) {
136
172
  this.wsUrl = wsUrl;
173
+ this.options = options;
137
174
  this.token = token;
175
+ this.tokenExpiry = tokenExpiresAt(token);
138
176
  }
139
177
  on(event, fn) {
140
178
  let set = this.listeners.get(event);
@@ -143,15 +181,45 @@ export class StreamClient {
143
181
  this.listeners.set(event, set);
144
182
  }
145
183
  set.add(fn);
146
- // videoLimited costs a getStats every 2 s, so it runs only while heard.
184
+ // videoLimited and networkQuality cost a getStats every few seconds, so
185
+ // they run only while heard.
147
186
  if (event === 'videoLimited')
148
187
  this.syncLimitWatch();
188
+ if (event === 'networkQuality')
189
+ this.syncNetworkWatch();
149
190
  return () => {
150
191
  set.delete(fn);
151
192
  if (event === 'videoLimited')
152
193
  this.syncLimitWatch();
194
+ if (event === 'networkQuality')
195
+ this.syncNetworkWatch();
153
196
  };
154
197
  }
198
+ /** Where the session stands: idle, connecting, connected, reconnecting or closed. */
199
+ get connectionState() {
200
+ return this.state;
201
+ }
202
+ setState(next) {
203
+ if (next === this.state)
204
+ return;
205
+ const previous = this.state;
206
+ this.state = next;
207
+ if (next === 'closed')
208
+ this.stopTimers();
209
+ this.emit('connectionStateChanged', { state: next, previous });
210
+ }
211
+ /** Over for good: the state, then the event that says why. */
212
+ finish(reason) {
213
+ this.closedByUs = true;
214
+ this.setState('closed');
215
+ this.emit('closed', reason);
216
+ }
217
+ stopTimers() {
218
+ if (this.tokenTimer)
219
+ clearTimeout(this.tokenTimer);
220
+ this.tokenTimer = null;
221
+ this.networkWatch.set(false);
222
+ }
155
223
  listenerCount(event) {
156
224
  return this.listeners.get(event)?.size ?? 0;
157
225
  }
@@ -168,6 +236,8 @@ export class StreamClient {
168
236
  // ------------------------------------------------------------- connection
169
237
  async connect() {
170
238
  let result;
239
+ if (this.state === 'idle' || this.state === 'closed')
240
+ this.setState('connecting');
171
241
  try {
172
242
  await this.openSocket();
173
243
  result = (await this.rpc('join', { token: this.token }));
@@ -179,8 +249,11 @@ export class StreamClient {
179
249
  // A session that never got going: the app has the error. Left open,
180
250
  // the socket would be closed by the server's join deadline instead, and
181
251
  // that close would start reconnecting a session that never began.
182
- if (!this.joinedOnce)
252
+ if (!this.joinedOnce) {
183
253
  this.detachSocket();
254
+ if (this.state === 'connecting')
255
+ this.setState('idle');
256
+ }
184
257
  throw err;
185
258
  }
186
259
  // Joined and set up: the next drop gets every reconnect attempt again.
@@ -189,6 +262,9 @@ export class StreamClient {
189
262
  // retried about twice a second, forever.
190
263
  this.reconnectAttempt = 0;
191
264
  this.joinedOnce = true;
265
+ this.skippedVideos.clear();
266
+ this.setState('connected');
267
+ this.scheduleTokenExpiring();
192
268
  // A role or mute can change while the socket is away. The fresh roster
193
269
  // decides what may return; reconnecting never turns a blocked device on.
194
270
  if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
@@ -223,11 +299,17 @@ export class StreamClient {
223
299
  if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
224
300
  await this.republish().catch((err) => this.emit('error', err));
225
301
  }
302
+ this.syncNetworkWatch();
226
303
  return result;
227
304
  }
228
- /** A new mediasoup Device for each join. Its own method so tests, which have no WebRTC, can stand one in. */
305
+ /**
306
+ * A new mediasoup Device for each join: the handler named in the options, or
307
+ * the one detected from the browser. Its own method so tests, which have no
308
+ * WebRTC, can stand one in.
309
+ */
229
310
  createDevice() {
230
- return new Device();
311
+ const { handlerName } = this.options;
312
+ return handlerName ? new Device({ handlerName }) : new Device();
231
313
  }
232
314
  openSocket() {
233
315
  // Abandon any previous socket first. Its close handler checks identity
@@ -235,15 +317,13 @@ export class StreamClient {
235
317
  // reconnect -- which is what let multiple reconnect chains pile up.
236
318
  this.detachSocket();
237
319
  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());
320
+ const ws = new WebSocket(socketUrl(this.wsUrl, this.token));
241
321
  this.ws = ws;
242
322
  const onOpen = () => {
243
323
  this.emit('connected');
244
324
  resolve();
245
325
  };
246
- const onError = () => reject(new Error('websocket failed to open'));
326
+ const onError = () => reject(new RpcError('connect-failed', 'websocket failed to open'));
247
327
  ws.addEventListener('open', onOpen, { once: true });
248
328
  ws.addEventListener('error', onError, { once: true });
249
329
  ws.addEventListener('message', (ev) => {
@@ -256,33 +336,33 @@ export class StreamClient {
256
336
  if (this.ws !== ws)
257
337
  return;
258
338
  this.ws = null;
259
- this.failAllPending(new Error(`socket closed (${ev.code})`));
339
+ this.failAllPending(new RpcError('not-connected', `socket closed (${ev.code})`));
260
340
  this.emit('disconnected', ev.reason || String(ev.code));
261
341
  if (this.closedByUs)
262
342
  return;
263
343
  if (ev.code === CLOSE_REPLACED) {
264
344
  // Another session for this identity is live. Reconnecting would
265
345
  // 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;
346
+ this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
268
347
  // The producers outlive this close until the app calls close(); the
269
348
  // videoLimited sampling must not, or it reports after `closed`.
270
349
  this.limitWatch.reset();
271
- this.emit('closed', 'replaced');
350
+ this.finish('replaced');
272
351
  return;
273
352
  }
274
353
  if (ev.code === CLOSE_ENDED) {
275
354
  // Normally roomClosed has already ended the session; this covers a
276
355
  // close that arrived without it.
277
- this.closedByUs = true;
278
356
  this.limitWatch.reset();
279
- this.emit('closed', 'room-closed');
357
+ this.finish('room-closed');
280
358
  return;
281
359
  }
282
360
  // Before a first successful join the app is still waiting on
283
361
  // connect(), which reports the failure; there is nothing to restore.
284
362
  if (!this.joinedOnce)
285
363
  return;
364
+ this.setState('reconnecting');
365
+ this.networkWatch.set(false);
286
366
  void this.scheduleReconnect();
287
367
  });
288
368
  });
@@ -315,9 +395,8 @@ export class StreamClient {
315
395
  while (!this.closedByUs) {
316
396
  this.reconnectAttempt += 1;
317
397
  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');
398
+ this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
399
+ this.finish('gave-up');
321
400
  return;
322
401
  }
323
402
  const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
@@ -336,12 +415,11 @@ export class StreamClient {
336
415
  }
337
416
  catch (err) {
338
417
  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');
418
+ // Stop on failures a retry cannot resolve (the registry's terminal
419
+ // codes). Hammering a room that has ended produces one error per
420
+ // attempt forever and drowns out whatever actually went wrong.
421
+ if (err instanceof RpcError && isTerminalError(err.code)) {
422
+ this.finish('gave-up');
345
423
  return;
346
424
  }
347
425
  }
@@ -368,6 +446,7 @@ export class StreamClient {
368
446
  this.music = null;
369
447
  // Nothing is emitted after closing, so not even a clearing videoLimited.
370
448
  this.limitWatch.reset();
449
+ this.skippedVideos.clear();
371
450
  // Producers no longer stop their tracks, so releasing the camera and
372
451
  // microphone is ours to do -- otherwise the device light stays on.
373
452
  this.localStream?.getTracks().forEach((t) => t.stop());
@@ -375,6 +454,7 @@ export class StreamClient {
375
454
  const ws = this.ws;
376
455
  this.ws = null;
377
456
  ws?.close(1000, 'client closed');
457
+ this.setState('closed');
378
458
  if (wasOpen)
379
459
  this.emit('closed', 'left');
380
460
  }
@@ -382,6 +462,9 @@ export class StreamClient {
382
462
  for (const c of this.consumers.values())
383
463
  c.close();
384
464
  this.consumers.clear();
465
+ this.remoteInfo.clear();
466
+ this.skippedVideos.clear();
467
+ this.videoSlots.clear();
385
468
  for (const p of this.producers.values())
386
469
  p.close();
387
470
  this.producers.clear();
@@ -398,22 +481,137 @@ export class StreamClient {
398
481
  this.sendTransportPending = null;
399
482
  this.recvTransportPending = null;
400
483
  this.device = null;
484
+ this.syncNetworkWatch();
401
485
  }
402
- /** Everything currently known about the room, for rendering. */
486
+ /**
487
+ * Everything currently known about the room, for rendering: the join
488
+ * result, kept current -- participants, roles, hands, mutes, display
489
+ * metadata, slots and room metadata follow the events.
490
+ */
403
491
  get info() {
404
492
  return this.joinResult;
405
493
  }
494
+ /** Who is in the room now, this client included; kept current from the roster events. */
495
+ get participants() {
496
+ return [...(this.joinResult?.participants ?? [])];
497
+ }
498
+ /** Every track this client is receiving now. */
499
+ get remoteTracks() {
500
+ const out = [];
501
+ for (const [producerId, consumer] of this.consumers) {
502
+ const info = this.remoteInfo.get(producerId);
503
+ if (!info || consumer.closed)
504
+ continue;
505
+ out.push({
506
+ producerId, identity: info.identity, kind: info.kind, track: consumer.track,
507
+ source: this.remoteSources.get(producerId) ?? defaultSource(info.kind), paused: info.paused,
508
+ });
509
+ }
510
+ return out;
511
+ }
512
+ /**
513
+ * What this project lets this client publish: from the join result's
514
+ * `media` (servers with wallet billing), else its `video` policy, else
515
+ * everything before a join. Offer only these in your UI; the SDK refuses
516
+ * or caps anything else with the server's own codes.
517
+ */
518
+ get mediaPermissions() {
519
+ return resolvePermissions(this.joinResult);
520
+ }
521
+ /** How this session is billed: `legacy` (minutes) or `wallet`; null before a join or on older servers. */
522
+ get billingMode() {
523
+ return this.joinResult?.billing?.mode ?? null;
524
+ }
525
+ // ------------------------------------------------------------ token refresh
526
+ /**
527
+ * Hand the session a newer room token, minted by your backend for the same
528
+ * room and identity (`POST /v1/rooms/{roomId}/tokens`), typically on
529
+ * `tokenExpiring`. On a live connection the server checks it like a join
530
+ * token (`bad-token`, or `token-mismatch` for another room or identity; the
531
+ * session then keeps its token); otherwise it is kept for the next join,
532
+ * which checks it there. Resolves to when it expires (unix ms), when known.
533
+ */
534
+ async updateToken(token) {
535
+ if (typeof token !== 'string' || token.trim() === '')
536
+ throw new TypeError('updateToken needs a token');
537
+ if (this.state === 'closed')
538
+ throw new RpcError('not-connected', 'this session is closed; create a new client');
539
+ if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
540
+ try {
541
+ const res = (await this.rpc('refreshToken', { token }));
542
+ const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
543
+ this.setToken(token, at);
544
+ return { expiresAt: this.tokenExpiry };
545
+ }
546
+ catch (err) {
547
+ // An older server has no refreshToken, and a socket that dropped
548
+ // during the call is reconnecting: either way keep the token for the
549
+ // next join, which checks it.
550
+ if (!(err instanceof RpcError) || (err.code !== 'unknown-method' && err.code !== 'not-connected'))
551
+ throw err;
552
+ }
553
+ }
554
+ this.setToken(token, null);
555
+ return { expiresAt: this.tokenExpiry };
556
+ }
557
+ /** The token reconnects use from now on, with its expiry (read from the token when not given). */
558
+ setToken(token, expiresAt) {
559
+ this.token = token;
560
+ this.tokenExpiry = expiresAt ?? tokenExpiresAt(token);
561
+ this.scheduleTokenExpiring();
562
+ }
563
+ /**
564
+ * Warn TOKEN_EXPIRING_LEAD_MS before the token held for reconnects
565
+ * expires. Every new token -- the server's refresh, a promotion's, the
566
+ * app's -- reschedules it, so it fires only when nobody refreshed it.
567
+ */
568
+ scheduleTokenExpiring() {
569
+ if (this.tokenTimer)
570
+ clearTimeout(this.tokenTimer);
571
+ this.tokenTimer = null;
572
+ const expiresAt = this.tokenExpiry;
573
+ if (expiresAt === null || this.state === 'idle' || this.state === 'closed')
574
+ return;
575
+ // setTimeout cannot wait longer than about 24.8 days.
576
+ const delay = Math.min(Math.max(0, expiresAt - TOKEN_EXPIRING_LEAD_MS - Date.now()), 2_147_000_000);
577
+ this.tokenTimer = setTimeout(() => {
578
+ this.tokenTimer = null;
579
+ if (this.state === 'closed' || this.tokenExpiry !== expiresAt)
580
+ return;
581
+ if (expiresAt - TOKEN_EXPIRING_LEAD_MS > Date.now()) {
582
+ this.scheduleTokenExpiring(); // woke early from a clamped wait
583
+ return;
584
+ }
585
+ this.emit('tokenExpiring', { expiresAt, secondsLeft: Math.max(0, Math.round((expiresAt - Date.now()) / 1000)) });
586
+ }, delay);
587
+ }
588
+ // ------------------------------------------------------------ network quality
589
+ /** networkQuality sampling runs while someone listens and the session is connected. */
590
+ syncNetworkWatch() {
591
+ this.networkWatch.set(!this.closedByUs && this.state === 'connected' && this.listenerCount('networkQuality') > 0);
592
+ }
593
+ /** Each live media transport's statistics, keyed by transport so a new one starts fresh. */
594
+ async transportStats() {
595
+ const out = new Map();
596
+ for (const transport of [this.sendTransport, this.recvTransport]) {
597
+ if (!transport || transport.closed)
598
+ continue;
599
+ const report = await transport.getStats();
600
+ out.set(transport.id, report.values());
601
+ }
602
+ return out;
603
+ }
406
604
  // -------------------------------------------------------------------- rpc
407
605
  rpc(method, params = {}) {
408
606
  return new Promise((resolve, reject) => {
409
607
  if (this.ws?.readyState !== WebSocket.OPEN) {
410
- reject(new Error('socket is not open'));
608
+ reject(new RpcError('not-connected', 'socket is not open'));
411
609
  return;
412
610
  }
413
611
  const id = this.nextId++;
414
612
  const timer = setTimeout(() => {
415
613
  this.pending.delete(id);
416
- reject(new Error(`${method} timed out`));
614
+ reject(new RpcError('timeout', `${method} timed out`));
417
615
  }, RPC_TIMEOUT_MS);
418
616
  this.pending.set(id, { resolve, reject, timer });
419
617
  this.ws.send(JSON.stringify({ id, method, params }));
@@ -444,7 +642,7 @@ export class StreamClient {
444
642
  p.resolve(msg.result);
445
643
  else {
446
644
  const e = msg.error;
447
- p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown'));
645
+ p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown', e?.data));
448
646
  }
449
647
  return;
450
648
  }
@@ -462,17 +660,27 @@ export class StreamClient {
462
660
  this.preferredLayers.delete(producerId);
463
661
  this.receiveQualities.delete(producerId);
464
662
  this.remoteVideoLayers.delete(producerId);
663
+ this.skippedVideos.delete(producerId);
465
664
  const consumer = this.consumers.get(producerId);
466
665
  if (!consumer)
467
666
  break; // never consumed it, or already cleaned up
667
+ this.videoSlots.delete(producerId);
468
668
  consumer.close();
469
669
  this.consumers.delete(producerId);
470
670
  this.remoteSources.delete(producerId);
671
+ this.remoteInfo.delete(producerId);
471
672
  this.emit('trackEnded', { producerId, identity });
673
+ // A video slot opened under the fan-out limit: the oldest video left
674
+ // waiting takes it.
675
+ if (consumer.kind === 'video')
676
+ void this.consumeSkipped().catch((err) => this.emit('error', err));
472
677
  break;
473
678
  }
474
679
  case 'producerPaused': {
475
680
  const p = data;
681
+ const info = this.remoteInfo.get(p.producerId);
682
+ if (info)
683
+ info.paused = Boolean(p.paused);
476
684
  this.emit('trackMuted', {
477
685
  identity: p.identity,
478
686
  kind: p.kind,
@@ -482,27 +690,40 @@ export class StreamClient {
482
690
  });
483
691
  break;
484
692
  }
485
- case 'participantJoined':
486
- this.emit('participantJoined', data);
693
+ case 'participantJoined': {
694
+ const p = data;
695
+ // A reconnecting participant joins again: replace, never duplicate.
696
+ this.updateRoster((list) => [...list.filter((q) => q.identity !== p.identity), p]);
697
+ this.emit('participantJoined', p);
487
698
  break;
699
+ }
488
700
  case 'participantMuted': {
489
701
  const wire = data;
490
702
  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
- });
703
+ this.updateRoster((list) => list.map((p) => p.identity !== e.identity ? p : { ...p,
704
+ ...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
705
+ ...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
706
+ }));
496
707
  this.emit('participantMuted', e);
497
708
  break;
498
709
  }
499
- case 'participantLeft':
500
- this.emit('participantLeft', data);
710
+ case 'participantUpdated': {
711
+ const e = data;
712
+ this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, metadata: e.metadata })));
713
+ this.emit('participantUpdated', { identity: e.identity, metadata: e.metadata ?? null });
714
+ break;
715
+ }
716
+ case 'participantLeft': {
717
+ const e = data;
718
+ this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
719
+ this.emit('participantLeft', e);
501
720
  break;
721
+ }
502
722
  case 'roleChanged': {
503
723
  const e = data;
504
724
  if (e.identity === this.joinResult?.identity)
505
725
  this.joinResult.role = e.role;
726
+ this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, role: e.role })));
506
727
  this.emit('roleChanged', e);
507
728
  // Demoted: the server has already closed our producers. Release the
508
729
  // camera and microphone too, instead of capturing for nobody.
@@ -513,9 +734,13 @@ export class StreamClient {
513
734
  break;
514
735
  }
515
736
  case 'handRaised':
516
- case 'handLowered':
737
+ case 'handLowered': {
738
+ const e = data;
739
+ this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p
740
+ : { ...p, handRaised: Boolean(e.handRaised), ...(e.handRaised ? { handMetadata: e.metadata } : { handMetadata: undefined }) })));
517
741
  this.emit('handRaised', data);
518
742
  break;
743
+ }
519
744
  case 'forceMuted': {
520
745
  // The server has already paused that kind (an audio mute includes
521
746
  // in-app music: it travels as audio). Reflect it locally; a release
@@ -528,20 +753,33 @@ export class StreamClient {
528
753
  this.emit('forceMuted', e);
529
754
  break;
530
755
  }
756
+ case 'claimChanged': {
757
+ const e = data;
758
+ this.applyClaim(e);
759
+ this.emit('claimChanged', { key: e.key, identity: e.identity || null, state: e.state, reason: e.reason ?? '' });
760
+ break;
761
+ }
762
+ case 'roomMetadataChanged': {
763
+ const e = data;
764
+ if (this.joinResult)
765
+ this.joinResult.metadata = e.metadata;
766
+ this.emit('roomMetadataChanged', { metadata: e.metadata ?? null, ...(e.by ? { by: e.by } : {}) });
767
+ break;
768
+ }
531
769
  case 'tokenRefreshed': {
532
770
  // A room outlives its join token many times over. The server hands us a
533
771
  // fresh one periodically so a reconnect at any point still works --
534
772
  // without this, any disconnect after the token's lifetime is terminal.
535
773
  const e = data;
536
774
  if (e.token)
537
- this.token = e.token;
775
+ this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
538
776
  break;
539
777
  }
540
778
  case 'mustReconnect': {
541
779
  const e = data;
542
780
  // A promotion hands us a fresh token carrying the new role.
543
781
  if (e.token)
544
- this.token = e.token;
782
+ this.setToken(e.token, null);
545
783
  this.emit('mustReconnect', e);
546
784
  if (e.retry) {
547
785
  this.teardownMedia();
@@ -557,16 +795,22 @@ export class StreamClient {
557
795
  case 'usageLimit':
558
796
  this.emit('usageLimit', d);
559
797
  break;
798
+ case 'walletLow': {
799
+ const e = data;
800
+ const remainingSeconds = typeof e?.remainingSeconds === 'number' && Number.isFinite(e.remainingSeconds) ? e.remainingSeconds : 0;
801
+ this.emit('walletLow', e?.cleared === true ? { remainingSeconds, cleared: true } : { remainingSeconds });
802
+ break;
803
+ }
560
804
  case 'roomClosed':
561
805
  this.closedByUs = true;
562
806
  this.emit('roomClosed', d);
563
- this.emit('closed', 'room-closed');
807
+ this.finish('room-closed');
564
808
  this.close();
565
809
  break;
566
810
  case 'kicked':
567
811
  this.closedByUs = true;
568
812
  this.emit('kicked', d);
569
- this.emit('closed', 'kicked');
813
+ this.finish('kicked');
570
814
  this.close();
571
815
  break;
572
816
  case 'message':
@@ -585,6 +829,25 @@ export class StreamClient {
585
829
  break;
586
830
  }
587
831
  }
832
+ /** Change the roster the join result holds; a new array each time, so a renderer sees the change. */
833
+ updateRoster(change) {
834
+ if (this.joinResult)
835
+ this.joinResult.participants = change(this.joinResult.participants ?? []);
836
+ }
837
+ /** Keep the join result's slots current: claims held or away, and the moderator's locks. */
838
+ applyClaim(e) {
839
+ const join = this.joinResult;
840
+ if (!join)
841
+ return;
842
+ const claims = (join.claims ?? []).filter((c) => c.key !== e.key);
843
+ let blocked = (join.blockedSlots ?? []).filter((key) => key !== e.key);
844
+ if ((e.state === 'held' || e.state === 'away') && e.identity)
845
+ claims.push({ key: e.key, identity: e.identity, state: e.state });
846
+ if (e.state === 'blocked')
847
+ blocked = [...blocked, e.key];
848
+ join.claims = claims;
849
+ join.blockedSlots = blocked;
850
+ }
588
851
  // ------------------------------------------------------------------ media
589
852
  ensureSendTransport() {
590
853
  if (this.sendTransport)
@@ -598,7 +861,7 @@ export class StreamClient {
598
861
  }
599
862
  async createSendTransport() {
600
863
  if (!this.device)
601
- throw new Error('not connected');
864
+ throw new RpcError('not-connected', 'not connected');
602
865
  const params = (await this.rpc('createTransport', { direction: 'send' }));
603
866
  const transport = this.device.createSendTransport({
604
867
  ...params,
@@ -610,7 +873,10 @@ export class StreamClient {
610
873
  .catch(fail);
611
874
  });
612
875
  transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
613
- this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source, videoLayers: appData.videoLayers })
876
+ this.rpc('produce', {
877
+ transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
878
+ appData, source: appData.source, videoLayers: appData.videoLayers,
879
+ })
614
880
  .then((res) => ok({ id: res.producerId }))
615
881
  .catch(fail);
616
882
  });
@@ -629,7 +895,7 @@ export class StreamClient {
629
895
  }
630
896
  async createRecvTransport() {
631
897
  if (!this.device)
632
- throw new Error('not connected');
898
+ throw new RpcError('not-connected', 'not connected');
633
899
  const params = (await this.rpc('createTransport', { direction: 'recv' }));
634
900
  const transport = this.device.createRecvTransport({
635
901
  ...params,
@@ -656,12 +922,24 @@ export class StreamClient {
656
922
  * kept for a reconnect's republish.
657
923
  */
658
924
  async publish(stream, constraints, options) {
925
+ // A kind the project switched off is refused here, before anything is
926
+ // produced, with the code the controller would answer with.
927
+ const permissions = this.mediaPermissions;
928
+ if (!permissions.audio && stream.getAudioTracks().length > 0) {
929
+ throw new RpcError('media-disabled', 'audio is switched off for this project');
930
+ }
931
+ if (!permissions.video && stream.getVideoTracks().length > 0) {
932
+ throw new RpcError('media-disabled', 'video is switched off for this project');
933
+ }
934
+ await this.publishStream(stream, constraints, options);
935
+ }
936
+ async publishStream(stream, constraints, options) {
659
937
  const generation = this.publishGeneration;
660
938
  const transport = await this.ensureSendTransport();
661
939
  // unpublish() ran first: this publish is over before it began, and must
662
940
  // leave nothing behind that a reconnect would publish again.
663
941
  if (generation !== this.publishGeneration)
664
- throw new Error(unpublishedWhile('stream'));
942
+ throw unpublishedWhile('stream');
665
943
  this.localStream = stream;
666
944
  this.wasPublishing = true;
667
945
  if (constraints)
@@ -670,35 +948,140 @@ export class StreamClient {
670
948
  this.publishOptions = { ...options };
671
949
  this.frameRateFallback = false;
672
950
  this.frameRateLowSamples = 0;
951
+ this.audioCapReported = false;
952
+ this.sizeCapReported = false;
673
953
  }
954
+ // Only a reconnect's republish gets here with a kind switched off since:
955
+ // what is still allowed goes out, and the rest is reported.
956
+ const permissions = this.mediaPermissions;
674
957
  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);
958
+ if (audio && !permissions.audio) {
959
+ this.emit('error', new RpcError('media-disabled', 'audio is switched off for this project; the microphone is not published'));
960
+ }
961
+ else if (audio) {
962
+ await this.micTask(() => this.produceMic(transport, this.localStream?.getAudioTracks()[0] ?? audio, generation));
687
963
  }
688
964
  const video = stream.getVideoTracks()[0];
689
- if (video) {
965
+ if (video && !permissions.video) {
966
+ this.emit('error', new RpcError('media-disabled', 'video is switched off for this project; the camera is not published'));
967
+ }
968
+ else if (video) {
690
969
  // The track is read when the camera's turn comes: one handed to
691
970
  // replaceVideoTrack while this publish was under way is the one to send.
692
971
  await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
693
972
  }
694
973
  }
974
+ // ------------------------------------------------------------ audio quality
975
+ /**
976
+ * The microphone's audio quality: what it is being sent at while
977
+ * published, else what the next publish will use, after the project's limit.
978
+ */
979
+ get audioQuality() {
980
+ if (this.micProducer())
981
+ return this.micQuality;
982
+ return this.publishOptions.audioQuality === 'hd' && this.mediaPermissions.audioQualityMax === 'hd' ? 'hd' : 'std';
983
+ }
984
+ /**
985
+ * Switch the microphone between standard (mono, up to 64 kbps) and HD
986
+ * (stereo, up to 128 kbps) while live. The codec settings are fixed per
987
+ * producer, so the microphone's producer is replaced: listeners hear a gap
988
+ * of a fraction of a second, and a microphone that is off stays off. Rejects
989
+ * with `audio-quality-not-allowed` when the project allows standard audio
990
+ * only. Resolves to the quality in use.
991
+ */
992
+ async setAudioQuality(quality) {
993
+ if (quality !== 'std' && quality !== 'hd')
994
+ throw new RangeError("setAudioQuality: expected 'std' or 'hd'");
995
+ if (quality === 'hd' && this.mediaPermissions.audioQualityMax !== 'hd') {
996
+ throw new RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project');
997
+ }
998
+ this.publishOptions = { ...this.publishOptions, audioQuality: quality };
999
+ this.audioCapReported = false;
1000
+ await this.micTask(async () => {
1001
+ const current = this.micProducer();
1002
+ const track = current?.track;
1003
+ if (!current || !track || track.readyState !== 'live' || this.micQuality === quality)
1004
+ return;
1005
+ await this.reproduceMic(current, track);
1006
+ });
1007
+ return this.audioQuality;
1008
+ }
1009
+ micProducer() {
1010
+ for (const p of this.producers.values()) {
1011
+ if (p.kind === 'audio' && p.appData.source === 'mic')
1012
+ return p;
1013
+ }
1014
+ return undefined;
1015
+ }
1016
+ micTask(task) {
1017
+ const run = this.micQueue.then(task, task);
1018
+ this.micQueue = run.catch(() => undefined);
1019
+ return run;
1020
+ }
1021
+ /** The audio quality to send: the request, capped to the project's, with the cap reported once. */
1022
+ audioQualityFor() {
1023
+ if (this.publishOptions.audioQuality !== 'hd')
1024
+ return 'std';
1025
+ if (this.mediaPermissions.audioQualityMax === 'hd')
1026
+ return 'hd';
1027
+ if (!this.audioCapReported) {
1028
+ this.audioCapReported = true;
1029
+ this.emit('error', new RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project; publishing standard audio'));
1030
+ }
1031
+ return 'std';
1032
+ }
1033
+ /**
1034
+ * Produce the microphone at the audio quality in force. A track handed over
1035
+ * switched off (or paused before a reconnect or a quality change) starts
1036
+ * paused, on both ends.
1037
+ */
1038
+ async produceMic(transport, track, generation) {
1039
+ if (generation !== this.publishGeneration)
1040
+ throw unpublishedWhile('microphone');
1041
+ const quality = this.audioQualityFor();
1042
+ const settings = AUDIO_SETTINGS[quality];
1043
+ const producer = await transport.produce({
1044
+ track,
1045
+ // Keep the track alive when the producer closes: a reconnect closes
1046
+ // every producer, and a stopped track cannot be republished.
1047
+ stopTracks: false,
1048
+ // The answer's maxaveragebitrate sets the encoder's rate and the
1049
+ // sender's maxBitrate holds it there: standard must stay at or under
1050
+ // 64 kbps, which is what the server measures to bill it, so its sender
1051
+ // is held a little under the declared rate (Opus VBR overshoots).
1052
+ encodings: [{ maxBitrate: settings.senderMaxBitrate }],
1053
+ codecOptions: { opusStereo: settings.stereo, opusDtx: true, opusFec: true, opusMaxAverageBitrate: settings.maxBitrate },
1054
+ appData: { source: 'mic', audioQuality: quality },
1055
+ });
1056
+ await this.keepProducer(producer, generation, 'microphone');
1057
+ this.micQuality = quality;
1058
+ // A microphone handed over switched off, or muted before a reconnect.
1059
+ await this.pauseOnServer(producer);
1060
+ return producer;
1061
+ }
1062
+ /** Close the microphone producer and produce its track again at the quality now in force. */
1063
+ async reproduceMic(current, track) {
1064
+ const generation = this.publishGeneration;
1065
+ current.close();
1066
+ this.producers.delete(current.id);
1067
+ // The server allows one producer per source: the old one goes first.
1068
+ await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
1069
+ return this.produceMic(await this.ensureSendTransport(), track, generation);
1070
+ }
695
1071
  /** What this project may publish. Known once joined; 1080p before that. */
696
1072
  get videoPolicy() {
697
1073
  return this.joinResult?.video ?? DEFAULT_VIDEO_POLICY;
698
1074
  }
699
- /** The quality video is being sent at, after the project's limit. */
1075
+ /**
1076
+ * The quality video is being sent at, after the project's limit: at 60 fps
1077
+ * the largest size the project allows at 60, otherwise its 30 fps maximum.
1078
+ */
700
1079
  get videoQuality() {
701
- return allowedQuality(this.publishOptions.quality, this.videoPolicy);
1080
+ return qualityAt(this.publishOptions.quality, this.videoFrameRate, this.mediaPermissions);
1081
+ }
1082
+ /** The camera's frame rate: what it is being sent at while published, else 30 until a publish decides. */
1083
+ get videoFrameRate() {
1084
+ return this.cameraProducer() ? this.cameraFrameRate : 30;
702
1085
  }
703
1086
  /**
704
1087
  * Change the video quality while publishing. The camera's producer is
@@ -708,6 +1091,7 @@ export class StreamClient {
708
1091
  */
709
1092
  async setVideoQuality(quality) {
710
1093
  this.publishOptions = { ...this.publishOptions, quality };
1094
+ this.sizeCapReported = false;
711
1095
  await this.cameraTask(async () => {
712
1096
  const current = this.cameraProducer();
713
1097
  const track = current?.track;
@@ -717,25 +1101,54 @@ export class StreamClient {
717
1101
  });
718
1102
  return this.videoQuality;
719
1103
  }
720
- /** Change the camera frame rate without stopping screen, microphone or music. */
1104
+ /**
1105
+ * Change the camera frame rate without stopping screen, microphone or music.
1106
+ * 60 where the project allows none stays at 30 and warns on `error` with
1107
+ * `frame-rate-not-allowed`, without touching the camera; a camera or device
1108
+ * that cannot keep 60 up falls back with `frame-rate-fallback`. Resolves to
1109
+ * the frame rate used.
1110
+ */
721
1111
  async setVideoFrameRate(frameRate) {
722
1112
  if (frameRate !== 30 && frameRate !== 60)
723
1113
  throw new RangeError('setVideoFrameRate: expected 30 or 60');
724
1114
  this.publishOptions = { ...this.publishOptions, frameRate };
725
1115
  this.frameRateFallback = false;
726
1116
  this.frameRateLowSamples = 0;
1117
+ this.sizeCapReported = false;
1118
+ if (frameRate === 60 && !this.allows60()) {
1119
+ this.frameRateFallback = true;
1120
+ this.emit('error', new RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
1121
+ return this.videoFrameRate;
1122
+ }
727
1123
  await this.cameraTask(async () => {
728
1124
  const current = this.cameraProducer();
729
1125
  const track = current?.track;
730
1126
  if (!current || !track || track.readyState !== 'live')
731
1127
  return;
732
1128
  try {
733
- await track.applyConstraints?.({ frameRate: { ideal: frameRate, max: frameRate } });
1129
+ await track.applyConstraints?.({ ...track.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } });
734
1130
  }
735
1131
  catch { /* Existing capture settings decide whether 60 fps is possible. */ }
736
1132
  await this.reproduceCamera(current, track);
737
1133
  });
738
- return this.cameraProducer() ? this.cameraFrameRate : this.videoPolicy.maxFrameRate === 30 ? 30 : frameRate;
1134
+ return this.cameraProducer() ? this.cameraFrameRate : this.allows60() ? frameRate : 30;
1135
+ }
1136
+ /** Whether this project allows any 60 fps mode. */
1137
+ allows60() {
1138
+ const permissions = this.mediaPermissions;
1139
+ return permissions.maxFrameRate === 60 && permissions.maxVideoQuality60 !== null;
1140
+ }
1141
+ /**
1142
+ * The quality to send at a frame rate. A request above what 60 fps allows
1143
+ * is sent at the largest size it does allow, and said so once.
1144
+ */
1145
+ cameraQuality(frameRate) {
1146
+ const permissions = this.mediaPermissions;
1147
+ if (frameRate === 60 && !this.sizeCapReported && above60fpsCap(this.publishOptions.quality, permissions)) {
1148
+ this.sizeCapReported = true;
1149
+ 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`));
1150
+ }
1151
+ return qualityAt(this.publishOptions.quality, frameRate, permissions);
739
1152
  }
740
1153
  /**
741
1154
  * Send a different video track from now on: a canvas with effects drawn on
@@ -781,7 +1194,8 @@ export class StreamClient {
781
1194
  const paused = current.paused;
782
1195
  try {
783
1196
  const { width = 0, height = 0 } = track.getSettings();
784
- const next = videoEncodings(width, height, this.videoQuality, this.layerCount(), this.frameRateFor(track));
1197
+ const frameRate = this.frameRateFor(track);
1198
+ const next = videoEncodings(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
785
1199
  const previousSize = current.track?.getSettings();
786
1200
  if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
787
1201
  previousSize?.width === width && previousSize?.height === height) {
@@ -974,7 +1388,7 @@ export class StreamClient {
974
1388
  */
975
1389
  async produceCamera(transport, track, generation, paused = !track.enabled) {
976
1390
  if (generation !== this.publishGeneration)
977
- throw new Error(unpublishedWhile('camera'));
1391
+ throw unpublishedWhile('camera');
978
1392
  // mediasoup-client starts a producer paused exactly when its track is
979
1393
  // off. Setting the track to the camera's state first makes the producer
980
1394
  // start in it, and a camera that is off never sends a frame of the new
@@ -985,8 +1399,9 @@ export class StreamClient {
985
1399
  // the device, so the browser sends every layer asked for (see
986
1400
  // videoEncodings).
987
1401
  const { width = 0, height = 0 } = track.getSettings();
988
- const quality = this.videoQuality;
1402
+ // The frame rate first: the size the project allows depends on it.
989
1403
  this.cameraFrameRate = this.frameRateFor(track);
1404
+ const quality = this.cameraQuality(this.cameraFrameRate);
990
1405
  const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
991
1406
  let codecs;
992
1407
  try {
@@ -1036,7 +1451,7 @@ export class StreamClient {
1036
1451
  if (generation !== this.publishGeneration) {
1037
1452
  producer.close();
1038
1453
  await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1039
- throw new Error(unpublishedWhile(what));
1454
+ throw unpublishedWhile(what);
1040
1455
  }
1041
1456
  this.producers.set(producer.id, producer);
1042
1457
  }
@@ -1075,7 +1490,7 @@ export class StreamClient {
1075
1490
  /** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
1076
1491
  reportCodecFallback(message) {
1077
1492
  if (normalizeCodec(this.publishOptions.codec) === 'h264') {
1078
- this.emit('error', new Error(message));
1493
+ this.emit('error', new RpcError('codec-fallback', message));
1079
1494
  }
1080
1495
  else if (!this.codecFallbackLogged) {
1081
1496
  this.codecFallbackLogged = true;
@@ -1133,7 +1548,7 @@ export class StreamClient {
1133
1548
  const live = stream?.getTracks().some((t) => t.readyState === 'live') ?? false;
1134
1549
  if (!live) {
1135
1550
  if (!this.publishConstraints) {
1136
- this.emit('error', new Error('cannot restore the broadcast: the devices are gone'));
1551
+ this.emit('error', new RpcError('devices-gone', 'cannot restore the broadcast: the devices are gone'));
1137
1552
  return;
1138
1553
  }
1139
1554
  const ended = stream;
@@ -1155,13 +1570,15 @@ export class StreamClient {
1155
1570
  }
1156
1571
  if (!stream)
1157
1572
  return;
1158
- await this.publish(stream);
1573
+ await this.publishStream(stream);
1159
1574
  }
1160
1575
  // ------------------------------------------------------------- screen sharing
1161
1576
  /** Publish a screen beside the camera. Call from a click so capture permission can open. */
1162
1577
  async publishScreen(options = {}) {
1163
1578
  if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost')
1164
1579
  throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
1580
+ if (!this.mediaPermissions.video)
1581
+ throw new RpcError('media-disabled', 'video is switched off for this project');
1165
1582
  if (this.screenStream || this.screenPending)
1166
1583
  throw new RpcError('screen-share-active', 'a screen share is already active');
1167
1584
  if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion')
@@ -1182,7 +1599,7 @@ export class StreamClient {
1182
1599
  if (!video || video.readyState !== 'live')
1183
1600
  throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
1184
1601
  if (generation !== this.screenGeneration || this.closedByUs)
1185
- throw new Error(unpublishedWhile('screen'));
1602
+ throw unpublishedWhile('screen');
1186
1603
  this.screenStream = stream;
1187
1604
  this.screenMode = mode;
1188
1605
  this.screenAudioEnabled = options.audio !== false;
@@ -1192,7 +1609,7 @@ export class StreamClient {
1192
1609
  video.addEventListener('ended', onEnded, { once: true });
1193
1610
  await this.restoreScreen();
1194
1611
  if (generation !== this.screenGeneration || video.readyState !== 'live')
1195
- throw new Error(unpublishedWhile('screen'));
1612
+ throw unpublishedWhile('screen');
1196
1613
  this.screenStarted = true;
1197
1614
  this.wasPublishing = true;
1198
1615
  this.emit('screenShareStarted', { stream, mode });
@@ -1256,7 +1673,7 @@ export class StreamClient {
1256
1673
  const { width = 0, height = 0 } = video.getSettings();
1257
1674
  const encodings = screenEncodings(width, height, this.screenMode);
1258
1675
  // A project's explicit safety restriction also applies to screen video.
1259
- const cap = topLayerSize(width, height, this.videoPolicy.maxQuality);
1676
+ const cap = topLayerSize(width, height, this.mediaPermissions.maxVideoQuality);
1260
1677
  const screenScale = encodings.at(-1).scaleResolutionDownBy;
1261
1678
  if (cap.scale > screenScale)
1262
1679
  encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
@@ -1264,7 +1681,7 @@ export class StreamClient {
1264
1681
  if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
1265
1682
  producer.close();
1266
1683
  await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1267
- throw new Error(unpublishedWhile('screen'));
1684
+ throw unpublishedWhile('screen');
1268
1685
  }
1269
1686
  this.producers.set(producer.id, producer);
1270
1687
  this.screenProducerIds.add(producer.id);
@@ -1275,7 +1692,12 @@ export class StreamClient {
1275
1692
  codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
1276
1693
  appData: { source: 'screen', videoLayers: describeVideoLayers(width, height, encodings) },
1277
1694
  }));
1278
- const audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
1695
+ let audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
1696
+ if (audio && this.screenAudioEnabled && !this.mediaPermissions.audio) {
1697
+ // The screen goes out; its sound cannot in a project without audio.
1698
+ this.emit('error', new RpcError('media-disabled', 'audio is switched off for this project; the screen is shared without its sound'));
1699
+ audio = undefined;
1700
+ }
1279
1701
  if (audio && this.screenAudioEnabled && generation === this.screenGeneration)
1280
1702
  await keep(await transport.produce({
1281
1703
  track: audio, stopTracks: false,
@@ -1283,19 +1705,34 @@ export class StreamClient {
1283
1705
  appData: { source: 'screen-audio' },
1284
1706
  }));
1285
1707
  }
1708
+ /**
1709
+ * 60 only when asked for, allowed by the project, captured at 50 or more and
1710
+ * not fallen back from. The project's refusal and the camera's are reported
1711
+ * apart, each once: `frame-rate-not-allowed` is the server's own code for a
1712
+ * 60 fps producer the project does not allow, `frame-rate-fallback` means
1713
+ * the camera or device cannot provide it.
1714
+ */
1286
1715
  frameRateFor(track) {
1287
1716
  if (this.publishOptions.frameRate !== 60 || this.frameRateFallback)
1288
1717
  return 30;
1718
+ if (!this.allows60()) {
1719
+ this.frameRateFallback = true;
1720
+ this.emit('error', new RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
1721
+ return 30;
1722
+ }
1289
1723
  const captured = track.getSettings().frameRate;
1290
- if (this.videoPolicy.maxFrameRate === 30 || (typeof captured === 'number' && captured < 50)) {
1724
+ if (typeof captured === 'number' && captured < 50) {
1291
1725
  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'));
1726
+ this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera; publishing at 30 fps'));
1293
1727
  return 30;
1294
1728
  }
1295
1729
  return 60;
1296
1730
  }
1297
- async sampleCamera(baseline) {
1731
+ async sampleCamera(baseline, isCurrent) {
1732
+ const sampledProducer = this.cameraProducer();
1298
1733
  const stats = await this.collectVideoStats(baseline);
1734
+ if (!isCurrent() || sampledProducer !== this.cameraProducer())
1735
+ return null;
1299
1736
  if (this.cameraFrameRate !== 60 || this.frameRateFallback || !stats)
1300
1737
  return stats;
1301
1738
  if (this.cameraProducer()?.paused)
@@ -1309,12 +1746,12 @@ export class StreamClient {
1309
1746
  const limited = stats.layers.some((layer) => layer.active && (layer.qualityLimitation === 'cpu' || layer.qualityLimitation === 'bandwidth'));
1310
1747
  this.frameRateLowSamples = limited && stats.fps > 0 && stats.fps < 50 ? this.frameRateLowSamples + 1 : 0;
1311
1748
  if (this.frameRateLowSamples >= 3) {
1312
- this.frameRateFallback = true;
1313
1749
  void this.cameraTask(async () => {
1314
1750
  const producer = this.cameraProducer();
1315
- if (!producer?.track || producer.closed)
1751
+ if (!isCurrent() || producer !== sampledProducer || this.frameRateFallback || !producer?.track || producer.closed)
1316
1752
  return;
1317
- await producer.track.applyConstraints?.({ frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
1753
+ this.frameRateFallback = true;
1754
+ await producer.track.applyConstraints?.({ ...producer.track.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
1318
1755
  await this.reproduceCamera(producer, producer.track);
1319
1756
  this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
1320
1757
  }).catch((error) => this.emit('error', error));
@@ -1333,6 +1770,8 @@ export class StreamClient {
1333
1770
  * the user has not interacted with.
1334
1771
  */
1335
1772
  async publishMusic(input, options) {
1773
+ if (!this.mediaPermissions.audio)
1774
+ throw new RpcError('media-disabled', 'audio is switched off for this project');
1336
1775
  const generation = this.publishGeneration;
1337
1776
  if (this.music)
1338
1777
  await this.stopMusic();
@@ -1343,7 +1782,7 @@ export class StreamClient {
1343
1782
  // already closed it, and the music must not be remembered for a
1344
1783
  // reconnect to publish again.
1345
1784
  if (generation !== this.publishGeneration)
1346
- throw new Error(unpublishedWhile('music'));
1785
+ throw unpublishedWhile('music');
1347
1786
  }
1348
1787
  catch (err) {
1349
1788
  music.close();
@@ -1356,7 +1795,7 @@ export class StreamClient {
1356
1795
  async produceMusic(music, generation) {
1357
1796
  const transport = await this.ensureSendTransport();
1358
1797
  if (generation !== this.publishGeneration)
1359
- throw new Error(unpublishedWhile('music'));
1798
+ throw unpublishedWhile('music');
1360
1799
  const producer = await transport.produce({
1361
1800
  track: music.track,
1362
1801
  stopTracks: false,
@@ -1416,13 +1855,49 @@ export class StreamClient {
1416
1855
  }
1417
1856
  async consume(remote) {
1418
1857
  if (!this.device)
1419
- throw new Error('not connected');
1858
+ throw new RpcError('not-connected', 'not connected');
1420
1859
  if (this.consumers.has(remote.producerId))
1421
1860
  return;
1422
1861
  // Your own camera is already local; pulling it back off the SFU wastes a
1423
1862
  // consumer and shows you a delayed copy of yourself.
1424
1863
  if (remote.identity === this.joinResult?.identity)
1425
1864
  return;
1865
+ // A wallet session receives at most media.fanout.maxVideoSubscriptions
1866
+ // videos; the media node refuses the next. Wait for a slot instead of
1867
+ // asking for a refusal, and say so once per video.
1868
+ if (remote.kind === 'video' && !this.videoSlots.has(remote.producerId)) {
1869
+ const limit = this.mediaPermissions.maxVideoSubscriptions;
1870
+ if (limit !== null && this.videoSlots.size >= limit) {
1871
+ if (!this.skippedVideos.has(remote.producerId)) {
1872
+ this.skippedVideos.set(remote.producerId, remote);
1873
+ 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`));
1874
+ }
1875
+ return;
1876
+ }
1877
+ this.videoSlots.add(remote.producerId);
1878
+ }
1879
+ try {
1880
+ await this.consumeNow(remote);
1881
+ }
1882
+ catch (err) {
1883
+ if (!this.consumers.has(remote.producerId))
1884
+ this.videoSlots.delete(remote.producerId);
1885
+ throw err;
1886
+ }
1887
+ }
1888
+ /** The oldest videos left waiting at the fan-out limit take the slots that opened. */
1889
+ async consumeSkipped() {
1890
+ for (const [producerId, remote] of this.skippedVideos) {
1891
+ const limit = this.mediaPermissions.maxVideoSubscriptions;
1892
+ if (limit !== null && this.videoSlots.size >= limit)
1893
+ return;
1894
+ this.skippedVideos.delete(producerId);
1895
+ await this.consume(remote);
1896
+ }
1897
+ }
1898
+ async consumeNow(remote) {
1899
+ if (!this.device)
1900
+ throw new RpcError('not-connected', 'not connected');
1426
1901
  const transport = await this.ensureRecvTransport();
1427
1902
  const params = (await this.rpc('consume', {
1428
1903
  transportId: transport.id,
@@ -1455,6 +1930,7 @@ export class StreamClient {
1455
1930
  const kind = consumer.kind;
1456
1931
  const source = remote.source ?? defaultSource(kind);
1457
1932
  this.remoteSources.set(remote.producerId, source);
1933
+ this.remoteInfo.set(remote.producerId, { identity: remote.identity, kind, paused: Boolean(remote.paused) });
1458
1934
  this.emit('track', {
1459
1935
  identity: remote.identity,
1460
1936
  kind,
@@ -1599,6 +2075,57 @@ export class StreamClient {
1599
2075
  kick(identity, reason) { return this.rpc('kick', { identity, reason }); }
1600
2076
  endRoom() { return this.rpc('endRoom'); }
1601
2077
  getRoomState() { return this.rpc('getRoomState'); }
2078
+ // ------------------------------------------------------- slots and metadata
2079
+ //
2080
+ // A slot is a named thing in the room that at most one participant holds at
2081
+ // a time: a seat on a board, a guest spot. Keys are yours; the server never
2082
+ // learns that `seat-13` is a seat. `info.claims` and `info.blockedSlots`
2083
+ // seed a board on join and follow claimChanged.
2084
+ /**
2085
+ * Take a slot, or (moderators) grant one by naming `identity`. One claim
2086
+ * per identity: a second moves you. Refused with `slot-taken` when someone
2087
+ * holds it (away included) and `slot-blocked` when a moderator locked it.
2088
+ * Claim first, then promote: if the slot has gone, nothing has happened yet.
2089
+ */
2090
+ async claim(key, identity) {
2091
+ await this.rpc('claim', identity ? { key, identity } : { key });
2092
+ }
2093
+ /** Give up a slot. Moderators may release anyone's. */
2094
+ async release(key) {
2095
+ await this.rpc('release', { key });
2096
+ }
2097
+ /**
2098
+ * Lock a slot so nobody may claim it. Moderators only. Refused with
2099
+ * `slot-occupied` while someone holds it: taking a seat from a person using
2100
+ * it is two deliberate acts, not a side effect of one tap.
2101
+ */
2102
+ async blockSlot(key) {
2103
+ await this.rpc('blockSlot', { key });
2104
+ }
2105
+ /** Unlock a slot. Moderators only. */
2106
+ async unblockSlot(key) {
2107
+ await this.rpc('unblockSlot', { key });
2108
+ }
2109
+ /** Replace your own display metadata; the room hears it as participantUpdated. */
2110
+ async setMetadata(metadata) {
2111
+ await this.rpc('setMetadata', { metadata });
2112
+ }
2113
+ /**
2114
+ * Change room-scoped metadata. Moderators only, and persisted, so a client
2115
+ * joining later reads it from `info.metadata`. Merges one key deep by
2116
+ * default (a null value deletes its key); `replace` swaps the whole object.
2117
+ * `requireFree` names slots that must be unheld for the change to apply,
2118
+ * checked and applied together: the refusal (`slot-occupied`) names the
2119
+ * offending keys in `RpcError.data.slots`. Resolves to the metadata now.
2120
+ */
2121
+ async setRoomMetadata(metadata, options = {}) {
2122
+ const res = (await this.rpc('setRoomMetadata', {
2123
+ metadata,
2124
+ ...(options.replace ? { replace: true } : {}),
2125
+ ...(options.requireFree ? { requireFree: options.requireFree } : {}),
2126
+ }));
2127
+ return res?.metadata ?? null;
2128
+ }
1602
2129
  // ------------------------------------------------------------------- chat
1603
2130
  //
1604
2131
  // The room's chat rides this same socket. Someone on a chat-only socket