@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/cjs/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.StreamClient = exports.RpcError = exports.VIDEO_QUALITIES = exports.videoEncodings = exports.layerBitrate = exports.allowedQuality = exports.ChatRpcError = exports.ChatClient = exports.MusicSource = exports.AudioMixer = exports.whipOffer = exports.whipEnd = exports.WhipError = exports.BroadcastPublisher = exports.RecordingError = exports.RecordingUploader = exports.CameraRecording = void 0;
3
+ exports.StreamClient = exports.CLIENT_EVENTS = exports.CONNECTION_STATES = exports.VIDEO_QUALITIES = exports.videoEncodings = exports.layerBitrate = exports.allowedQuality = exports.tokenExpiresAt = exports.TOKEN_EXPIRING_LEAD_MS = exports.networkLevel = exports.NETWORK_QUALITY_LEVELS = exports.NETWORK_QUALITY_INTERVAL_MS = exports.resolvePermissions = exports.DEFAULT_MEDIA_PERMISSIONS = exports.AUDIO_SETTINGS = exports.AUDIO_QUALITIES = exports.isTerminalError = exports.errorInfo = exports.ERROR_CODES = exports.RpcError = exports.ChatRpcError = exports.ChatClient = exports.MusicSource = exports.AudioMixer = exports.whipOffer = exports.whipEnd = exports.WhipError = exports.BroadcastPublisher = exports.RecordingError = exports.RecordingUploader = exports.CameraRecording = void 0;
4
4
  const recording_js_1 = require("./recording.js");
5
5
  /**
6
6
  * Thin client SDK over the orchestrator's signaling protocol.
@@ -10,6 +10,10 @@ const recording_js_1 = require("./recording.js");
10
10
  * setup, consumer resume ordering and reconnection, each slightly wrong.
11
11
  */
12
12
  const mediasoup_client_1 = require("mediasoup-client");
13
+ const errors_js_1 = require("./errors.js");
14
+ const media_permissions_js_1 = require("./media-permissions.js");
15
+ const network_js_1 = require("./network.js");
16
+ const token_js_1 = require("./token.js");
13
17
  const device_js_1 = require("./device.js");
14
18
  const music_js_1 = require("./music.js");
15
19
  const quality_js_1 = require("./quality.js");
@@ -29,41 +33,46 @@ Object.defineProperty(exports, "MusicSource", { enumerable: true, get: function
29
33
  var chat_js_1 = require("./chat.js");
30
34
  Object.defineProperty(exports, "ChatClient", { enumerable: true, get: function () { return chat_js_1.ChatClient; } });
31
35
  Object.defineProperty(exports, "ChatRpcError", { enumerable: true, get: function () { return chat_js_1.ChatRpcError; } });
36
+ var errors_js_2 = require("./errors.js");
37
+ Object.defineProperty(exports, "RpcError", { enumerable: true, get: function () { return errors_js_2.RpcError; } });
38
+ Object.defineProperty(exports, "ERROR_CODES", { enumerable: true, get: function () { return errors_js_2.ERROR_CODES; } });
39
+ Object.defineProperty(exports, "errorInfo", { enumerable: true, get: function () { return errors_js_2.errorInfo; } });
40
+ Object.defineProperty(exports, "isTerminalError", { enumerable: true, get: function () { return errors_js_2.isTerminalError; } });
41
+ var media_permissions_js_2 = require("./media-permissions.js");
42
+ Object.defineProperty(exports, "AUDIO_QUALITIES", { enumerable: true, get: function () { return media_permissions_js_2.AUDIO_QUALITIES; } });
43
+ Object.defineProperty(exports, "AUDIO_SETTINGS", { enumerable: true, get: function () { return media_permissions_js_2.AUDIO_SETTINGS; } });
44
+ Object.defineProperty(exports, "DEFAULT_MEDIA_PERMISSIONS", { enumerable: true, get: function () { return media_permissions_js_2.DEFAULT_MEDIA_PERMISSIONS; } });
45
+ Object.defineProperty(exports, "resolvePermissions", { enumerable: true, get: function () { return media_permissions_js_2.resolvePermissions; } });
46
+ var network_js_2 = require("./network.js");
47
+ Object.defineProperty(exports, "NETWORK_QUALITY_INTERVAL_MS", { enumerable: true, get: function () { return network_js_2.NETWORK_QUALITY_INTERVAL_MS; } });
48
+ Object.defineProperty(exports, "NETWORK_QUALITY_LEVELS", { enumerable: true, get: function () { return network_js_2.NETWORK_QUALITY_LEVELS; } });
49
+ Object.defineProperty(exports, "networkLevel", { enumerable: true, get: function () { return network_js_2.networkLevel; } });
50
+ var token_js_2 = require("./token.js");
51
+ Object.defineProperty(exports, "TOKEN_EXPIRING_LEAD_MS", { enumerable: true, get: function () { return token_js_2.TOKEN_EXPIRING_LEAD_MS; } });
52
+ Object.defineProperty(exports, "tokenExpiresAt", { enumerable: true, get: function () { return token_js_2.tokenExpiresAt; } });
32
53
  var quality_js_2 = require("./quality.js");
33
54
  Object.defineProperty(exports, "allowedQuality", { enumerable: true, get: function () { return quality_js_2.allowedQuality; } });
34
55
  Object.defineProperty(exports, "layerBitrate", { enumerable: true, get: function () { return quality_js_2.layerBitrate; } });
35
56
  Object.defineProperty(exports, "videoEncodings", { enumerable: true, get: function () { return quality_js_2.videoEncodings; } });
36
57
  Object.defineProperty(exports, "VIDEO_QUALITIES", { enumerable: true, get: function () { return quality_js_2.VIDEO_QUALITIES; } });
58
+ /** Where a session stands, in the order a session goes through them. `closed` is final; the `closed` event says why. */
59
+ exports.CONNECTION_STATES = Object.freeze(['idle', 'connecting', 'connected', 'reconnecting', 'closed']);
37
60
  const defaultSource = (kind) => (kind === 'audio' ? 'mic' : 'camera');
38
- /** An error returned by the server, carrying its machine-readable code. */
39
- class RpcError extends Error {
40
- code;
41
- constructor(code, message) {
42
- super(`${code}: ${message}`);
43
- this.code = code;
44
- this.name = 'RpcError';
45
- }
46
- }
47
- exports.RpcError = RpcError;
48
- /**
49
- * Failures that reconnecting cannot fix. A room that has ended is not coming
50
- * back, and a token that is wrong will be wrong next time too -- retrying
51
- * these just floods the log and burns the attempt budget that a genuine
52
- * network blip needs.
53
- */
54
- const TERMINAL_JOIN_ERRORS = new Set([
55
- 'no-room',
56
- 'room-ended',
57
- 'wrong-tenant',
58
- 'bad-token',
59
- 'no-token',
60
- 'forbidden',
61
- // The account cannot use the service until it is topped up or restored.
62
- 'quota-exhausted',
63
- 'daily-limit-reached',
64
- 'monthly-limit-reached',
65
- 'tenant-suspended',
66
- ]);
61
+ // Every event a StreamClient emits. Typed against ClientEvents, so adding an
62
+ // event without listing it here (or listing one that does not exist) fails to
63
+ // compile; the contract check compares this list with docs/sdk-contract.json.
64
+ const EVENT_NAMES = {
65
+ connected: true, disconnected: true, connectionStateChanged: true, joined: true,
66
+ track: true, trackEnded: true, trackMuted: true,
67
+ participantJoined: true, participantLeft: true, participantMuted: true, participantUpdated: true,
68
+ roleChanged: true, handRaised: true, forceMuted: true, claimChanged: true, roomMetadataChanged: true,
69
+ mustReconnect: true, roomClosed: true, kicked: true, streamInterrupted: true, closed: true,
70
+ usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true,
71
+ screenShareStarted: true, screenShareEnded: true, error: true, localMediaChanged: true, localStreamChanged: true,
72
+ message: true, messageDeleted: true, chatMuted: true, typing: true,
73
+ };
74
+ /** The names of every event `StreamClient.on` accepts. */
75
+ exports.CLIENT_EVENTS = Object.freeze(Object.keys(EVENT_NAMES));
67
76
  const RPC_TIMEOUT_MS = 15_000;
68
77
  const MAX_RECONNECT_ATTEMPTS = 8;
69
78
  /** Server close code meaning "another socket took over this identity". */
@@ -78,9 +87,29 @@ const isLayer = (value) => Number.isInteger(value) && value >= 0;
78
87
  */
79
88
  const VIDEO_START_BITRATE_KBPS = 2000;
80
89
  /** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
81
- const unpublishedWhile = (what) => `the ${what} was unpublished while it was being published`;
90
+ const unpublishedWhile = (what) => new errors_js_1.RpcError('unpublished', `the ${what} was unpublished while it was being published`);
91
+ /**
92
+ * mediasoup-client puts the microphone's Opus maxaveragebitrate in the answer
93
+ * SDP only, so the server would never see it. Declare it in the producer's
94
+ * parameters too: the media node bills HD on a declared value above 64,000
95
+ * (spec §4) and refuses one in a project limited to standard audio, so a
96
+ * standard microphone declares 64,000 and an HD one 128,000. Music and screen
97
+ * audio go out as they always did.
98
+ */
99
+ function declareAudioQuality(rtpParameters, appData) {
100
+ const quality = appData.audioQuality;
101
+ if (appData.source !== 'mic' || (quality !== 'std' && quality !== 'hd'))
102
+ return rtpParameters;
103
+ const maxaveragebitrate = media_permissions_js_1.AUDIO_SETTINGS[quality].maxBitrate;
104
+ return {
105
+ ...rtpParameters,
106
+ codecs: rtpParameters.codecs.map((codec) => (codec.mimeType.toLowerCase() === 'audio/opus'
107
+ ? { ...codec, parameters: { ...codec.parameters, maxaveragebitrate } } : codec)),
108
+ };
109
+ }
82
110
  class StreamClient {
83
111
  wsUrl;
112
+ options;
84
113
  ws = null;
85
114
  device = null;
86
115
  sendTransport = null;
@@ -150,10 +179,31 @@ class StreamClient {
150
179
  codecFallbackLogged = false;
151
180
  sendStatsBaseline = new Map();
152
181
  recvStatsBaseline = new Map();
153
- limitWatch = new stats_js_1.VideoLimitWatch((baseline) => this.sampleCamera(baseline), (event) => this.emit('videoLimited', event));
154
- constructor(wsUrl, token) {
182
+ limitWatch = new stats_js_1.VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) => this.emit('videoLimited', event), undefined, () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; });
183
+ /** Where the session stands; see connectionStateChanged. */
184
+ state = 'idle';
185
+ /** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
186
+ tokenExpiry;
187
+ tokenTimer = null;
188
+ /** Whose each received track is and whether it is switched off, by producerId. */
189
+ remoteInfo = new Map();
190
+ /** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
191
+ skippedVideos = new Map();
192
+ /** Producers whose video this client receives, or is about to: the fan-out count. */
193
+ videoSlots = new Set();
194
+ /** The quality the microphone producer was made with. */
195
+ micQuality = 'std';
196
+ // Changes to the microphone producer run one at a time, as the camera's do.
197
+ micQueue = Promise.resolve();
198
+ // A request the project does not allow is reported once, until it is made again.
199
+ audioCapReported = false;
200
+ sizeCapReported = false;
201
+ networkWatch = new network_js_1.NetworkQualityWatch(() => this.transportStats(), () => this.producers.size > 0, (quality) => this.emit('networkQuality', quality));
202
+ constructor(wsUrl, token, options = {}) {
155
203
  this.wsUrl = wsUrl;
204
+ this.options = options;
156
205
  this.token = token;
206
+ this.tokenExpiry = (0, token_js_1.tokenExpiresAt)(token);
157
207
  }
158
208
  on(event, fn) {
159
209
  let set = this.listeners.get(event);
@@ -162,15 +212,45 @@ class StreamClient {
162
212
  this.listeners.set(event, set);
163
213
  }
164
214
  set.add(fn);
165
- // videoLimited costs a getStats every 2 s, so it runs only while heard.
215
+ // videoLimited and networkQuality cost a getStats every few seconds, so
216
+ // they run only while heard.
166
217
  if (event === 'videoLimited')
167
218
  this.syncLimitWatch();
219
+ if (event === 'networkQuality')
220
+ this.syncNetworkWatch();
168
221
  return () => {
169
222
  set.delete(fn);
170
223
  if (event === 'videoLimited')
171
224
  this.syncLimitWatch();
225
+ if (event === 'networkQuality')
226
+ this.syncNetworkWatch();
172
227
  };
173
228
  }
229
+ /** Where the session stands: idle, connecting, connected, reconnecting or closed. */
230
+ get connectionState() {
231
+ return this.state;
232
+ }
233
+ setState(next) {
234
+ if (next === this.state)
235
+ return;
236
+ const previous = this.state;
237
+ this.state = next;
238
+ if (next === 'closed')
239
+ this.stopTimers();
240
+ this.emit('connectionStateChanged', { state: next, previous });
241
+ }
242
+ /** Over for good: the state, then the event that says why. */
243
+ finish(reason) {
244
+ this.closedByUs = true;
245
+ this.setState('closed');
246
+ this.emit('closed', reason);
247
+ }
248
+ stopTimers() {
249
+ if (this.tokenTimer)
250
+ clearTimeout(this.tokenTimer);
251
+ this.tokenTimer = null;
252
+ this.networkWatch.set(false);
253
+ }
174
254
  listenerCount(event) {
175
255
  return this.listeners.get(event)?.size ?? 0;
176
256
  }
@@ -187,6 +267,8 @@ class StreamClient {
187
267
  // ------------------------------------------------------------- connection
188
268
  async connect() {
189
269
  let result;
270
+ if (this.state === 'idle' || this.state === 'closed')
271
+ this.setState('connecting');
190
272
  try {
191
273
  await this.openSocket();
192
274
  result = (await this.rpc('join', { token: this.token }));
@@ -198,8 +280,11 @@ class StreamClient {
198
280
  // A session that never got going: the app has the error. Left open,
199
281
  // the socket would be closed by the server's join deadline instead, and
200
282
  // that close would start reconnecting a session that never began.
201
- if (!this.joinedOnce)
283
+ if (!this.joinedOnce) {
202
284
  this.detachSocket();
285
+ if (this.state === 'connecting')
286
+ this.setState('idle');
287
+ }
203
288
  throw err;
204
289
  }
205
290
  // Joined and set up: the next drop gets every reconnect attempt again.
@@ -208,6 +293,9 @@ class StreamClient {
208
293
  // retried about twice a second, forever.
209
294
  this.reconnectAttempt = 0;
210
295
  this.joinedOnce = true;
296
+ this.skippedVideos.clear();
297
+ this.setState('connected');
298
+ this.scheduleTokenExpiring();
211
299
  // A role or mute can change while the socket is away. The fresh roster
212
300
  // decides what may return; reconnecting never turns a blocked device on.
213
301
  if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
@@ -242,11 +330,17 @@ class StreamClient {
242
330
  if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
243
331
  await this.republish().catch((err) => this.emit('error', err));
244
332
  }
333
+ this.syncNetworkWatch();
245
334
  return result;
246
335
  }
247
- /** A new mediasoup Device for each join. Its own method so tests, which have no WebRTC, can stand one in. */
336
+ /**
337
+ * A new mediasoup Device for each join: the handler named in the options, or
338
+ * the one detected from the browser. Its own method so tests, which have no
339
+ * WebRTC, can stand one in.
340
+ */
248
341
  createDevice() {
249
- return new mediasoup_client_1.Device();
342
+ const { handlerName } = this.options;
343
+ return handlerName ? new mediasoup_client_1.Device({ handlerName }) : new mediasoup_client_1.Device();
250
344
  }
251
345
  openSocket() {
252
346
  // Abandon any previous socket first. Its close handler checks identity
@@ -254,15 +348,13 @@ class StreamClient {
254
348
  // reconnect -- which is what let multiple reconnect chains pile up.
255
349
  this.detachSocket();
256
350
  return new Promise((resolve, reject) => {
257
- const url = new URL(this.wsUrl);
258
- url.searchParams.set('token', this.token);
259
- const ws = new WebSocket(url.toString());
351
+ const ws = new WebSocket((0, token_js_1.socketUrl)(this.wsUrl, this.token));
260
352
  this.ws = ws;
261
353
  const onOpen = () => {
262
354
  this.emit('connected');
263
355
  resolve();
264
356
  };
265
- const onError = () => reject(new Error('websocket failed to open'));
357
+ const onError = () => reject(new errors_js_1.RpcError('connect-failed', 'websocket failed to open'));
266
358
  ws.addEventListener('open', onOpen, { once: true });
267
359
  ws.addEventListener('error', onError, { once: true });
268
360
  ws.addEventListener('message', (ev) => {
@@ -275,33 +367,33 @@ class StreamClient {
275
367
  if (this.ws !== ws)
276
368
  return;
277
369
  this.ws = null;
278
- this.failAllPending(new Error(`socket closed (${ev.code})`));
370
+ this.failAllPending(new errors_js_1.RpcError('not-connected', `socket closed (${ev.code})`));
279
371
  this.emit('disconnected', ev.reason || String(ev.code));
280
372
  if (this.closedByUs)
281
373
  return;
282
374
  if (ev.code === CLOSE_REPLACED) {
283
375
  // Another session for this identity is live. Reconnecting would
284
376
  // displace it, and it would displace us right back, forever.
285
- this.emit('error', new Error('this identity connected from somewhere else'));
286
- this.closedByUs = true;
377
+ this.emit('error', new errors_js_1.RpcError('replaced', 'this identity connected from somewhere else'));
287
378
  // The producers outlive this close until the app calls close(); the
288
379
  // videoLimited sampling must not, or it reports after `closed`.
289
380
  this.limitWatch.reset();
290
- this.emit('closed', 'replaced');
381
+ this.finish('replaced');
291
382
  return;
292
383
  }
293
384
  if (ev.code === CLOSE_ENDED) {
294
385
  // Normally roomClosed has already ended the session; this covers a
295
386
  // close that arrived without it.
296
- this.closedByUs = true;
297
387
  this.limitWatch.reset();
298
- this.emit('closed', 'room-closed');
388
+ this.finish('room-closed');
299
389
  return;
300
390
  }
301
391
  // Before a first successful join the app is still waiting on
302
392
  // connect(), which reports the failure; there is nothing to restore.
303
393
  if (!this.joinedOnce)
304
394
  return;
395
+ this.setState('reconnecting');
396
+ this.networkWatch.set(false);
305
397
  void this.scheduleReconnect();
306
398
  });
307
399
  });
@@ -334,9 +426,8 @@ class StreamClient {
334
426
  while (!this.closedByUs) {
335
427
  this.reconnectAttempt += 1;
336
428
  if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
337
- this.emit('error', new Error(`gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
338
- this.closedByUs = true;
339
- this.emit('closed', 'gave-up');
429
+ this.emit('error', new errors_js_1.RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
430
+ this.finish('gave-up');
340
431
  return;
341
432
  }
342
433
  const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
@@ -355,12 +446,11 @@ class StreamClient {
355
446
  }
356
447
  catch (err) {
357
448
  this.emit('error', err);
358
- // Stop on failures a retry cannot resolve. Hammering a room that has
359
- // ended produces one error per attempt forever and drowns out
360
- // whatever actually went wrong.
361
- if (err instanceof RpcError && TERMINAL_JOIN_ERRORS.has(err.code)) {
362
- this.closedByUs = true;
363
- this.emit('closed', 'gave-up');
449
+ // Stop on failures a retry cannot resolve (the registry's terminal
450
+ // codes). Hammering a room that has ended produces one error per
451
+ // attempt forever and drowns out whatever actually went wrong.
452
+ if (err instanceof errors_js_1.RpcError && (0, errors_js_1.isTerminalError)(err.code)) {
453
+ this.finish('gave-up');
364
454
  return;
365
455
  }
366
456
  }
@@ -387,6 +477,7 @@ class StreamClient {
387
477
  this.music = null;
388
478
  // Nothing is emitted after closing, so not even a clearing videoLimited.
389
479
  this.limitWatch.reset();
480
+ this.skippedVideos.clear();
390
481
  // Producers no longer stop their tracks, so releasing the camera and
391
482
  // microphone is ours to do -- otherwise the device light stays on.
392
483
  this.localStream?.getTracks().forEach((t) => t.stop());
@@ -394,6 +485,7 @@ class StreamClient {
394
485
  const ws = this.ws;
395
486
  this.ws = null;
396
487
  ws?.close(1000, 'client closed');
488
+ this.setState('closed');
397
489
  if (wasOpen)
398
490
  this.emit('closed', 'left');
399
491
  }
@@ -401,6 +493,9 @@ class StreamClient {
401
493
  for (const c of this.consumers.values())
402
494
  c.close();
403
495
  this.consumers.clear();
496
+ this.remoteInfo.clear();
497
+ this.skippedVideos.clear();
498
+ this.videoSlots.clear();
404
499
  for (const p of this.producers.values())
405
500
  p.close();
406
501
  this.producers.clear();
@@ -417,22 +512,137 @@ class StreamClient {
417
512
  this.sendTransportPending = null;
418
513
  this.recvTransportPending = null;
419
514
  this.device = null;
515
+ this.syncNetworkWatch();
420
516
  }
421
- /** Everything currently known about the room, for rendering. */
517
+ /**
518
+ * Everything currently known about the room, for rendering: the join
519
+ * result, kept current -- participants, roles, hands, mutes, display
520
+ * metadata, slots and room metadata follow the events.
521
+ */
422
522
  get info() {
423
523
  return this.joinResult;
424
524
  }
525
+ /** Who is in the room now, this client included; kept current from the roster events. */
526
+ get participants() {
527
+ return [...(this.joinResult?.participants ?? [])];
528
+ }
529
+ /** Every track this client is receiving now. */
530
+ get remoteTracks() {
531
+ const out = [];
532
+ for (const [producerId, consumer] of this.consumers) {
533
+ const info = this.remoteInfo.get(producerId);
534
+ if (!info || consumer.closed)
535
+ continue;
536
+ out.push({
537
+ producerId, identity: info.identity, kind: info.kind, track: consumer.track,
538
+ source: this.remoteSources.get(producerId) ?? defaultSource(info.kind), paused: info.paused,
539
+ });
540
+ }
541
+ return out;
542
+ }
543
+ /**
544
+ * What this project lets this client publish: from the join result's
545
+ * `media` (servers with wallet billing), else its `video` policy, else
546
+ * everything before a join. Offer only these in your UI; the SDK refuses
547
+ * or caps anything else with the server's own codes.
548
+ */
549
+ get mediaPermissions() {
550
+ return (0, media_permissions_js_1.resolvePermissions)(this.joinResult);
551
+ }
552
+ /** How this session is billed: `legacy` (minutes) or `wallet`; null before a join or on older servers. */
553
+ get billingMode() {
554
+ return this.joinResult?.billing?.mode ?? null;
555
+ }
556
+ // ------------------------------------------------------------ token refresh
557
+ /**
558
+ * Hand the session a newer room token, minted by your backend for the same
559
+ * room and identity (`POST /v1/rooms/{roomId}/tokens`), typically on
560
+ * `tokenExpiring`. On a live connection the server checks it like a join
561
+ * token (`bad-token`, or `token-mismatch` for another room or identity; the
562
+ * session then keeps its token); otherwise it is kept for the next join,
563
+ * which checks it there. Resolves to when it expires (unix ms), when known.
564
+ */
565
+ async updateToken(token) {
566
+ if (typeof token !== 'string' || token.trim() === '')
567
+ throw new TypeError('updateToken needs a token');
568
+ if (this.state === 'closed')
569
+ throw new errors_js_1.RpcError('not-connected', 'this session is closed; create a new client');
570
+ if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
571
+ try {
572
+ const res = (await this.rpc('refreshToken', { token }));
573
+ const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
574
+ this.setToken(token, at);
575
+ return { expiresAt: this.tokenExpiry };
576
+ }
577
+ catch (err) {
578
+ // An older server has no refreshToken, and a socket that dropped
579
+ // during the call is reconnecting: either way keep the token for the
580
+ // next join, which checks it.
581
+ if (!(err instanceof errors_js_1.RpcError) || (err.code !== 'unknown-method' && err.code !== 'not-connected'))
582
+ throw err;
583
+ }
584
+ }
585
+ this.setToken(token, null);
586
+ return { expiresAt: this.tokenExpiry };
587
+ }
588
+ /** The token reconnects use from now on, with its expiry (read from the token when not given). */
589
+ setToken(token, expiresAt) {
590
+ this.token = token;
591
+ this.tokenExpiry = expiresAt ?? (0, token_js_1.tokenExpiresAt)(token);
592
+ this.scheduleTokenExpiring();
593
+ }
594
+ /**
595
+ * Warn TOKEN_EXPIRING_LEAD_MS before the token held for reconnects
596
+ * expires. Every new token -- the server's refresh, a promotion's, the
597
+ * app's -- reschedules it, so it fires only when nobody refreshed it.
598
+ */
599
+ scheduleTokenExpiring() {
600
+ if (this.tokenTimer)
601
+ clearTimeout(this.tokenTimer);
602
+ this.tokenTimer = null;
603
+ const expiresAt = this.tokenExpiry;
604
+ if (expiresAt === null || this.state === 'idle' || this.state === 'closed')
605
+ return;
606
+ // setTimeout cannot wait longer than about 24.8 days.
607
+ const delay = Math.min(Math.max(0, expiresAt - token_js_1.TOKEN_EXPIRING_LEAD_MS - Date.now()), 2_147_000_000);
608
+ this.tokenTimer = setTimeout(() => {
609
+ this.tokenTimer = null;
610
+ if (this.state === 'closed' || this.tokenExpiry !== expiresAt)
611
+ return;
612
+ if (expiresAt - token_js_1.TOKEN_EXPIRING_LEAD_MS > Date.now()) {
613
+ this.scheduleTokenExpiring(); // woke early from a clamped wait
614
+ return;
615
+ }
616
+ this.emit('tokenExpiring', { expiresAt, secondsLeft: Math.max(0, Math.round((expiresAt - Date.now()) / 1000)) });
617
+ }, delay);
618
+ }
619
+ // ------------------------------------------------------------ network quality
620
+ /** networkQuality sampling runs while someone listens and the session is connected. */
621
+ syncNetworkWatch() {
622
+ this.networkWatch.set(!this.closedByUs && this.state === 'connected' && this.listenerCount('networkQuality') > 0);
623
+ }
624
+ /** Each live media transport's statistics, keyed by transport so a new one starts fresh. */
625
+ async transportStats() {
626
+ const out = new Map();
627
+ for (const transport of [this.sendTransport, this.recvTransport]) {
628
+ if (!transport || transport.closed)
629
+ continue;
630
+ const report = await transport.getStats();
631
+ out.set(transport.id, report.values());
632
+ }
633
+ return out;
634
+ }
425
635
  // -------------------------------------------------------------------- rpc
426
636
  rpc(method, params = {}) {
427
637
  return new Promise((resolve, reject) => {
428
638
  if (this.ws?.readyState !== WebSocket.OPEN) {
429
- reject(new Error('socket is not open'));
639
+ reject(new errors_js_1.RpcError('not-connected', 'socket is not open'));
430
640
  return;
431
641
  }
432
642
  const id = this.nextId++;
433
643
  const timer = setTimeout(() => {
434
644
  this.pending.delete(id);
435
- reject(new Error(`${method} timed out`));
645
+ reject(new errors_js_1.RpcError('timeout', `${method} timed out`));
436
646
  }, RPC_TIMEOUT_MS);
437
647
  this.pending.set(id, { resolve, reject, timer });
438
648
  this.ws.send(JSON.stringify({ id, method, params }));
@@ -463,7 +673,7 @@ class StreamClient {
463
673
  p.resolve(msg.result);
464
674
  else {
465
675
  const e = msg.error;
466
- p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown'));
676
+ p.reject(new errors_js_1.RpcError(e?.code ?? 'error', e?.message ?? 'unknown', e?.data));
467
677
  }
468
678
  return;
469
679
  }
@@ -481,17 +691,27 @@ class StreamClient {
481
691
  this.preferredLayers.delete(producerId);
482
692
  this.receiveQualities.delete(producerId);
483
693
  this.remoteVideoLayers.delete(producerId);
694
+ this.skippedVideos.delete(producerId);
484
695
  const consumer = this.consumers.get(producerId);
485
696
  if (!consumer)
486
697
  break; // never consumed it, or already cleaned up
698
+ this.videoSlots.delete(producerId);
487
699
  consumer.close();
488
700
  this.consumers.delete(producerId);
489
701
  this.remoteSources.delete(producerId);
702
+ this.remoteInfo.delete(producerId);
490
703
  this.emit('trackEnded', { producerId, identity });
704
+ // A video slot opened under the fan-out limit: the oldest video left
705
+ // waiting takes it.
706
+ if (consumer.kind === 'video')
707
+ void this.consumeSkipped().catch((err) => this.emit('error', err));
491
708
  break;
492
709
  }
493
710
  case 'producerPaused': {
494
711
  const p = data;
712
+ const info = this.remoteInfo.get(p.producerId);
713
+ if (info)
714
+ info.paused = Boolean(p.paused);
495
715
  this.emit('trackMuted', {
496
716
  identity: p.identity,
497
717
  kind: p.kind,
@@ -501,27 +721,40 @@ class StreamClient {
501
721
  });
502
722
  break;
503
723
  }
504
- case 'participantJoined':
505
- this.emit('participantJoined', data);
724
+ case 'participantJoined': {
725
+ const p = data;
726
+ // A reconnecting participant joins again: replace, never duplicate.
727
+ this.updateRoster((list) => [...list.filter((q) => q.identity !== p.identity), p]);
728
+ this.emit('participantJoined', p);
506
729
  break;
730
+ }
507
731
  case 'participantMuted': {
508
732
  const wire = data;
509
733
  const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
510
- if (this.joinResult)
511
- this.joinResult.participants = this.joinResult.participants.map((p) => p.identity !== e.identity ? p : { ...p,
512
- ...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
513
- ...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
514
- });
734
+ this.updateRoster((list) => list.map((p) => p.identity !== e.identity ? p : { ...p,
735
+ ...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
736
+ ...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
737
+ }));
515
738
  this.emit('participantMuted', e);
516
739
  break;
517
740
  }
518
- case 'participantLeft':
519
- this.emit('participantLeft', data);
741
+ case 'participantUpdated': {
742
+ const e = data;
743
+ this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, metadata: e.metadata })));
744
+ this.emit('participantUpdated', { identity: e.identity, metadata: e.metadata ?? null });
745
+ break;
746
+ }
747
+ case 'participantLeft': {
748
+ const e = data;
749
+ this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
750
+ this.emit('participantLeft', e);
520
751
  break;
752
+ }
521
753
  case 'roleChanged': {
522
754
  const e = data;
523
755
  if (e.identity === this.joinResult?.identity)
524
756
  this.joinResult.role = e.role;
757
+ this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, role: e.role })));
525
758
  this.emit('roleChanged', e);
526
759
  // Demoted: the server has already closed our producers. Release the
527
760
  // camera and microphone too, instead of capturing for nobody.
@@ -532,9 +765,13 @@ class StreamClient {
532
765
  break;
533
766
  }
534
767
  case 'handRaised':
535
- case 'handLowered':
768
+ case 'handLowered': {
769
+ const e = data;
770
+ this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p
771
+ : { ...p, handRaised: Boolean(e.handRaised), ...(e.handRaised ? { handMetadata: e.metadata } : { handMetadata: undefined }) })));
536
772
  this.emit('handRaised', data);
537
773
  break;
774
+ }
538
775
  case 'forceMuted': {
539
776
  // The server has already paused that kind (an audio mute includes
540
777
  // in-app music: it travels as audio). Reflect it locally; a release
@@ -547,20 +784,33 @@ class StreamClient {
547
784
  this.emit('forceMuted', e);
548
785
  break;
549
786
  }
787
+ case 'claimChanged': {
788
+ const e = data;
789
+ this.applyClaim(e);
790
+ this.emit('claimChanged', { key: e.key, identity: e.identity || null, state: e.state, reason: e.reason ?? '' });
791
+ break;
792
+ }
793
+ case 'roomMetadataChanged': {
794
+ const e = data;
795
+ if (this.joinResult)
796
+ this.joinResult.metadata = e.metadata;
797
+ this.emit('roomMetadataChanged', { metadata: e.metadata ?? null, ...(e.by ? { by: e.by } : {}) });
798
+ break;
799
+ }
550
800
  case 'tokenRefreshed': {
551
801
  // A room outlives its join token many times over. The server hands us a
552
802
  // fresh one periodically so a reconnect at any point still works --
553
803
  // without this, any disconnect after the token's lifetime is terminal.
554
804
  const e = data;
555
805
  if (e.token)
556
- this.token = e.token;
806
+ this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
557
807
  break;
558
808
  }
559
809
  case 'mustReconnect': {
560
810
  const e = data;
561
811
  // A promotion hands us a fresh token carrying the new role.
562
812
  if (e.token)
563
- this.token = e.token;
813
+ this.setToken(e.token, null);
564
814
  this.emit('mustReconnect', e);
565
815
  if (e.retry) {
566
816
  this.teardownMedia();
@@ -576,16 +826,22 @@ class StreamClient {
576
826
  case 'usageLimit':
577
827
  this.emit('usageLimit', d);
578
828
  break;
829
+ case 'walletLow': {
830
+ const e = data;
831
+ const remainingSeconds = typeof e?.remainingSeconds === 'number' && Number.isFinite(e.remainingSeconds) ? e.remainingSeconds : 0;
832
+ this.emit('walletLow', e?.cleared === true ? { remainingSeconds, cleared: true } : { remainingSeconds });
833
+ break;
834
+ }
579
835
  case 'roomClosed':
580
836
  this.closedByUs = true;
581
837
  this.emit('roomClosed', d);
582
- this.emit('closed', 'room-closed');
838
+ this.finish('room-closed');
583
839
  this.close();
584
840
  break;
585
841
  case 'kicked':
586
842
  this.closedByUs = true;
587
843
  this.emit('kicked', d);
588
- this.emit('closed', 'kicked');
844
+ this.finish('kicked');
589
845
  this.close();
590
846
  break;
591
847
  case 'message':
@@ -604,6 +860,25 @@ class StreamClient {
604
860
  break;
605
861
  }
606
862
  }
863
+ /** Change the roster the join result holds; a new array each time, so a renderer sees the change. */
864
+ updateRoster(change) {
865
+ if (this.joinResult)
866
+ this.joinResult.participants = change(this.joinResult.participants ?? []);
867
+ }
868
+ /** Keep the join result's slots current: claims held or away, and the moderator's locks. */
869
+ applyClaim(e) {
870
+ const join = this.joinResult;
871
+ if (!join)
872
+ return;
873
+ const claims = (join.claims ?? []).filter((c) => c.key !== e.key);
874
+ let blocked = (join.blockedSlots ?? []).filter((key) => key !== e.key);
875
+ if ((e.state === 'held' || e.state === 'away') && e.identity)
876
+ claims.push({ key: e.key, identity: e.identity, state: e.state });
877
+ if (e.state === 'blocked')
878
+ blocked = [...blocked, e.key];
879
+ join.claims = claims;
880
+ join.blockedSlots = blocked;
881
+ }
607
882
  // ------------------------------------------------------------------ media
608
883
  ensureSendTransport() {
609
884
  if (this.sendTransport)
@@ -617,7 +892,7 @@ class StreamClient {
617
892
  }
618
893
  async createSendTransport() {
619
894
  if (!this.device)
620
- throw new Error('not connected');
895
+ throw new errors_js_1.RpcError('not-connected', 'not connected');
621
896
  const params = (await this.rpc('createTransport', { direction: 'send' }));
622
897
  const transport = this.device.createSendTransport({
623
898
  ...params,
@@ -629,7 +904,10 @@ class StreamClient {
629
904
  .catch(fail);
630
905
  });
631
906
  transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
632
- this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source, videoLayers: appData.videoLayers })
907
+ this.rpc('produce', {
908
+ transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
909
+ appData, source: appData.source, videoLayers: appData.videoLayers,
910
+ })
633
911
  .then((res) => ok({ id: res.producerId }))
634
912
  .catch(fail);
635
913
  });
@@ -648,7 +926,7 @@ class StreamClient {
648
926
  }
649
927
  async createRecvTransport() {
650
928
  if (!this.device)
651
- throw new Error('not connected');
929
+ throw new errors_js_1.RpcError('not-connected', 'not connected');
652
930
  const params = (await this.rpc('createTransport', { direction: 'recv' }));
653
931
  const transport = this.device.createRecvTransport({
654
932
  ...params,
@@ -675,12 +953,24 @@ class StreamClient {
675
953
  * kept for a reconnect's republish.
676
954
  */
677
955
  async publish(stream, constraints, options) {
956
+ // A kind the project switched off is refused here, before anything is
957
+ // produced, with the code the controller would answer with.
958
+ const permissions = this.mediaPermissions;
959
+ if (!permissions.audio && stream.getAudioTracks().length > 0) {
960
+ throw new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project');
961
+ }
962
+ if (!permissions.video && stream.getVideoTracks().length > 0) {
963
+ throw new errors_js_1.RpcError('media-disabled', 'video is switched off for this project');
964
+ }
965
+ await this.publishStream(stream, constraints, options);
966
+ }
967
+ async publishStream(stream, constraints, options) {
678
968
  const generation = this.publishGeneration;
679
969
  const transport = await this.ensureSendTransport();
680
970
  // unpublish() ran first: this publish is over before it began, and must
681
971
  // leave nothing behind that a reconnect would publish again.
682
972
  if (generation !== this.publishGeneration)
683
- throw new Error(unpublishedWhile('stream'));
973
+ throw unpublishedWhile('stream');
684
974
  this.localStream = stream;
685
975
  this.wasPublishing = true;
686
976
  if (constraints)
@@ -689,35 +979,140 @@ class StreamClient {
689
979
  this.publishOptions = { ...options };
690
980
  this.frameRateFallback = false;
691
981
  this.frameRateLowSamples = 0;
982
+ this.audioCapReported = false;
983
+ this.sizeCapReported = false;
692
984
  }
985
+ // Only a reconnect's republish gets here with a kind switched off since:
986
+ // what is still allowed goes out, and the rest is reported.
987
+ const permissions = this.mediaPermissions;
693
988
  const audio = stream.getAudioTracks()[0];
694
- if (audio) {
695
- const producer = await transport.produce({
696
- track: audio,
697
- // Keep the track alive when the producer closes: a reconnect closes
698
- // every producer, and a stopped track cannot be republished.
699
- stopTracks: false,
700
- codecOptions: { opusStereo: true, opusDtx: true, opusFec: true },
701
- appData: { source: 'mic' },
702
- });
703
- await this.keepProducer(producer, generation, 'microphone');
704
- // A microphone handed over switched off, or muted before a reconnect.
705
- await this.pauseOnServer(producer);
989
+ if (audio && !permissions.audio) {
990
+ this.emit('error', new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project; the microphone is not published'));
991
+ }
992
+ else if (audio) {
993
+ await this.micTask(() => this.produceMic(transport, this.localStream?.getAudioTracks()[0] ?? audio, generation));
706
994
  }
707
995
  const video = stream.getVideoTracks()[0];
708
- if (video) {
996
+ if (video && !permissions.video) {
997
+ this.emit('error', new errors_js_1.RpcError('media-disabled', 'video is switched off for this project; the camera is not published'));
998
+ }
999
+ else if (video) {
709
1000
  // The track is read when the camera's turn comes: one handed to
710
1001
  // replaceVideoTrack while this publish was under way is the one to send.
711
1002
  await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
712
1003
  }
713
1004
  }
1005
+ // ------------------------------------------------------------ audio quality
1006
+ /**
1007
+ * The microphone's audio quality: what it is being sent at while
1008
+ * published, else what the next publish will use, after the project's limit.
1009
+ */
1010
+ get audioQuality() {
1011
+ if (this.micProducer())
1012
+ return this.micQuality;
1013
+ return this.publishOptions.audioQuality === 'hd' && this.mediaPermissions.audioQualityMax === 'hd' ? 'hd' : 'std';
1014
+ }
1015
+ /**
1016
+ * Switch the microphone between standard (mono, up to 64 kbps) and HD
1017
+ * (stereo, up to 128 kbps) while live. The codec settings are fixed per
1018
+ * producer, so the microphone's producer is replaced: listeners hear a gap
1019
+ * of a fraction of a second, and a microphone that is off stays off. Rejects
1020
+ * with `audio-quality-not-allowed` when the project allows standard audio
1021
+ * only. Resolves to the quality in use.
1022
+ */
1023
+ async setAudioQuality(quality) {
1024
+ if (quality !== 'std' && quality !== 'hd')
1025
+ throw new RangeError("setAudioQuality: expected 'std' or 'hd'");
1026
+ if (quality === 'hd' && this.mediaPermissions.audioQualityMax !== 'hd') {
1027
+ throw new errors_js_1.RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project');
1028
+ }
1029
+ this.publishOptions = { ...this.publishOptions, audioQuality: quality };
1030
+ this.audioCapReported = false;
1031
+ await this.micTask(async () => {
1032
+ const current = this.micProducer();
1033
+ const track = current?.track;
1034
+ if (!current || !track || track.readyState !== 'live' || this.micQuality === quality)
1035
+ return;
1036
+ await this.reproduceMic(current, track);
1037
+ });
1038
+ return this.audioQuality;
1039
+ }
1040
+ micProducer() {
1041
+ for (const p of this.producers.values()) {
1042
+ if (p.kind === 'audio' && p.appData.source === 'mic')
1043
+ return p;
1044
+ }
1045
+ return undefined;
1046
+ }
1047
+ micTask(task) {
1048
+ const run = this.micQueue.then(task, task);
1049
+ this.micQueue = run.catch(() => undefined);
1050
+ return run;
1051
+ }
1052
+ /** The audio quality to send: the request, capped to the project's, with the cap reported once. */
1053
+ audioQualityFor() {
1054
+ if (this.publishOptions.audioQuality !== 'hd')
1055
+ return 'std';
1056
+ if (this.mediaPermissions.audioQualityMax === 'hd')
1057
+ return 'hd';
1058
+ if (!this.audioCapReported) {
1059
+ this.audioCapReported = true;
1060
+ this.emit('error', new errors_js_1.RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project; publishing standard audio'));
1061
+ }
1062
+ return 'std';
1063
+ }
1064
+ /**
1065
+ * Produce the microphone at the audio quality in force. A track handed over
1066
+ * switched off (or paused before a reconnect or a quality change) starts
1067
+ * paused, on both ends.
1068
+ */
1069
+ async produceMic(transport, track, generation) {
1070
+ if (generation !== this.publishGeneration)
1071
+ throw unpublishedWhile('microphone');
1072
+ const quality = this.audioQualityFor();
1073
+ const settings = media_permissions_js_1.AUDIO_SETTINGS[quality];
1074
+ const producer = await transport.produce({
1075
+ track,
1076
+ // Keep the track alive when the producer closes: a reconnect closes
1077
+ // every producer, and a stopped track cannot be republished.
1078
+ stopTracks: false,
1079
+ // The answer's maxaveragebitrate sets the encoder's rate and the
1080
+ // sender's maxBitrate holds it there: standard must stay at or under
1081
+ // 64 kbps, which is what the server measures to bill it, so its sender
1082
+ // is held a little under the declared rate (Opus VBR overshoots).
1083
+ encodings: [{ maxBitrate: settings.senderMaxBitrate }],
1084
+ codecOptions: { opusStereo: settings.stereo, opusDtx: true, opusFec: true, opusMaxAverageBitrate: settings.maxBitrate },
1085
+ appData: { source: 'mic', audioQuality: quality },
1086
+ });
1087
+ await this.keepProducer(producer, generation, 'microphone');
1088
+ this.micQuality = quality;
1089
+ // A microphone handed over switched off, or muted before a reconnect.
1090
+ await this.pauseOnServer(producer);
1091
+ return producer;
1092
+ }
1093
+ /** Close the microphone producer and produce its track again at the quality now in force. */
1094
+ async reproduceMic(current, track) {
1095
+ const generation = this.publishGeneration;
1096
+ current.close();
1097
+ this.producers.delete(current.id);
1098
+ // The server allows one producer per source: the old one goes first.
1099
+ await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
1100
+ return this.produceMic(await this.ensureSendTransport(), track, generation);
1101
+ }
714
1102
  /** What this project may publish. Known once joined; 1080p before that. */
715
1103
  get videoPolicy() {
716
1104
  return this.joinResult?.video ?? quality_js_1.DEFAULT_VIDEO_POLICY;
717
1105
  }
718
- /** The quality video is being sent at, after the project's limit. */
1106
+ /**
1107
+ * The quality video is being sent at, after the project's limit: at 60 fps
1108
+ * the largest size the project allows at 60, otherwise its 30 fps maximum.
1109
+ */
719
1110
  get videoQuality() {
720
- return (0, quality_js_1.allowedQuality)(this.publishOptions.quality, this.videoPolicy);
1111
+ return (0, media_permissions_js_1.qualityAt)(this.publishOptions.quality, this.videoFrameRate, this.mediaPermissions);
1112
+ }
1113
+ /** The camera's frame rate: what it is being sent at while published, else 30 until a publish decides. */
1114
+ get videoFrameRate() {
1115
+ return this.cameraProducer() ? this.cameraFrameRate : 30;
721
1116
  }
722
1117
  /**
723
1118
  * Change the video quality while publishing. The camera's producer is
@@ -727,6 +1122,7 @@ class StreamClient {
727
1122
  */
728
1123
  async setVideoQuality(quality) {
729
1124
  this.publishOptions = { ...this.publishOptions, quality };
1125
+ this.sizeCapReported = false;
730
1126
  await this.cameraTask(async () => {
731
1127
  const current = this.cameraProducer();
732
1128
  const track = current?.track;
@@ -736,25 +1132,54 @@ class StreamClient {
736
1132
  });
737
1133
  return this.videoQuality;
738
1134
  }
739
- /** Change the camera frame rate without stopping screen, microphone or music. */
1135
+ /**
1136
+ * Change the camera frame rate without stopping screen, microphone or music.
1137
+ * 60 where the project allows none stays at 30 and warns on `error` with
1138
+ * `frame-rate-not-allowed`, without touching the camera; a camera or device
1139
+ * that cannot keep 60 up falls back with `frame-rate-fallback`. Resolves to
1140
+ * the frame rate used.
1141
+ */
740
1142
  async setVideoFrameRate(frameRate) {
741
1143
  if (frameRate !== 30 && frameRate !== 60)
742
1144
  throw new RangeError('setVideoFrameRate: expected 30 or 60');
743
1145
  this.publishOptions = { ...this.publishOptions, frameRate };
744
1146
  this.frameRateFallback = false;
745
1147
  this.frameRateLowSamples = 0;
1148
+ this.sizeCapReported = false;
1149
+ if (frameRate === 60 && !this.allows60()) {
1150
+ this.frameRateFallback = true;
1151
+ this.emit('error', new errors_js_1.RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
1152
+ return this.videoFrameRate;
1153
+ }
746
1154
  await this.cameraTask(async () => {
747
1155
  const current = this.cameraProducer();
748
1156
  const track = current?.track;
749
1157
  if (!current || !track || track.readyState !== 'live')
750
1158
  return;
751
1159
  try {
752
- await track.applyConstraints?.({ frameRate: { ideal: frameRate, max: frameRate } });
1160
+ await track.applyConstraints?.({ ...track.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } });
753
1161
  }
754
1162
  catch { /* Existing capture settings decide whether 60 fps is possible. */ }
755
1163
  await this.reproduceCamera(current, track);
756
1164
  });
757
- return this.cameraProducer() ? this.cameraFrameRate : this.videoPolicy.maxFrameRate === 30 ? 30 : frameRate;
1165
+ return this.cameraProducer() ? this.cameraFrameRate : this.allows60() ? frameRate : 30;
1166
+ }
1167
+ /** Whether this project allows any 60 fps mode. */
1168
+ allows60() {
1169
+ const permissions = this.mediaPermissions;
1170
+ return permissions.maxFrameRate === 60 && permissions.maxVideoQuality60 !== null;
1171
+ }
1172
+ /**
1173
+ * The quality to send at a frame rate. A request above what 60 fps allows
1174
+ * is sent at the largest size it does allow, and said so once.
1175
+ */
1176
+ cameraQuality(frameRate) {
1177
+ const permissions = this.mediaPermissions;
1178
+ if (frameRate === 60 && !this.sizeCapReported && (0, media_permissions_js_1.above60fpsCap)(this.publishOptions.quality, permissions)) {
1179
+ this.sizeCapReported = true;
1180
+ this.emit('error', new errors_js_1.RpcError('video-quality-not-allowed', `${this.publishOptions.quality} at 60 fps is not enabled for this project; publishing ${permissions.maxVideoQuality60} at 60 fps`));
1181
+ }
1182
+ return (0, media_permissions_js_1.qualityAt)(this.publishOptions.quality, frameRate, permissions);
758
1183
  }
759
1184
  /**
760
1185
  * Send a different video track from now on: a canvas with effects drawn on
@@ -800,7 +1225,8 @@ class StreamClient {
800
1225
  const paused = current.paused;
801
1226
  try {
802
1227
  const { width = 0, height = 0 } = track.getSettings();
803
- const next = (0, quality_js_1.videoEncodings)(width, height, this.videoQuality, this.layerCount(), this.frameRateFor(track));
1228
+ const frameRate = this.frameRateFor(track);
1229
+ const next = (0, quality_js_1.videoEncodings)(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
804
1230
  const previousSize = current.track?.getSettings();
805
1231
  if (this.cameraEncodings && (0, quality_js_1.sameLayout)(this.cameraEncodings, next) &&
806
1232
  previousSize?.width === width && previousSize?.height === height) {
@@ -942,7 +1368,7 @@ class StreamClient {
942
1368
  layers = { spatialLayer: (0, quality_js_1.receiveSpatialLayer)(this.remoteVideoLayers.get(producerId) ?? [], quality) };
943
1369
  }
944
1370
  catch {
945
- throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto');
1371
+ throw new errors_js_1.RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto');
946
1372
  }
947
1373
  }
948
1374
  this.receiveQualities.set(producerId, quality);
@@ -993,7 +1419,7 @@ class StreamClient {
993
1419
  */
994
1420
  async produceCamera(transport, track, generation, paused = !track.enabled) {
995
1421
  if (generation !== this.publishGeneration)
996
- throw new Error(unpublishedWhile('camera'));
1422
+ throw unpublishedWhile('camera');
997
1423
  // mediasoup-client starts a producer paused exactly when its track is
998
1424
  // off. Setting the track to the camera's state first makes the producer
999
1425
  // start in it, and a camera that is off never sends a frame of the new
@@ -1004,8 +1430,9 @@ class StreamClient {
1004
1430
  // the device, so the browser sends every layer asked for (see
1005
1431
  // videoEncodings).
1006
1432
  const { width = 0, height = 0 } = track.getSettings();
1007
- const quality = this.videoQuality;
1433
+ // The frame rate first: the size the project allows depends on it.
1008
1434
  this.cameraFrameRate = this.frameRateFor(track);
1435
+ const quality = this.cameraQuality(this.cameraFrameRate);
1009
1436
  const encodings = (0, quality_js_1.videoEncodings)(width, height, quality, this.layerCount(), this.cameraFrameRate);
1010
1437
  let codecs;
1011
1438
  try {
@@ -1055,7 +1482,7 @@ class StreamClient {
1055
1482
  if (generation !== this.publishGeneration) {
1056
1483
  producer.close();
1057
1484
  await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1058
- throw new Error(unpublishedWhile(what));
1485
+ throw unpublishedWhile(what);
1059
1486
  }
1060
1487
  this.producers.set(producer.id, producer);
1061
1488
  }
@@ -1094,7 +1521,7 @@ class StreamClient {
1094
1521
  /** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
1095
1522
  reportCodecFallback(message) {
1096
1523
  if ((0, device_js_1.normalizeCodec)(this.publishOptions.codec) === 'h264') {
1097
- this.emit('error', new Error(message));
1524
+ this.emit('error', new errors_js_1.RpcError('codec-fallback', message));
1098
1525
  }
1099
1526
  else if (!this.codecFallbackLogged) {
1100
1527
  this.codecFallbackLogged = true;
@@ -1152,7 +1579,7 @@ class StreamClient {
1152
1579
  const live = stream?.getTracks().some((t) => t.readyState === 'live') ?? false;
1153
1580
  if (!live) {
1154
1581
  if (!this.publishConstraints) {
1155
- this.emit('error', new Error('cannot restore the broadcast: the devices are gone'));
1582
+ this.emit('error', new errors_js_1.RpcError('devices-gone', 'cannot restore the broadcast: the devices are gone'));
1156
1583
  return;
1157
1584
  }
1158
1585
  const ended = stream;
@@ -1174,20 +1601,22 @@ class StreamClient {
1174
1601
  }
1175
1602
  if (!stream)
1176
1603
  return;
1177
- await this.publish(stream);
1604
+ await this.publishStream(stream);
1178
1605
  }
1179
1606
  // ------------------------------------------------------------- screen sharing
1180
1607
  /** Publish a screen beside the camera. Call from a click so capture permission can open. */
1181
1608
  async publishScreen(options = {}) {
1182
1609
  if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost')
1183
- throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
1610
+ throw new errors_js_1.RpcError('forbidden', 'only hosts and cohosts can share a screen');
1611
+ if (!this.mediaPermissions.video)
1612
+ throw new errors_js_1.RpcError('media-disabled', 'video is switched off for this project');
1184
1613
  if (this.screenStream || this.screenPending)
1185
- throw new RpcError('screen-share-active', 'a screen share is already active');
1614
+ throw new errors_js_1.RpcError('screen-share-active', 'a screen share is already active');
1186
1615
  if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion')
1187
1616
  throw new TypeError('publishScreen: mode must be detail or motion');
1188
1617
  const mode = options.mode ?? 'detail';
1189
1618
  if (!options.stream && (typeof navigator === 'undefined' || !navigator.mediaDevices?.getDisplayMedia)) {
1190
- throw new RpcError('screen-share-unsupported', 'screen capture is not supported by this browser; use a supported desktop browser');
1619
+ throw new errors_js_1.RpcError('screen-share-unsupported', 'screen capture is not supported by this browser; use a supported desktop browser');
1191
1620
  }
1192
1621
  const generation = this.screenGeneration;
1193
1622
  this.screenPending = true;
@@ -1199,9 +1628,9 @@ class StreamClient {
1199
1628
  });
1200
1629
  const video = stream.getVideoTracks()[0];
1201
1630
  if (!video || video.readyState !== 'live')
1202
- throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
1631
+ throw new errors_js_1.RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
1203
1632
  if (generation !== this.screenGeneration || this.closedByUs)
1204
- throw new Error(unpublishedWhile('screen'));
1633
+ throw unpublishedWhile('screen');
1205
1634
  this.screenStream = stream;
1206
1635
  this.screenMode = mode;
1207
1636
  this.screenAudioEnabled = options.audio !== false;
@@ -1211,7 +1640,7 @@ class StreamClient {
1211
1640
  video.addEventListener('ended', onEnded, { once: true });
1212
1641
  await this.restoreScreen();
1213
1642
  if (generation !== this.screenGeneration || video.readyState !== 'live')
1214
- throw new Error(unpublishedWhile('screen'));
1643
+ throw unpublishedWhile('screen');
1215
1644
  this.screenStarted = true;
1216
1645
  this.wasPublishing = true;
1217
1646
  this.emit('screenShareStarted', { stream, mode });
@@ -1275,7 +1704,7 @@ class StreamClient {
1275
1704
  const { width = 0, height = 0 } = video.getSettings();
1276
1705
  const encodings = (0, quality_js_1.screenEncodings)(width, height, this.screenMode);
1277
1706
  // A project's explicit safety restriction also applies to screen video.
1278
- const cap = (0, quality_js_1.topLayerSize)(width, height, this.videoPolicy.maxQuality);
1707
+ const cap = (0, quality_js_1.topLayerSize)(width, height, this.mediaPermissions.maxVideoQuality);
1279
1708
  const screenScale = encodings.at(-1).scaleResolutionDownBy;
1280
1709
  if (cap.scale > screenScale)
1281
1710
  encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
@@ -1283,7 +1712,7 @@ class StreamClient {
1283
1712
  if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
1284
1713
  producer.close();
1285
1714
  await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1286
- throw new Error(unpublishedWhile('screen'));
1715
+ throw unpublishedWhile('screen');
1287
1716
  }
1288
1717
  this.producers.set(producer.id, producer);
1289
1718
  this.screenProducerIds.add(producer.id);
@@ -1294,7 +1723,12 @@ class StreamClient {
1294
1723
  codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
1295
1724
  appData: { source: 'screen', videoLayers: (0, quality_js_1.describeVideoLayers)(width, height, encodings) },
1296
1725
  }));
1297
- const audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
1726
+ let audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
1727
+ if (audio && this.screenAudioEnabled && !this.mediaPermissions.audio) {
1728
+ // The screen goes out; its sound cannot in a project without audio.
1729
+ this.emit('error', new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project; the screen is shared without its sound'));
1730
+ audio = undefined;
1731
+ }
1298
1732
  if (audio && this.screenAudioEnabled && generation === this.screenGeneration)
1299
1733
  await keep(await transport.produce({
1300
1734
  track: audio, stopTracks: false,
@@ -1302,19 +1736,34 @@ class StreamClient {
1302
1736
  appData: { source: 'screen-audio' },
1303
1737
  }));
1304
1738
  }
1739
+ /**
1740
+ * 60 only when asked for, allowed by the project, captured at 50 or more and
1741
+ * not fallen back from. The project's refusal and the camera's are reported
1742
+ * apart, each once: `frame-rate-not-allowed` is the server's own code for a
1743
+ * 60 fps producer the project does not allow, `frame-rate-fallback` means
1744
+ * the camera or device cannot provide it.
1745
+ */
1305
1746
  frameRateFor(track) {
1306
1747
  if (this.publishOptions.frameRate !== 60 || this.frameRateFallback)
1307
1748
  return 30;
1749
+ if (!this.allows60()) {
1750
+ this.frameRateFallback = true;
1751
+ this.emit('error', new errors_js_1.RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
1752
+ return 30;
1753
+ }
1308
1754
  const captured = track.getSettings().frameRate;
1309
- if (this.videoPolicy.maxFrameRate === 30 || (typeof captured === 'number' && captured < 50)) {
1755
+ if (typeof captured === 'number' && captured < 50) {
1310
1756
  this.frameRateFallback = true;
1311
- this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera or project; publishing at 30 fps'));
1757
+ this.emit('error', new errors_js_1.RpcError('frame-rate-fallback', '60 fps is unavailable from this camera; publishing at 30 fps'));
1312
1758
  return 30;
1313
1759
  }
1314
1760
  return 60;
1315
1761
  }
1316
- async sampleCamera(baseline) {
1762
+ async sampleCamera(baseline, isCurrent) {
1763
+ const sampledProducer = this.cameraProducer();
1317
1764
  const stats = await this.collectVideoStats(baseline);
1765
+ if (!isCurrent() || sampledProducer !== this.cameraProducer())
1766
+ return null;
1318
1767
  if (this.cameraFrameRate !== 60 || this.frameRateFallback || !stats)
1319
1768
  return stats;
1320
1769
  if (this.cameraProducer()?.paused)
@@ -1328,14 +1777,14 @@ class StreamClient {
1328
1777
  const limited = stats.layers.some((layer) => layer.active && (layer.qualityLimitation === 'cpu' || layer.qualityLimitation === 'bandwidth'));
1329
1778
  this.frameRateLowSamples = limited && stats.fps > 0 && stats.fps < 50 ? this.frameRateLowSamples + 1 : 0;
1330
1779
  if (this.frameRateLowSamples >= 3) {
1331
- this.frameRateFallback = true;
1332
1780
  void this.cameraTask(async () => {
1333
1781
  const producer = this.cameraProducer();
1334
- if (!producer?.track || producer.closed)
1782
+ if (!isCurrent() || producer !== sampledProducer || this.frameRateFallback || !producer?.track || producer.closed)
1335
1783
  return;
1336
- await producer.track.applyConstraints?.({ frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
1784
+ this.frameRateFallback = true;
1785
+ await producer.track.applyConstraints?.({ ...producer.track.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
1337
1786
  await this.reproduceCamera(producer, producer.track);
1338
- this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
1787
+ this.emit('error', new errors_js_1.RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
1339
1788
  }).catch((error) => this.emit('error', error));
1340
1789
  }
1341
1790
  return stats;
@@ -1352,6 +1801,8 @@ class StreamClient {
1352
1801
  * the user has not interacted with.
1353
1802
  */
1354
1803
  async publishMusic(input, options) {
1804
+ if (!this.mediaPermissions.audio)
1805
+ throw new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project');
1355
1806
  const generation = this.publishGeneration;
1356
1807
  if (this.music)
1357
1808
  await this.stopMusic();
@@ -1362,7 +1813,7 @@ class StreamClient {
1362
1813
  // already closed it, and the music must not be remembered for a
1363
1814
  // reconnect to publish again.
1364
1815
  if (generation !== this.publishGeneration)
1365
- throw new Error(unpublishedWhile('music'));
1816
+ throw unpublishedWhile('music');
1366
1817
  }
1367
1818
  catch (err) {
1368
1819
  music.close();
@@ -1375,7 +1826,7 @@ class StreamClient {
1375
1826
  async produceMusic(music, generation) {
1376
1827
  const transport = await this.ensureSendTransport();
1377
1828
  if (generation !== this.publishGeneration)
1378
- throw new Error(unpublishedWhile('music'));
1829
+ throw unpublishedWhile('music');
1379
1830
  const producer = await transport.produce({
1380
1831
  track: music.track,
1381
1832
  stopTracks: false,
@@ -1435,13 +1886,49 @@ class StreamClient {
1435
1886
  }
1436
1887
  async consume(remote) {
1437
1888
  if (!this.device)
1438
- throw new Error('not connected');
1889
+ throw new errors_js_1.RpcError('not-connected', 'not connected');
1439
1890
  if (this.consumers.has(remote.producerId))
1440
1891
  return;
1441
1892
  // Your own camera is already local; pulling it back off the SFU wastes a
1442
1893
  // consumer and shows you a delayed copy of yourself.
1443
1894
  if (remote.identity === this.joinResult?.identity)
1444
1895
  return;
1896
+ // A wallet session receives at most media.fanout.maxVideoSubscriptions
1897
+ // videos; the media node refuses the next. Wait for a slot instead of
1898
+ // asking for a refusal, and say so once per video.
1899
+ if (remote.kind === 'video' && !this.videoSlots.has(remote.producerId)) {
1900
+ const limit = this.mediaPermissions.maxVideoSubscriptions;
1901
+ if (limit !== null && this.videoSlots.size >= limit) {
1902
+ if (!this.skippedVideos.has(remote.producerId)) {
1903
+ this.skippedVideos.set(remote.producerId, remote);
1904
+ this.emit('error', new errors_js_1.RpcError('video-subscription-limit', `already receiving ${limit} videos, the most this project allows; ${remote.identity}'s video waits for one to end`));
1905
+ }
1906
+ return;
1907
+ }
1908
+ this.videoSlots.add(remote.producerId);
1909
+ }
1910
+ try {
1911
+ await this.consumeNow(remote);
1912
+ }
1913
+ catch (err) {
1914
+ if (!this.consumers.has(remote.producerId))
1915
+ this.videoSlots.delete(remote.producerId);
1916
+ throw err;
1917
+ }
1918
+ }
1919
+ /** The oldest videos left waiting at the fan-out limit take the slots that opened. */
1920
+ async consumeSkipped() {
1921
+ for (const [producerId, remote] of this.skippedVideos) {
1922
+ const limit = this.mediaPermissions.maxVideoSubscriptions;
1923
+ if (limit !== null && this.videoSlots.size >= limit)
1924
+ return;
1925
+ this.skippedVideos.delete(producerId);
1926
+ await this.consume(remote);
1927
+ }
1928
+ }
1929
+ async consumeNow(remote) {
1930
+ if (!this.device)
1931
+ throw new errors_js_1.RpcError('not-connected', 'not connected');
1445
1932
  const transport = await this.ensureRecvTransport();
1446
1933
  const params = (await this.rpc('consume', {
1447
1934
  transportId: transport.id,
@@ -1462,7 +1949,7 @@ class StreamClient {
1462
1949
  }
1463
1950
  catch {
1464
1951
  layers = undefined;
1465
- this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
1952
+ this.emit('error', new errors_js_1.RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
1466
1953
  }
1467
1954
  }
1468
1955
  if (layers) {
@@ -1474,6 +1961,7 @@ class StreamClient {
1474
1961
  const kind = consumer.kind;
1475
1962
  const source = remote.source ?? defaultSource(kind);
1476
1963
  this.remoteSources.set(remote.producerId, source);
1964
+ this.remoteInfo.set(remote.producerId, { identity: remote.identity, kind, paused: Boolean(remote.paused) });
1477
1965
  this.emit('track', {
1478
1966
  identity: remote.identity,
1479
1967
  kind,
@@ -1618,6 +2106,57 @@ class StreamClient {
1618
2106
  kick(identity, reason) { return this.rpc('kick', { identity, reason }); }
1619
2107
  endRoom() { return this.rpc('endRoom'); }
1620
2108
  getRoomState() { return this.rpc('getRoomState'); }
2109
+ // ------------------------------------------------------- slots and metadata
2110
+ //
2111
+ // A slot is a named thing in the room that at most one participant holds at
2112
+ // a time: a seat on a board, a guest spot. Keys are yours; the server never
2113
+ // learns that `seat-13` is a seat. `info.claims` and `info.blockedSlots`
2114
+ // seed a board on join and follow claimChanged.
2115
+ /**
2116
+ * Take a slot, or (moderators) grant one by naming `identity`. One claim
2117
+ * per identity: a second moves you. Refused with `slot-taken` when someone
2118
+ * holds it (away included) and `slot-blocked` when a moderator locked it.
2119
+ * Claim first, then promote: if the slot has gone, nothing has happened yet.
2120
+ */
2121
+ async claim(key, identity) {
2122
+ await this.rpc('claim', identity ? { key, identity } : { key });
2123
+ }
2124
+ /** Give up a slot. Moderators may release anyone's. */
2125
+ async release(key) {
2126
+ await this.rpc('release', { key });
2127
+ }
2128
+ /**
2129
+ * Lock a slot so nobody may claim it. Moderators only. Refused with
2130
+ * `slot-occupied` while someone holds it: taking a seat from a person using
2131
+ * it is two deliberate acts, not a side effect of one tap.
2132
+ */
2133
+ async blockSlot(key) {
2134
+ await this.rpc('blockSlot', { key });
2135
+ }
2136
+ /** Unlock a slot. Moderators only. */
2137
+ async unblockSlot(key) {
2138
+ await this.rpc('unblockSlot', { key });
2139
+ }
2140
+ /** Replace your own display metadata; the room hears it as participantUpdated. */
2141
+ async setMetadata(metadata) {
2142
+ await this.rpc('setMetadata', { metadata });
2143
+ }
2144
+ /**
2145
+ * Change room-scoped metadata. Moderators only, and persisted, so a client
2146
+ * joining later reads it from `info.metadata`. Merges one key deep by
2147
+ * default (a null value deletes its key); `replace` swaps the whole object.
2148
+ * `requireFree` names slots that must be unheld for the change to apply,
2149
+ * checked and applied together: the refusal (`slot-occupied`) names the
2150
+ * offending keys in `RpcError.data.slots`. Resolves to the metadata now.
2151
+ */
2152
+ async setRoomMetadata(metadata, options = {}) {
2153
+ const res = (await this.rpc('setRoomMetadata', {
2154
+ metadata,
2155
+ ...(options.replace ? { replace: true } : {}),
2156
+ ...(options.requireFree ? { requireFree: options.requireFree } : {}),
2157
+ }));
2158
+ return res?.metadata ?? null;
2159
+ }
1621
2160
  // ------------------------------------------------------------------- chat
1622
2161
  //
1623
2162
  // The room's chat rides this same socket. Someone on a chat-only socket