@voltkit-rtc/client 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +109 -2
  2. package/README.md +236 -11
  3. package/dist/cjs/broadcast.d.ts.map +1 -1
  4. package/dist/cjs/broadcast.js +16 -7
  5. package/dist/cjs/broadcast.js.map +1 -1
  6. package/dist/cjs/chat.d.ts +11 -9
  7. package/dist/cjs/chat.d.ts.map +1 -1
  8. package/dist/cjs/chat.js +56 -19
  9. package/dist/cjs/chat.js.map +1 -1
  10. package/dist/cjs/errors.d.ts +571 -0
  11. package/dist/cjs/errors.d.ts.map +1 -0
  12. package/dist/cjs/errors.js +155 -0
  13. package/dist/cjs/errors.js.map +1 -0
  14. package/dist/cjs/index.d.ts +320 -16
  15. package/dist/cjs/index.d.ts.map +1 -1
  16. package/dist/cjs/index.js +728 -123
  17. package/dist/cjs/index.js.map +1 -1
  18. package/dist/cjs/media-permissions.d.ts +101 -0
  19. package/dist/cjs/media-permissions.d.ts.map +1 -0
  20. package/dist/cjs/media-permissions.js +166 -0
  21. package/dist/cjs/media-permissions.js.map +1 -0
  22. package/dist/cjs/network.d.ts +80 -0
  23. package/dist/cjs/network.d.ts.map +1 -0
  24. package/dist/cjs/network.js +190 -0
  25. package/dist/cjs/network.js.map +1 -0
  26. package/dist/cjs/token.d.ts +17 -0
  27. package/dist/cjs/token.d.ts.map +1 -0
  28. package/dist/cjs/token.js +36 -0
  29. package/dist/cjs/token.js.map +1 -0
  30. package/dist/esm/broadcast.d.ts.map +1 -1
  31. package/dist/esm/broadcast.js +16 -7
  32. package/dist/esm/broadcast.js.map +1 -1
  33. package/dist/esm/chat.d.ts +11 -9
  34. package/dist/esm/chat.d.ts.map +1 -1
  35. package/dist/esm/chat.js +56 -19
  36. package/dist/esm/chat.js.map +1 -1
  37. package/dist/esm/errors.d.ts +571 -0
  38. package/dist/esm/errors.d.ts.map +1 -0
  39. package/dist/esm/errors.js +149 -0
  40. package/dist/esm/errors.js.map +1 -0
  41. package/dist/esm/index.d.ts +320 -16
  42. package/dist/esm/index.d.ts.map +1 -1
  43. package/dist/esm/index.js +707 -114
  44. package/dist/esm/index.js.map +1 -1
  45. package/dist/esm/media-permissions.d.ts +101 -0
  46. package/dist/esm/media-permissions.d.ts.map +1 -0
  47. package/dist/esm/media-permissions.js +160 -0
  48. package/dist/esm/media-permissions.js.map +1 -0
  49. package/dist/esm/network.d.ts +80 -0
  50. package/dist/esm/network.d.ts.map +1 -0
  51. package/dist/esm/network.js +183 -0
  52. package/dist/esm/network.js.map +1 -0
  53. package/dist/esm/token.d.ts +17 -0
  54. package/dist/esm/token.d.ts.map +1 -0
  55. package/dist/esm/token.js +31 -0
  56. package/dist/esm/token.js.map +1 -0
  57. package/package.json +1 -1
  58. package/src/broadcast.ts +16 -7
  59. package/src/chat.ts +44 -18
  60. package/src/errors.ts +162 -0
  61. package/src/index.ts +842 -114
  62. package/src/media-permissions.ts +220 -0
  63. package/src/network.ts +212 -0
  64. package/src/token.ts +31 -0
package/dist/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". */
@@ -77,10 +86,32 @@ const isLayer = (value) => Number.isInteger(value) && value >= 0;
77
86
  * otherwise starts low and takes many seconds to climb to the top layer.
78
87
  */
79
88
  const VIDEO_START_BITRATE_KBPS = 2000;
89
+ /** Why a connect() gave up: close() ran while it was under way, and close wins. */
90
+ const closedWhileConnecting = () => new errors_js_1.RpcError('not-connected', 'the session was closed while connecting');
80
91
  /** 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`;
92
+ const unpublishedWhile = (what) => new errors_js_1.RpcError('unpublished', `the ${what} was unpublished while it was being published`);
93
+ /**
94
+ * mediasoup-client puts the microphone's Opus maxaveragebitrate in the answer
95
+ * SDP only, so the server would never see it. Declare it in the producer's
96
+ * parameters too: the media node bills HD on a declared value above 64,000
97
+ * (spec §4) and refuses one in a project limited to standard audio, so a
98
+ * standard microphone declares 64,000 and an HD one 128,000. Music and screen
99
+ * audio go out as they always did.
100
+ */
101
+ function declareAudioQuality(rtpParameters, appData) {
102
+ const quality = appData.audioQuality;
103
+ if (appData.source !== 'mic' || (quality !== 'std' && quality !== 'hd'))
104
+ return rtpParameters;
105
+ const maxaveragebitrate = media_permissions_js_1.AUDIO_SETTINGS[quality].maxBitrate;
106
+ return {
107
+ ...rtpParameters,
108
+ codecs: rtpParameters.codecs.map((codec) => (codec.mimeType.toLowerCase() === 'audio/opus'
109
+ ? { ...codec, parameters: { ...codec.parameters, maxaveragebitrate } } : codec)),
110
+ };
111
+ }
82
112
  class StreamClient {
83
113
  wsUrl;
114
+ options;
84
115
  ws = null;
85
116
  device = null;
86
117
  sendTransport = null;
@@ -132,7 +163,15 @@ class StreamClient {
132
163
  * the full picture.
133
164
  */
134
165
  preferredLayers = new Map();
166
+ /** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
167
+ endRoomWaiters = new Set();
135
168
  closedByUs = false;
169
+ /**
170
+ * Bumped when the session ends for good: close(), or finish(). A connect()
171
+ * under way when it did -- the app's, or a reconnect's -- sees the change
172
+ * after its next await and stops, so a closed session never comes back.
173
+ */
174
+ lifecycle = 0;
136
175
  reconnecting = false;
137
176
  reconnectAttempt = 0;
138
177
  /** Set once a connect() has joined and set up. Only such a session is reconnected. */
@@ -151,9 +190,30 @@ class StreamClient {
151
190
  sendStatsBaseline = new Map();
152
191
  recvStatsBaseline = new Map();
153
192
  limitWatch = new stats_js_1.VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) => this.emit('videoLimited', event), undefined, () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; });
154
- constructor(wsUrl, token) {
193
+ /** Where the session stands; see connectionStateChanged. */
194
+ state = 'idle';
195
+ /** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
196
+ tokenExpiry;
197
+ tokenTimer = null;
198
+ /** Whose each received track is and whether it is switched off, by producerId. */
199
+ remoteInfo = new Map();
200
+ /** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
201
+ skippedVideos = new Map();
202
+ /** Producers whose video this client receives, or is about to: the fan-out count. */
203
+ videoSlots = new Set();
204
+ /** The quality the microphone producer was made with. */
205
+ micQuality = 'std';
206
+ // Changes to the microphone producer run one at a time, as the camera's do.
207
+ micQueue = Promise.resolve();
208
+ // A request the project does not allow is reported once, until it is made again.
209
+ audioCapReported = false;
210
+ sizeCapReported = false;
211
+ networkWatch = new network_js_1.NetworkQualityWatch(() => this.transportStats(), () => this.producers.size > 0, (quality) => this.emit('networkQuality', quality));
212
+ constructor(wsUrl, token, options = {}) {
155
213
  this.wsUrl = wsUrl;
214
+ this.options = options;
156
215
  this.token = token;
216
+ this.tokenExpiry = (0, token_js_1.tokenExpiresAt)(token);
157
217
  }
158
218
  on(event, fn) {
159
219
  let set = this.listeners.get(event);
@@ -162,15 +222,46 @@ class StreamClient {
162
222
  this.listeners.set(event, set);
163
223
  }
164
224
  set.add(fn);
165
- // videoLimited costs a getStats every 2 s, so it runs only while heard.
225
+ // videoLimited and networkQuality cost a getStats every few seconds, so
226
+ // they run only while heard.
166
227
  if (event === 'videoLimited')
167
228
  this.syncLimitWatch();
229
+ if (event === 'networkQuality')
230
+ this.syncNetworkWatch();
168
231
  return () => {
169
232
  set.delete(fn);
170
233
  if (event === 'videoLimited')
171
234
  this.syncLimitWatch();
235
+ if (event === 'networkQuality')
236
+ this.syncNetworkWatch();
172
237
  };
173
238
  }
239
+ /** Where the session stands: idle, connecting, connected, reconnecting or closed. */
240
+ get connectionState() {
241
+ return this.state;
242
+ }
243
+ setState(next) {
244
+ if (next === this.state)
245
+ return;
246
+ const previous = this.state;
247
+ this.state = next;
248
+ if (next === 'closed')
249
+ this.stopTimers();
250
+ this.emit('connectionStateChanged', { state: next, previous });
251
+ }
252
+ /** Over for good: the state, then the event that says why. */
253
+ finish(reason) {
254
+ this.closedByUs = true;
255
+ this.lifecycle += 1;
256
+ this.setState('closed');
257
+ this.emit('closed', reason);
258
+ }
259
+ stopTimers() {
260
+ if (this.tokenTimer)
261
+ clearTimeout(this.tokenTimer);
262
+ this.tokenTimer = null;
263
+ this.networkWatch.set(false);
264
+ }
174
265
  listenerCount(event) {
175
266
  return this.listeners.get(event)?.size ?? 0;
176
267
  }
@@ -186,20 +277,31 @@ class StreamClient {
186
277
  }
187
278
  // ------------------------------------------------------------- connection
188
279
  async connect() {
280
+ const lifecycle = this.lifecycle;
281
+ const closed = () => lifecycle !== this.lifecycle;
189
282
  let result;
283
+ if (this.state === 'idle' || this.state === 'closed')
284
+ this.setState('connecting');
190
285
  try {
191
286
  await this.openSocket();
192
287
  result = (await this.rpc('join', { token: this.token }));
193
288
  this.joinResult = result;
194
289
  this.device = this.createDevice();
195
290
  await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
291
+ // close() ran while the Device loaded: going on would set the state
292
+ // back to connected, and emit joined, after closed.
293
+ if (closed())
294
+ throw closedWhileConnecting();
196
295
  }
197
296
  catch (err) {
198
297
  // A session that never got going: the app has the error. Left open,
199
298
  // the socket would be closed by the server's join deadline instead, and
200
299
  // that close would start reconnecting a session that never began.
201
- if (!this.joinedOnce)
300
+ if (!this.joinedOnce) {
202
301
  this.detachSocket();
302
+ if (this.state === 'connecting')
303
+ this.setState('idle');
304
+ }
203
305
  throw err;
204
306
  }
205
307
  // Joined and set up: the next drop gets every reconnect attempt again.
@@ -208,10 +310,15 @@ class StreamClient {
208
310
  // retried about twice a second, forever.
209
311
  this.reconnectAttempt = 0;
210
312
  this.joinedOnce = true;
313
+ this.skippedVideos.clear();
314
+ this.setState('connected');
315
+ this.scheduleTokenExpiring();
211
316
  // A role or mute can change while the socket is away. The fresh roster
212
317
  // decides what may return; reconnecting never turns a blocked device on.
213
318
  if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
214
319
  await this.unpublish();
320
+ if (closed())
321
+ throw closedWhileConnecting();
215
322
  }
216
323
  const own = result.participants.find((p) => p.identity === result.identity);
217
324
  if (own?.muted)
@@ -233,20 +340,33 @@ class StreamClient {
233
340
  if (!present.has(producerId))
234
341
  this.remoteVideoLayers.delete(producerId);
235
342
  // Subscribe to everything already publishing before we announce readiness.
343
+ // A close() part-way stops it: what is left would only fail, after closed.
236
344
  for (const p of result.producers) {
237
- await this.consume(p).catch((err) => this.emit('error', err));
345
+ if (closed())
346
+ throw closedWhileConnecting();
347
+ await this.consume(p).catch((err) => { if (!closed())
348
+ this.emit('error', err); });
238
349
  }
239
350
  // Restore our own broadcast. This is what makes an origin failover
240
351
  // survivable for the host: it reconnects onto the new origin and the
241
352
  // stream comes back on its own.
242
353
  if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
243
- await this.republish().catch((err) => this.emit('error', err));
354
+ await this.republish().catch((err) => { if (!closed())
355
+ this.emit('error', err); });
244
356
  }
357
+ if (closed())
358
+ throw closedWhileConnecting();
359
+ this.syncNetworkWatch();
245
360
  return result;
246
361
  }
247
- /** A new mediasoup Device for each join. Its own method so tests, which have no WebRTC, can stand one in. */
362
+ /**
363
+ * A new mediasoup Device for each join: the handler named in the options, or
364
+ * the one detected from the browser. Its own method so tests, which have no
365
+ * WebRTC, can stand one in.
366
+ */
248
367
  createDevice() {
249
- return new mediasoup_client_1.Device();
368
+ const { handlerName } = this.options;
369
+ return handlerName ? new mediasoup_client_1.Device({ handlerName }) : new mediasoup_client_1.Device();
250
370
  }
251
371
  openSocket() {
252
372
  // Abandon any previous socket first. Its close handler checks identity
@@ -254,15 +374,13 @@ class StreamClient {
254
374
  // reconnect -- which is what let multiple reconnect chains pile up.
255
375
  this.detachSocket();
256
376
  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());
377
+ const ws = new WebSocket((0, token_js_1.socketUrl)(this.wsUrl, this.token));
260
378
  this.ws = ws;
261
379
  const onOpen = () => {
262
380
  this.emit('connected');
263
381
  resolve();
264
382
  };
265
- const onError = () => reject(new Error('websocket failed to open'));
383
+ const onError = () => reject(new errors_js_1.RpcError('connect-failed', 'websocket failed to open'));
266
384
  ws.addEventListener('open', onOpen, { once: true });
267
385
  ws.addEventListener('error', onError, { once: true });
268
386
  ws.addEventListener('message', (ev) => {
@@ -275,49 +393,59 @@ class StreamClient {
275
393
  if (this.ws !== ws)
276
394
  return;
277
395
  this.ws = null;
278
- this.failAllPending(new Error(`socket closed (${ev.code})`));
396
+ this.failAllPending(new errors_js_1.RpcError('not-connected', `socket closed (${ev.code})`));
279
397
  this.emit('disconnected', ev.reason || String(ev.code));
280
398
  if (this.closedByUs)
281
399
  return;
282
400
  if (ev.code === CLOSE_REPLACED) {
283
401
  // Another session for this identity is live. Reconnecting would
284
402
  // 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;
403
+ this.emit('error', new errors_js_1.RpcError('replaced', 'this identity connected from somewhere else'));
287
404
  // The producers outlive this close until the app calls close(); the
288
405
  // videoLimited sampling must not, or it reports after `closed`.
289
406
  this.limitWatch.reset();
290
- this.emit('closed', 'replaced');
407
+ this.finish('replaced');
291
408
  return;
292
409
  }
293
410
  if (ev.code === CLOSE_ENDED) {
294
411
  // Normally roomClosed has already ended the session; this covers a
295
412
  // close that arrived without it.
296
- this.closedByUs = true;
297
413
  this.limitWatch.reset();
298
- this.emit('closed', 'room-closed');
414
+ this.finish('room-closed');
299
415
  return;
300
416
  }
301
417
  // Before a first successful join the app is still waiting on
302
418
  // connect(), which reports the failure; there is nothing to restore.
303
419
  if (!this.joinedOnce)
304
420
  return;
421
+ this.setState('reconnecting');
422
+ this.networkWatch.set(false);
305
423
  void this.scheduleReconnect();
306
424
  });
307
425
  });
308
426
  }
309
- detachSocket() {
427
+ detachSocket(reason = 'superseded') {
310
428
  const ws = this.ws;
311
429
  this.ws = null;
312
430
  if (ws && ws.readyState <= WebSocket.OPEN) {
313
431
  try {
314
- ws.close(1000, 'superseded');
432
+ ws.close(1000, reason);
315
433
  }
316
434
  catch {
317
435
  /* already closing */
318
436
  }
319
437
  }
320
438
  }
439
+ /**
440
+ * A reconnect that ends the session lets go of its socket. A refused join
441
+ * leaves that socket open (connect() keeps it once a session has joined),
442
+ * and the session would otherwise report closed with it still attached and
443
+ * still sending.
444
+ */
445
+ releaseSocket() {
446
+ this.detachSocket('gave up');
447
+ this.failAllPending(new errors_js_1.RpcError('not-connected', 'the session has ended'));
448
+ }
321
449
  /**
322
450
  * Reconnection is mandatory, not optional: nodes get drained, relays die and
323
451
  * promotions move you to a different node. Always re-ask the orchestrator
@@ -334,9 +462,9 @@ class StreamClient {
334
462
  while (!this.closedByUs) {
335
463
  this.reconnectAttempt += 1;
336
464
  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');
465
+ this.emit('error', new errors_js_1.RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
466
+ this.releaseSocket();
467
+ this.finish('gave-up');
340
468
  return;
341
469
  }
342
470
  const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
@@ -354,13 +482,17 @@ class StreamClient {
354
482
  return;
355
483
  }
356
484
  catch (err) {
485
+ // Ended while this attempt was under way -- by close(), or by the
486
+ // server -- and that has been reported: nothing follows closed.
487
+ if (this.closedByUs)
488
+ return;
357
489
  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');
490
+ // Stop on failures a retry cannot resolve (the registry's terminal
491
+ // codes). Hammering a room that has ended produces one error per
492
+ // attempt forever and drowns out whatever actually went wrong.
493
+ if (err instanceof errors_js_1.RpcError && (0, errors_js_1.isTerminalError)(err.code)) {
494
+ this.releaseSocket();
495
+ this.finish('gave-up');
364
496
  return;
365
497
  }
366
498
  }
@@ -376,6 +508,7 @@ class StreamClient {
376
508
  // others have already reported themselves and set closedByUs.
377
509
  const wasOpen = !this.closedByUs;
378
510
  this.closedByUs = true;
511
+ this.lifecycle += 1;
379
512
  this.publishGeneration += 1;
380
513
  this.wasPublishing = false;
381
514
  this.teardownMedia();
@@ -387,6 +520,7 @@ class StreamClient {
387
520
  this.music = null;
388
521
  // Nothing is emitted after closing, so not even a clearing videoLimited.
389
522
  this.limitWatch.reset();
523
+ this.skippedVideos.clear();
390
524
  // Producers no longer stop their tracks, so releasing the camera and
391
525
  // microphone is ours to do -- otherwise the device light stays on.
392
526
  this.localStream?.getTracks().forEach((t) => t.stop());
@@ -394,6 +528,11 @@ class StreamClient {
394
528
  const ws = this.ws;
395
529
  this.ws = null;
396
530
  ws?.close(1000, 'client closed');
531
+ // Detached first, so the socket's own close handler no longer fails what
532
+ // was waiting on it: done here, or a join or a request in flight would
533
+ // wait out its 15 s timeout.
534
+ this.failAllPending(new errors_js_1.RpcError('not-connected', 'the session was closed'));
535
+ this.setState('closed');
397
536
  if (wasOpen)
398
537
  this.emit('closed', 'left');
399
538
  }
@@ -401,6 +540,9 @@ class StreamClient {
401
540
  for (const c of this.consumers.values())
402
541
  c.close();
403
542
  this.consumers.clear();
543
+ this.remoteInfo.clear();
544
+ this.skippedVideos.clear();
545
+ this.videoSlots.clear();
404
546
  for (const p of this.producers.values())
405
547
  p.close();
406
548
  this.producers.clear();
@@ -417,22 +559,137 @@ class StreamClient {
417
559
  this.sendTransportPending = null;
418
560
  this.recvTransportPending = null;
419
561
  this.device = null;
562
+ this.syncNetworkWatch();
420
563
  }
421
- /** Everything currently known about the room, for rendering. */
564
+ /**
565
+ * Everything currently known about the room, for rendering: the join
566
+ * result, kept current -- participants, roles, hands, mutes, display
567
+ * metadata, slots and room metadata follow the events.
568
+ */
422
569
  get info() {
423
570
  return this.joinResult;
424
571
  }
572
+ /** Who is in the room now, this client included; kept current from the roster events. */
573
+ get participants() {
574
+ return [...(this.joinResult?.participants ?? [])];
575
+ }
576
+ /** Every track this client is receiving now. */
577
+ get remoteTracks() {
578
+ const out = [];
579
+ for (const [producerId, consumer] of this.consumers) {
580
+ const info = this.remoteInfo.get(producerId);
581
+ if (!info || consumer.closed)
582
+ continue;
583
+ out.push({
584
+ producerId, identity: info.identity, kind: info.kind, track: consumer.track,
585
+ source: this.remoteSources.get(producerId) ?? defaultSource(info.kind), paused: info.paused,
586
+ });
587
+ }
588
+ return out;
589
+ }
590
+ /**
591
+ * What this project lets this client publish: from the join result's
592
+ * `media` (servers with wallet billing), else its `video` policy, else
593
+ * everything before a join. Offer only these in your UI; the SDK refuses
594
+ * or caps anything else with the server's own codes.
595
+ */
596
+ get mediaPermissions() {
597
+ return (0, media_permissions_js_1.resolvePermissions)(this.joinResult);
598
+ }
599
+ /** How this session is billed: `legacy` (minutes) or `wallet`; null before a join or on older servers. */
600
+ get billingMode() {
601
+ return this.joinResult?.billing?.mode ?? null;
602
+ }
603
+ // ------------------------------------------------------------ token refresh
604
+ /**
605
+ * Hand the session a newer room token, minted by your backend for the same
606
+ * room and identity (`POST /v1/rooms/{roomId}/tokens`), typically on
607
+ * `tokenExpiring`. On a live connection the server checks it like a join
608
+ * token (`bad-token`, or `token-mismatch` for another room or identity; the
609
+ * session then keeps its token); otherwise it is kept for the next join,
610
+ * which checks it there. Resolves to when it expires (unix ms), when known.
611
+ */
612
+ async updateToken(token) {
613
+ if (typeof token !== 'string' || token.trim() === '')
614
+ throw new TypeError('updateToken needs a token');
615
+ if (this.state === 'closed')
616
+ throw new errors_js_1.RpcError('not-connected', 'this session is closed; create a new client');
617
+ if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
618
+ try {
619
+ const res = (await this.rpc('refreshToken', { token }));
620
+ const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
621
+ this.setToken(token, at);
622
+ return { expiresAt: this.tokenExpiry };
623
+ }
624
+ catch (err) {
625
+ // An older server has no refreshToken, and a socket that dropped
626
+ // during the call is reconnecting: either way keep the token for the
627
+ // next join, which checks it.
628
+ if (!(err instanceof errors_js_1.RpcError) || (err.code !== 'unknown-method' && err.code !== 'not-connected'))
629
+ throw err;
630
+ }
631
+ }
632
+ this.setToken(token, null);
633
+ return { expiresAt: this.tokenExpiry };
634
+ }
635
+ /** The token reconnects use from now on, with its expiry (read from the token when not given). */
636
+ setToken(token, expiresAt) {
637
+ this.token = token;
638
+ this.tokenExpiry = expiresAt ?? (0, token_js_1.tokenExpiresAt)(token);
639
+ this.scheduleTokenExpiring();
640
+ }
641
+ /**
642
+ * Warn TOKEN_EXPIRING_LEAD_MS before the token held for reconnects
643
+ * expires. Every new token -- the server's refresh, a promotion's, the
644
+ * app's -- reschedules it, so it fires only when nobody refreshed it.
645
+ */
646
+ scheduleTokenExpiring() {
647
+ if (this.tokenTimer)
648
+ clearTimeout(this.tokenTimer);
649
+ this.tokenTimer = null;
650
+ const expiresAt = this.tokenExpiry;
651
+ if (expiresAt === null || this.state === 'idle' || this.state === 'closed')
652
+ return;
653
+ // setTimeout cannot wait longer than about 24.8 days.
654
+ const delay = Math.min(Math.max(0, expiresAt - token_js_1.TOKEN_EXPIRING_LEAD_MS - Date.now()), 2_147_000_000);
655
+ this.tokenTimer = setTimeout(() => {
656
+ this.tokenTimer = null;
657
+ if (this.state === 'closed' || this.tokenExpiry !== expiresAt)
658
+ return;
659
+ if (expiresAt - token_js_1.TOKEN_EXPIRING_LEAD_MS > Date.now()) {
660
+ this.scheduleTokenExpiring(); // woke early from a clamped wait
661
+ return;
662
+ }
663
+ this.emit('tokenExpiring', { expiresAt, secondsLeft: Math.max(0, Math.round((expiresAt - Date.now()) / 1000)) });
664
+ }, delay);
665
+ }
666
+ // ------------------------------------------------------------ network quality
667
+ /** networkQuality sampling runs while someone listens and the session is connected. */
668
+ syncNetworkWatch() {
669
+ this.networkWatch.set(!this.closedByUs && this.state === 'connected' && this.listenerCount('networkQuality') > 0);
670
+ }
671
+ /** Each live media transport's statistics, keyed by transport so a new one starts fresh. */
672
+ async transportStats() {
673
+ const out = new Map();
674
+ for (const transport of [this.sendTransport, this.recvTransport]) {
675
+ if (!transport || transport.closed)
676
+ continue;
677
+ const report = await transport.getStats();
678
+ out.set(transport.id, report.values());
679
+ }
680
+ return out;
681
+ }
425
682
  // -------------------------------------------------------------------- rpc
426
683
  rpc(method, params = {}) {
427
684
  return new Promise((resolve, reject) => {
428
685
  if (this.ws?.readyState !== WebSocket.OPEN) {
429
- reject(new Error('socket is not open'));
686
+ reject(new errors_js_1.RpcError('not-connected', 'socket is not open'));
430
687
  return;
431
688
  }
432
689
  const id = this.nextId++;
433
690
  const timer = setTimeout(() => {
434
691
  this.pending.delete(id);
435
- reject(new Error(`${method} timed out`));
692
+ reject(new errors_js_1.RpcError('timeout', `${method} timed out`));
436
693
  }, RPC_TIMEOUT_MS);
437
694
  this.pending.set(id, { resolve, reject, timer });
438
695
  this.ws.send(JSON.stringify({ id, method, params }));
@@ -453,6 +710,10 @@ class StreamClient {
453
710
  catch {
454
711
  return;
455
712
  }
713
+ // Valid JSON that is not a message object ("null" would throw below) is
714
+ // ignored like any other frame that is not a message.
715
+ if (typeof msg !== 'object' || msg === null)
716
+ return;
456
717
  if (typeof msg.id === 'number') {
457
718
  const p = this.pending.get(msg.id);
458
719
  if (!p)
@@ -463,7 +724,7 @@ class StreamClient {
463
724
  p.resolve(msg.result);
464
725
  else {
465
726
  const e = msg.error;
466
- p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown'));
727
+ p.reject(new errors_js_1.RpcError(e?.code ?? 'error', e?.message ?? 'unknown', e?.data));
467
728
  }
468
729
  return;
469
730
  }
@@ -481,17 +742,27 @@ class StreamClient {
481
742
  this.preferredLayers.delete(producerId);
482
743
  this.receiveQualities.delete(producerId);
483
744
  this.remoteVideoLayers.delete(producerId);
745
+ this.skippedVideos.delete(producerId);
484
746
  const consumer = this.consumers.get(producerId);
485
747
  if (!consumer)
486
748
  break; // never consumed it, or already cleaned up
749
+ this.videoSlots.delete(producerId);
487
750
  consumer.close();
488
751
  this.consumers.delete(producerId);
489
752
  this.remoteSources.delete(producerId);
753
+ this.remoteInfo.delete(producerId);
490
754
  this.emit('trackEnded', { producerId, identity });
755
+ // A video slot opened under the fan-out limit: the oldest video left
756
+ // waiting takes it.
757
+ if (consumer.kind === 'video')
758
+ void this.consumeSkipped().catch((err) => this.emit('error', err));
491
759
  break;
492
760
  }
493
761
  case 'producerPaused': {
494
762
  const p = data;
763
+ const info = this.remoteInfo.get(p.producerId);
764
+ if (info)
765
+ info.paused = Boolean(p.paused);
495
766
  this.emit('trackMuted', {
496
767
  identity: p.identity,
497
768
  kind: p.kind,
@@ -501,27 +772,40 @@ class StreamClient {
501
772
  });
502
773
  break;
503
774
  }
504
- case 'participantJoined':
505
- this.emit('participantJoined', data);
775
+ case 'participantJoined': {
776
+ const p = data;
777
+ // A reconnecting participant joins again: replace, never duplicate.
778
+ this.updateRoster((list) => [...list.filter((q) => q.identity !== p.identity), p]);
779
+ this.emit('participantJoined', p);
506
780
  break;
781
+ }
507
782
  case 'participantMuted': {
508
783
  const wire = data;
509
784
  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
- });
785
+ this.updateRoster((list) => list.map((p) => p.identity !== e.identity ? p : { ...p,
786
+ ...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
787
+ ...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
788
+ }));
515
789
  this.emit('participantMuted', e);
516
790
  break;
517
791
  }
518
- case 'participantLeft':
519
- this.emit('participantLeft', data);
792
+ case 'participantUpdated': {
793
+ const e = data;
794
+ this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, metadata: e.metadata })));
795
+ this.emit('participantUpdated', { identity: e.identity, metadata: e.metadata ?? null });
796
+ break;
797
+ }
798
+ case 'participantLeft': {
799
+ const e = data;
800
+ this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
801
+ this.emit('participantLeft', e);
520
802
  break;
803
+ }
521
804
  case 'roleChanged': {
522
805
  const e = data;
523
806
  if (e.identity === this.joinResult?.identity)
524
807
  this.joinResult.role = e.role;
808
+ this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, role: e.role })));
525
809
  this.emit('roleChanged', e);
526
810
  // Demoted: the server has already closed our producers. Release the
527
811
  // camera and microphone too, instead of capturing for nobody.
@@ -532,9 +816,13 @@ class StreamClient {
532
816
  break;
533
817
  }
534
818
  case 'handRaised':
535
- case 'handLowered':
819
+ case 'handLowered': {
820
+ const e = data;
821
+ this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p
822
+ : { ...p, handRaised: Boolean(e.handRaised), ...(e.handRaised ? { handMetadata: e.metadata } : { handMetadata: undefined }) })));
536
823
  this.emit('handRaised', data);
537
824
  break;
825
+ }
538
826
  case 'forceMuted': {
539
827
  // The server has already paused that kind (an audio mute includes
540
828
  // in-app music: it travels as audio). Reflect it locally; a release
@@ -547,20 +835,33 @@ class StreamClient {
547
835
  this.emit('forceMuted', e);
548
836
  break;
549
837
  }
838
+ case 'claimChanged': {
839
+ const e = data;
840
+ this.applyClaim(e);
841
+ this.emit('claimChanged', { key: e.key, identity: e.identity || null, state: e.state, reason: e.reason ?? '' });
842
+ break;
843
+ }
844
+ case 'roomMetadataChanged': {
845
+ const e = data;
846
+ if (this.joinResult)
847
+ this.joinResult.metadata = e.metadata;
848
+ this.emit('roomMetadataChanged', { metadata: e.metadata ?? null, ...(e.by ? { by: e.by } : {}) });
849
+ break;
850
+ }
550
851
  case 'tokenRefreshed': {
551
852
  // A room outlives its join token many times over. The server hands us a
552
853
  // fresh one periodically so a reconnect at any point still works --
553
854
  // without this, any disconnect after the token's lifetime is terminal.
554
855
  const e = data;
555
856
  if (e.token)
556
- this.token = e.token;
857
+ this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
557
858
  break;
558
859
  }
559
860
  case 'mustReconnect': {
560
861
  const e = data;
561
862
  // A promotion hands us a fresh token carrying the new role.
562
863
  if (e.token)
563
- this.token = e.token;
864
+ this.setToken(e.token, null);
564
865
  this.emit('mustReconnect', e);
565
866
  if (e.retry) {
566
867
  this.teardownMedia();
@@ -576,16 +877,27 @@ class StreamClient {
576
877
  case 'usageLimit':
577
878
  this.emit('usageLimit', d);
578
879
  break;
880
+ case 'walletLow': {
881
+ const e = data;
882
+ const remainingSeconds = typeof e?.remainingSeconds === 'number' && Number.isFinite(e.remainingSeconds) ? e.remainingSeconds : 0;
883
+ this.emit('walletLow', e?.cleared === true ? { remainingSeconds, cleared: true } : { remainingSeconds });
884
+ break;
885
+ }
579
886
  case 'roomClosed':
580
887
  this.closedByUs = true;
581
888
  this.emit('roomClosed', d);
582
- this.emit('closed', 'room-closed');
889
+ this.finish('room-closed');
890
+ // Before close() fails what is pending: an endRoom() this event
891
+ // answers succeeded.
892
+ for (const ended of this.endRoomWaiters)
893
+ ended();
894
+ this.endRoomWaiters.clear();
583
895
  this.close();
584
896
  break;
585
897
  case 'kicked':
586
898
  this.closedByUs = true;
587
899
  this.emit('kicked', d);
588
- this.emit('closed', 'kicked');
900
+ this.finish('kicked');
589
901
  this.close();
590
902
  break;
591
903
  case 'message':
@@ -604,6 +916,25 @@ class StreamClient {
604
916
  break;
605
917
  }
606
918
  }
919
+ /** Change the roster the join result holds; a new array each time, so a renderer sees the change. */
920
+ updateRoster(change) {
921
+ if (this.joinResult)
922
+ this.joinResult.participants = change(this.joinResult.participants ?? []);
923
+ }
924
+ /** Keep the join result's slots current: claims held or away, and the moderator's locks. */
925
+ applyClaim(e) {
926
+ const join = this.joinResult;
927
+ if (!join)
928
+ return;
929
+ const claims = (join.claims ?? []).filter((c) => c.key !== e.key);
930
+ let blocked = (join.blockedSlots ?? []).filter((key) => key !== e.key);
931
+ if ((e.state === 'held' || e.state === 'away') && e.identity)
932
+ claims.push({ key: e.key, identity: e.identity, state: e.state });
933
+ if (e.state === 'blocked')
934
+ blocked = [...blocked, e.key];
935
+ join.claims = claims;
936
+ join.blockedSlots = blocked;
937
+ }
607
938
  // ------------------------------------------------------------------ media
608
939
  ensureSendTransport() {
609
940
  if (this.sendTransport)
@@ -617,7 +948,7 @@ class StreamClient {
617
948
  }
618
949
  async createSendTransport() {
619
950
  if (!this.device)
620
- throw new Error('not connected');
951
+ throw new errors_js_1.RpcError('not-connected', 'not connected');
621
952
  const params = (await this.rpc('createTransport', { direction: 'send' }));
622
953
  const transport = this.device.createSendTransport({
623
954
  ...params,
@@ -629,7 +960,10 @@ class StreamClient {
629
960
  .catch(fail);
630
961
  });
631
962
  transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
632
- this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source, videoLayers: appData.videoLayers })
963
+ this.rpc('produce', {
964
+ transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
965
+ appData, source: appData.source, videoLayers: appData.videoLayers,
966
+ })
633
967
  .then((res) => ok({ id: res.producerId }))
634
968
  .catch(fail);
635
969
  });
@@ -648,7 +982,7 @@ class StreamClient {
648
982
  }
649
983
  async createRecvTransport() {
650
984
  if (!this.device)
651
- throw new Error('not connected');
985
+ throw new errors_js_1.RpcError('not-connected', 'not connected');
652
986
  const params = (await this.rpc('createTransport', { direction: 'recv' }));
653
987
  const transport = this.device.createRecvTransport({
654
988
  ...params,
@@ -675,12 +1009,24 @@ class StreamClient {
675
1009
  * kept for a reconnect's republish.
676
1010
  */
677
1011
  async publish(stream, constraints, options) {
1012
+ // A kind the project switched off is refused here, before anything is
1013
+ // produced, with the code the controller would answer with.
1014
+ const permissions = this.mediaPermissions;
1015
+ if (!permissions.audio && stream.getAudioTracks().length > 0) {
1016
+ throw new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project');
1017
+ }
1018
+ if (!permissions.video && stream.getVideoTracks().length > 0) {
1019
+ throw new errors_js_1.RpcError('media-disabled', 'video is switched off for this project');
1020
+ }
1021
+ await this.publishStream(stream, constraints, options);
1022
+ }
1023
+ async publishStream(stream, constraints, options) {
678
1024
  const generation = this.publishGeneration;
679
1025
  const transport = await this.ensureSendTransport();
680
1026
  // unpublish() ran first: this publish is over before it began, and must
681
1027
  // leave nothing behind that a reconnect would publish again.
682
1028
  if (generation !== this.publishGeneration)
683
- throw new Error(unpublishedWhile('stream'));
1029
+ throw unpublishedWhile('stream');
684
1030
  this.localStream = stream;
685
1031
  this.wasPublishing = true;
686
1032
  if (constraints)
@@ -689,35 +1035,140 @@ class StreamClient {
689
1035
  this.publishOptions = { ...options };
690
1036
  this.frameRateFallback = false;
691
1037
  this.frameRateLowSamples = 0;
1038
+ this.audioCapReported = false;
1039
+ this.sizeCapReported = false;
692
1040
  }
1041
+ // Only a reconnect's republish gets here with a kind switched off since:
1042
+ // what is still allowed goes out, and the rest is reported.
1043
+ const permissions = this.mediaPermissions;
693
1044
  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);
1045
+ if (audio && !permissions.audio) {
1046
+ this.emit('error', new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project; the microphone is not published'));
1047
+ }
1048
+ else if (audio) {
1049
+ await this.micTask(() => this.produceMic(transport, this.localStream?.getAudioTracks()[0] ?? audio, generation));
706
1050
  }
707
1051
  const video = stream.getVideoTracks()[0];
708
- if (video) {
1052
+ if (video && !permissions.video) {
1053
+ this.emit('error', new errors_js_1.RpcError('media-disabled', 'video is switched off for this project; the camera is not published'));
1054
+ }
1055
+ else if (video) {
709
1056
  // The track is read when the camera's turn comes: one handed to
710
1057
  // replaceVideoTrack while this publish was under way is the one to send.
711
1058
  await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
712
1059
  }
713
1060
  }
1061
+ // ------------------------------------------------------------ audio quality
1062
+ /**
1063
+ * The microphone's audio quality: what it is being sent at while
1064
+ * published, else what the next publish will use, after the project's limit.
1065
+ */
1066
+ get audioQuality() {
1067
+ if (this.micProducer())
1068
+ return this.micQuality;
1069
+ return this.publishOptions.audioQuality === 'hd' && this.mediaPermissions.audioQualityMax === 'hd' ? 'hd' : 'std';
1070
+ }
1071
+ /**
1072
+ * Switch the microphone between standard (mono, up to 64 kbps) and HD
1073
+ * (stereo, up to 128 kbps) while live. The codec settings are fixed per
1074
+ * producer, so the microphone's producer is replaced: listeners hear a gap
1075
+ * of a fraction of a second, and a microphone that is off stays off. Rejects
1076
+ * with `audio-quality-not-allowed` when the project allows standard audio
1077
+ * only. Resolves to the quality in use.
1078
+ */
1079
+ async setAudioQuality(quality) {
1080
+ if (quality !== 'std' && quality !== 'hd')
1081
+ throw new RangeError("setAudioQuality: expected 'std' or 'hd'");
1082
+ if (quality === 'hd' && this.mediaPermissions.audioQualityMax !== 'hd') {
1083
+ throw new errors_js_1.RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project');
1084
+ }
1085
+ this.publishOptions = { ...this.publishOptions, audioQuality: quality };
1086
+ this.audioCapReported = false;
1087
+ await this.micTask(async () => {
1088
+ const current = this.micProducer();
1089
+ const track = current?.track;
1090
+ if (!current || !track || track.readyState !== 'live' || this.micQuality === quality)
1091
+ return;
1092
+ await this.reproduceMic(current, track);
1093
+ });
1094
+ return this.audioQuality;
1095
+ }
1096
+ micProducer() {
1097
+ for (const p of this.producers.values()) {
1098
+ if (p.kind === 'audio' && p.appData.source === 'mic')
1099
+ return p;
1100
+ }
1101
+ return undefined;
1102
+ }
1103
+ micTask(task) {
1104
+ const run = this.micQueue.then(task, task);
1105
+ this.micQueue = run.catch(() => undefined);
1106
+ return run;
1107
+ }
1108
+ /** The audio quality to send: the request, capped to the project's, with the cap reported once. */
1109
+ audioQualityFor() {
1110
+ if (this.publishOptions.audioQuality !== 'hd')
1111
+ return 'std';
1112
+ if (this.mediaPermissions.audioQualityMax === 'hd')
1113
+ return 'hd';
1114
+ if (!this.audioCapReported) {
1115
+ this.audioCapReported = true;
1116
+ this.emit('error', new errors_js_1.RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project; publishing standard audio'));
1117
+ }
1118
+ return 'std';
1119
+ }
1120
+ /**
1121
+ * Produce the microphone at the audio quality in force. A track handed over
1122
+ * switched off (or paused before a reconnect or a quality change) starts
1123
+ * paused, on both ends.
1124
+ */
1125
+ async produceMic(transport, track, generation) {
1126
+ if (generation !== this.publishGeneration)
1127
+ throw unpublishedWhile('microphone');
1128
+ const quality = this.audioQualityFor();
1129
+ const settings = media_permissions_js_1.AUDIO_SETTINGS[quality];
1130
+ const producer = await transport.produce({
1131
+ track,
1132
+ // Keep the track alive when the producer closes: a reconnect closes
1133
+ // every producer, and a stopped track cannot be republished.
1134
+ stopTracks: false,
1135
+ // The answer's maxaveragebitrate sets the encoder's rate and the
1136
+ // sender's maxBitrate holds it there: standard must stay at or under
1137
+ // 64 kbps, which is what the server measures to bill it, so its sender
1138
+ // is held a little under the declared rate (Opus VBR overshoots).
1139
+ encodings: [{ maxBitrate: settings.senderMaxBitrate }],
1140
+ codecOptions: { opusStereo: settings.stereo, opusDtx: true, opusFec: true, opusMaxAverageBitrate: settings.maxBitrate },
1141
+ appData: { source: 'mic', audioQuality: quality },
1142
+ });
1143
+ await this.keepProducer(producer, generation, 'microphone');
1144
+ this.micQuality = quality;
1145
+ // A microphone handed over switched off, or muted before a reconnect.
1146
+ await this.pauseOnServer(producer);
1147
+ return producer;
1148
+ }
1149
+ /** Close the microphone producer and produce its track again at the quality now in force. */
1150
+ async reproduceMic(current, track) {
1151
+ const generation = this.publishGeneration;
1152
+ current.close();
1153
+ this.producers.delete(current.id);
1154
+ // The server allows one producer per source: the old one goes first.
1155
+ await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
1156
+ return this.produceMic(await this.ensureSendTransport(), track, generation);
1157
+ }
714
1158
  /** What this project may publish. Known once joined; 1080p before that. */
715
1159
  get videoPolicy() {
716
1160
  return this.joinResult?.video ?? quality_js_1.DEFAULT_VIDEO_POLICY;
717
1161
  }
718
- /** The quality video is being sent at, after the project's limit. */
1162
+ /**
1163
+ * The quality video is being sent at, after the project's limit: at 60 fps
1164
+ * the largest size the project allows at 60, otherwise its 30 fps maximum.
1165
+ */
719
1166
  get videoQuality() {
720
- return (0, quality_js_1.allowedQuality)(this.publishOptions.quality, this.videoPolicy);
1167
+ return (0, media_permissions_js_1.qualityAt)(this.publishOptions.quality, this.videoFrameRate, this.mediaPermissions);
1168
+ }
1169
+ /** The camera's frame rate: what it is being sent at while published, else 30 until a publish decides. */
1170
+ get videoFrameRate() {
1171
+ return this.cameraProducer() ? this.cameraFrameRate : 30;
721
1172
  }
722
1173
  /**
723
1174
  * Change the video quality while publishing. The camera's producer is
@@ -727,6 +1178,7 @@ class StreamClient {
727
1178
  */
728
1179
  async setVideoQuality(quality) {
729
1180
  this.publishOptions = { ...this.publishOptions, quality };
1181
+ this.sizeCapReported = false;
730
1182
  await this.cameraTask(async () => {
731
1183
  const current = this.cameraProducer();
732
1184
  const track = current?.track;
@@ -736,13 +1188,25 @@ class StreamClient {
736
1188
  });
737
1189
  return this.videoQuality;
738
1190
  }
739
- /** Change the camera frame rate without stopping screen, microphone or music. */
1191
+ /**
1192
+ * Change the camera frame rate without stopping screen, microphone or music.
1193
+ * 60 where the project allows none stays at 30 and warns on `error` with
1194
+ * `frame-rate-not-allowed`, without touching the camera; a camera or device
1195
+ * that cannot keep 60 up falls back with `frame-rate-fallback`. Resolves to
1196
+ * the frame rate used.
1197
+ */
740
1198
  async setVideoFrameRate(frameRate) {
741
1199
  if (frameRate !== 30 && frameRate !== 60)
742
1200
  throw new RangeError('setVideoFrameRate: expected 30 or 60');
743
1201
  this.publishOptions = { ...this.publishOptions, frameRate };
744
1202
  this.frameRateFallback = false;
745
1203
  this.frameRateLowSamples = 0;
1204
+ this.sizeCapReported = false;
1205
+ if (frameRate === 60 && !this.allows60()) {
1206
+ this.frameRateFallback = true;
1207
+ this.emit('error', new errors_js_1.RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
1208
+ return this.videoFrameRate;
1209
+ }
746
1210
  await this.cameraTask(async () => {
747
1211
  const current = this.cameraProducer();
748
1212
  const track = current?.track;
@@ -754,7 +1218,24 @@ class StreamClient {
754
1218
  catch { /* Existing capture settings decide whether 60 fps is possible. */ }
755
1219
  await this.reproduceCamera(current, track);
756
1220
  });
757
- return this.cameraProducer() ? this.cameraFrameRate : this.videoPolicy.maxFrameRate === 30 ? 30 : frameRate;
1221
+ return this.cameraProducer() ? this.cameraFrameRate : this.allows60() ? frameRate : 30;
1222
+ }
1223
+ /** Whether this project allows any 60 fps mode. */
1224
+ allows60() {
1225
+ const permissions = this.mediaPermissions;
1226
+ return permissions.maxFrameRate === 60 && permissions.maxVideoQuality60 !== null;
1227
+ }
1228
+ /**
1229
+ * The quality to send at a frame rate. A request above what 60 fps allows
1230
+ * is sent at the largest size it does allow, and said so once.
1231
+ */
1232
+ cameraQuality(frameRate) {
1233
+ const permissions = this.mediaPermissions;
1234
+ if (frameRate === 60 && !this.sizeCapReported && (0, media_permissions_js_1.above60fpsCap)(this.publishOptions.quality, permissions)) {
1235
+ this.sizeCapReported = true;
1236
+ 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`));
1237
+ }
1238
+ return (0, media_permissions_js_1.qualityAt)(this.publishOptions.quality, frameRate, permissions);
758
1239
  }
759
1240
  /**
760
1241
  * Send a different video track from now on: a canvas with effects drawn on
@@ -800,7 +1281,8 @@ class StreamClient {
800
1281
  const paused = current.paused;
801
1282
  try {
802
1283
  const { width = 0, height = 0 } = track.getSettings();
803
- const next = (0, quality_js_1.videoEncodings)(width, height, this.videoQuality, this.layerCount(), this.frameRateFor(track));
1284
+ const frameRate = this.frameRateFor(track);
1285
+ const next = (0, quality_js_1.videoEncodings)(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
804
1286
  const previousSize = current.track?.getSettings();
805
1287
  if (this.cameraEncodings && (0, quality_js_1.sameLayout)(this.cameraEncodings, next) &&
806
1288
  previousSize?.width === width && previousSize?.height === height) {
@@ -942,7 +1424,7 @@ class StreamClient {
942
1424
  layers = { spatialLayer: (0, quality_js_1.receiveSpatialLayer)(this.remoteVideoLayers.get(producerId) ?? [], quality) };
943
1425
  }
944
1426
  catch {
945
- throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto');
1427
+ throw new errors_js_1.RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto');
946
1428
  }
947
1429
  }
948
1430
  this.receiveQualities.set(producerId, quality);
@@ -993,7 +1475,7 @@ class StreamClient {
993
1475
  */
994
1476
  async produceCamera(transport, track, generation, paused = !track.enabled) {
995
1477
  if (generation !== this.publishGeneration)
996
- throw new Error(unpublishedWhile('camera'));
1478
+ throw unpublishedWhile('camera');
997
1479
  // mediasoup-client starts a producer paused exactly when its track is
998
1480
  // off. Setting the track to the camera's state first makes the producer
999
1481
  // start in it, and a camera that is off never sends a frame of the new
@@ -1004,8 +1486,9 @@ class StreamClient {
1004
1486
  // the device, so the browser sends every layer asked for (see
1005
1487
  // videoEncodings).
1006
1488
  const { width = 0, height = 0 } = track.getSettings();
1007
- const quality = this.videoQuality;
1489
+ // The frame rate first: the size the project allows depends on it.
1008
1490
  this.cameraFrameRate = this.frameRateFor(track);
1491
+ const quality = this.cameraQuality(this.cameraFrameRate);
1009
1492
  const encodings = (0, quality_js_1.videoEncodings)(width, height, quality, this.layerCount(), this.cameraFrameRate);
1010
1493
  let codecs;
1011
1494
  try {
@@ -1055,7 +1538,7 @@ class StreamClient {
1055
1538
  if (generation !== this.publishGeneration) {
1056
1539
  producer.close();
1057
1540
  await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1058
- throw new Error(unpublishedWhile(what));
1541
+ throw unpublishedWhile(what);
1059
1542
  }
1060
1543
  this.producers.set(producer.id, producer);
1061
1544
  }
@@ -1094,7 +1577,7 @@ class StreamClient {
1094
1577
  /** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
1095
1578
  reportCodecFallback(message) {
1096
1579
  if ((0, device_js_1.normalizeCodec)(this.publishOptions.codec) === 'h264') {
1097
- this.emit('error', new Error(message));
1580
+ this.emit('error', new errors_js_1.RpcError('codec-fallback', message));
1098
1581
  }
1099
1582
  else if (!this.codecFallbackLogged) {
1100
1583
  this.codecFallbackLogged = true;
@@ -1152,7 +1635,7 @@ class StreamClient {
1152
1635
  const live = stream?.getTracks().some((t) => t.readyState === 'live') ?? false;
1153
1636
  if (!live) {
1154
1637
  if (!this.publishConstraints) {
1155
- this.emit('error', new Error('cannot restore the broadcast: the devices are gone'));
1638
+ this.emit('error', new errors_js_1.RpcError('devices-gone', 'cannot restore the broadcast: the devices are gone'));
1156
1639
  return;
1157
1640
  }
1158
1641
  const ended = stream;
@@ -1174,20 +1657,22 @@ class StreamClient {
1174
1657
  }
1175
1658
  if (!stream)
1176
1659
  return;
1177
- await this.publish(stream);
1660
+ await this.publishStream(stream);
1178
1661
  }
1179
1662
  // ------------------------------------------------------------- screen sharing
1180
1663
  /** Publish a screen beside the camera. Call from a click so capture permission can open. */
1181
1664
  async publishScreen(options = {}) {
1182
1665
  if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost')
1183
- throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
1666
+ throw new errors_js_1.RpcError('forbidden', 'only hosts and cohosts can share a screen');
1667
+ if (!this.mediaPermissions.video)
1668
+ throw new errors_js_1.RpcError('media-disabled', 'video is switched off for this project');
1184
1669
  if (this.screenStream || this.screenPending)
1185
- throw new RpcError('screen-share-active', 'a screen share is already active');
1670
+ throw new errors_js_1.RpcError('screen-share-active', 'a screen share is already active');
1186
1671
  if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion')
1187
1672
  throw new TypeError('publishScreen: mode must be detail or motion');
1188
1673
  const mode = options.mode ?? 'detail';
1189
1674
  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');
1675
+ throw new errors_js_1.RpcError('screen-share-unsupported', 'screen capture is not supported by this browser; use a supported desktop browser');
1191
1676
  }
1192
1677
  const generation = this.screenGeneration;
1193
1678
  this.screenPending = true;
@@ -1199,9 +1684,9 @@ class StreamClient {
1199
1684
  });
1200
1685
  const video = stream.getVideoTracks()[0];
1201
1686
  if (!video || video.readyState !== 'live')
1202
- throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
1687
+ throw new errors_js_1.RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
1203
1688
  if (generation !== this.screenGeneration || this.closedByUs)
1204
- throw new Error(unpublishedWhile('screen'));
1689
+ throw unpublishedWhile('screen');
1205
1690
  this.screenStream = stream;
1206
1691
  this.screenMode = mode;
1207
1692
  this.screenAudioEnabled = options.audio !== false;
@@ -1211,7 +1696,7 @@ class StreamClient {
1211
1696
  video.addEventListener('ended', onEnded, { once: true });
1212
1697
  await this.restoreScreen();
1213
1698
  if (generation !== this.screenGeneration || video.readyState !== 'live')
1214
- throw new Error(unpublishedWhile('screen'));
1699
+ throw unpublishedWhile('screen');
1215
1700
  this.screenStarted = true;
1216
1701
  this.wasPublishing = true;
1217
1702
  this.emit('screenShareStarted', { stream, mode });
@@ -1275,7 +1760,7 @@ class StreamClient {
1275
1760
  const { width = 0, height = 0 } = video.getSettings();
1276
1761
  const encodings = (0, quality_js_1.screenEncodings)(width, height, this.screenMode);
1277
1762
  // A project's explicit safety restriction also applies to screen video.
1278
- const cap = (0, quality_js_1.topLayerSize)(width, height, this.videoPolicy.maxQuality);
1763
+ const cap = (0, quality_js_1.topLayerSize)(width, height, this.mediaPermissions.maxVideoQuality);
1279
1764
  const screenScale = encodings.at(-1).scaleResolutionDownBy;
1280
1765
  if (cap.scale > screenScale)
1281
1766
  encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
@@ -1283,7 +1768,7 @@ class StreamClient {
1283
1768
  if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
1284
1769
  producer.close();
1285
1770
  await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1286
- throw new Error(unpublishedWhile('screen'));
1771
+ throw unpublishedWhile('screen');
1287
1772
  }
1288
1773
  this.producers.set(producer.id, producer);
1289
1774
  this.screenProducerIds.add(producer.id);
@@ -1294,7 +1779,12 @@ class StreamClient {
1294
1779
  codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
1295
1780
  appData: { source: 'screen', videoLayers: (0, quality_js_1.describeVideoLayers)(width, height, encodings) },
1296
1781
  }));
1297
- const audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
1782
+ let audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
1783
+ if (audio && this.screenAudioEnabled && !this.mediaPermissions.audio) {
1784
+ // The screen goes out; its sound cannot in a project without audio.
1785
+ this.emit('error', new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project; the screen is shared without its sound'));
1786
+ audio = undefined;
1787
+ }
1298
1788
  if (audio && this.screenAudioEnabled && generation === this.screenGeneration)
1299
1789
  await keep(await transport.produce({
1300
1790
  track: audio, stopTracks: false,
@@ -1302,13 +1792,25 @@ class StreamClient {
1302
1792
  appData: { source: 'screen-audio' },
1303
1793
  }));
1304
1794
  }
1795
+ /**
1796
+ * 60 only when asked for, allowed by the project, captured at 50 or more and
1797
+ * not fallen back from. The project's refusal and the camera's are reported
1798
+ * apart, each once: `frame-rate-not-allowed` is the server's own code for a
1799
+ * 60 fps producer the project does not allow, `frame-rate-fallback` means
1800
+ * the camera or device cannot provide it.
1801
+ */
1305
1802
  frameRateFor(track) {
1306
1803
  if (this.publishOptions.frameRate !== 60 || this.frameRateFallback)
1307
1804
  return 30;
1805
+ if (!this.allows60()) {
1806
+ this.frameRateFallback = true;
1807
+ this.emit('error', new errors_js_1.RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
1808
+ return 30;
1809
+ }
1308
1810
  const captured = track.getSettings().frameRate;
1309
- if (this.videoPolicy.maxFrameRate === 30 || (typeof captured === 'number' && captured < 50)) {
1811
+ if (typeof captured === 'number' && captured < 50) {
1310
1812
  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'));
1813
+ this.emit('error', new errors_js_1.RpcError('frame-rate-fallback', '60 fps is unavailable from this camera; publishing at 30 fps'));
1312
1814
  return 30;
1313
1815
  }
1314
1816
  return 60;
@@ -1338,7 +1840,7 @@ class StreamClient {
1338
1840
  this.frameRateFallback = true;
1339
1841
  await producer.track.applyConstraints?.({ ...producer.track.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
1340
1842
  await this.reproduceCamera(producer, producer.track);
1341
- this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
1843
+ this.emit('error', new errors_js_1.RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
1342
1844
  }).catch((error) => this.emit('error', error));
1343
1845
  }
1344
1846
  return stats;
@@ -1355,6 +1857,8 @@ class StreamClient {
1355
1857
  * the user has not interacted with.
1356
1858
  */
1357
1859
  async publishMusic(input, options) {
1860
+ if (!this.mediaPermissions.audio)
1861
+ throw new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project');
1358
1862
  const generation = this.publishGeneration;
1359
1863
  if (this.music)
1360
1864
  await this.stopMusic();
@@ -1365,7 +1869,7 @@ class StreamClient {
1365
1869
  // already closed it, and the music must not be remembered for a
1366
1870
  // reconnect to publish again.
1367
1871
  if (generation !== this.publishGeneration)
1368
- throw new Error(unpublishedWhile('music'));
1872
+ throw unpublishedWhile('music');
1369
1873
  }
1370
1874
  catch (err) {
1371
1875
  music.close();
@@ -1378,7 +1882,7 @@ class StreamClient {
1378
1882
  async produceMusic(music, generation) {
1379
1883
  const transport = await this.ensureSendTransport();
1380
1884
  if (generation !== this.publishGeneration)
1381
- throw new Error(unpublishedWhile('music'));
1885
+ throw unpublishedWhile('music');
1382
1886
  const producer = await transport.produce({
1383
1887
  track: music.track,
1384
1888
  stopTracks: false,
@@ -1438,13 +1942,49 @@ class StreamClient {
1438
1942
  }
1439
1943
  async consume(remote) {
1440
1944
  if (!this.device)
1441
- throw new Error('not connected');
1945
+ throw new errors_js_1.RpcError('not-connected', 'not connected');
1442
1946
  if (this.consumers.has(remote.producerId))
1443
1947
  return;
1444
1948
  // Your own camera is already local; pulling it back off the SFU wastes a
1445
1949
  // consumer and shows you a delayed copy of yourself.
1446
1950
  if (remote.identity === this.joinResult?.identity)
1447
1951
  return;
1952
+ // A wallet session receives at most media.fanout.maxVideoSubscriptions
1953
+ // videos; the media node refuses the next. Wait for a slot instead of
1954
+ // asking for a refusal, and say so once per video.
1955
+ if (remote.kind === 'video' && !this.videoSlots.has(remote.producerId)) {
1956
+ const limit = this.mediaPermissions.maxVideoSubscriptions;
1957
+ if (limit !== null && this.videoSlots.size >= limit) {
1958
+ if (!this.skippedVideos.has(remote.producerId)) {
1959
+ this.skippedVideos.set(remote.producerId, remote);
1960
+ 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`));
1961
+ }
1962
+ return;
1963
+ }
1964
+ this.videoSlots.add(remote.producerId);
1965
+ }
1966
+ try {
1967
+ await this.consumeNow(remote);
1968
+ }
1969
+ catch (err) {
1970
+ if (!this.consumers.has(remote.producerId))
1971
+ this.videoSlots.delete(remote.producerId);
1972
+ throw err;
1973
+ }
1974
+ }
1975
+ /** The oldest videos left waiting at the fan-out limit take the slots that opened. */
1976
+ async consumeSkipped() {
1977
+ for (const [producerId, remote] of this.skippedVideos) {
1978
+ const limit = this.mediaPermissions.maxVideoSubscriptions;
1979
+ if (limit !== null && this.videoSlots.size >= limit)
1980
+ return;
1981
+ this.skippedVideos.delete(producerId);
1982
+ await this.consume(remote);
1983
+ }
1984
+ }
1985
+ async consumeNow(remote) {
1986
+ if (!this.device)
1987
+ throw new errors_js_1.RpcError('not-connected', 'not connected');
1448
1988
  const transport = await this.ensureRecvTransport();
1449
1989
  const params = (await this.rpc('consume', {
1450
1990
  transportId: transport.id,
@@ -1465,7 +2005,7 @@ class StreamClient {
1465
2005
  }
1466
2006
  catch {
1467
2007
  layers = undefined;
1468
- this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
2008
+ this.emit('error', new errors_js_1.RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
1469
2009
  }
1470
2010
  }
1471
2011
  if (layers) {
@@ -1477,6 +2017,7 @@ class StreamClient {
1477
2017
  const kind = consumer.kind;
1478
2018
  const source = remote.source ?? defaultSource(kind);
1479
2019
  this.remoteSources.set(remote.producerId, source);
2020
+ this.remoteInfo.set(remote.producerId, { identity: remote.identity, kind, paused: Boolean(remote.paused) });
1480
2021
  this.emit('track', {
1481
2022
  identity: remote.identity,
1482
2023
  kind,
@@ -1619,8 +2160,72 @@ class StreamClient {
1619
2160
  }
1620
2161
  promote(identity, role) { return this.rpc('promote', { identity, role }); }
1621
2162
  kick(identity, reason) { return this.rpc('kick', { identity, reason }); }
1622
- endRoom() { return this.rpc('endRoom'); }
2163
+ /**
2164
+ * End the room for everyone. The server tells the room it has closed
2165
+ * before it replies, and that roomClosed closes this session, so the reply
2166
+ * may never be read: roomClosed is then the answer, and endRoom() resolves
2167
+ * as the reply would have, with `{ ok: true }`. It still fails when the
2168
+ * server refuses, or the connection drops with neither.
2169
+ */
2170
+ endRoom() {
2171
+ return new Promise((resolve, reject) => {
2172
+ const ended = () => resolve({ ok: true });
2173
+ this.endRoomWaiters.add(ended);
2174
+ this.rpc('endRoom').then(resolve, reject).finally(() => this.endRoomWaiters.delete(ended));
2175
+ });
2176
+ }
1623
2177
  getRoomState() { return this.rpc('getRoomState'); }
2178
+ // ------------------------------------------------------- slots and metadata
2179
+ //
2180
+ // A slot is a named thing in the room that at most one participant holds at
2181
+ // a time: a seat on a board, a guest spot. Keys are yours; the server never
2182
+ // learns that `seat-13` is a seat. `info.claims` and `info.blockedSlots`
2183
+ // seed a board on join and follow claimChanged.
2184
+ /**
2185
+ * Take a slot, or (moderators) grant one by naming `identity`. One claim
2186
+ * per identity: a second moves you. Refused with `slot-taken` when someone
2187
+ * holds it (away included) and `slot-blocked` when a moderator locked it.
2188
+ * Claim first, then promote: if the slot has gone, nothing has happened yet.
2189
+ */
2190
+ async claim(key, identity) {
2191
+ await this.rpc('claim', identity ? { key, identity } : { key });
2192
+ }
2193
+ /** Give up a slot. Moderators may release anyone's. */
2194
+ async release(key) {
2195
+ await this.rpc('release', { key });
2196
+ }
2197
+ /**
2198
+ * Lock a slot so nobody may claim it. Moderators only. Refused with
2199
+ * `slot-occupied` while someone holds it: taking a seat from a person using
2200
+ * it is two deliberate acts, not a side effect of one tap.
2201
+ */
2202
+ async blockSlot(key) {
2203
+ await this.rpc('blockSlot', { key });
2204
+ }
2205
+ /** Unlock a slot. Moderators only. */
2206
+ async unblockSlot(key) {
2207
+ await this.rpc('unblockSlot', { key });
2208
+ }
2209
+ /** Replace your own display metadata; the room hears it as participantUpdated. */
2210
+ async setMetadata(metadata) {
2211
+ await this.rpc('setMetadata', { metadata });
2212
+ }
2213
+ /**
2214
+ * Change room-scoped metadata. Moderators only, and persisted, so a client
2215
+ * joining later reads it from `info.metadata`. Merges one key deep by
2216
+ * default (a null value deletes its key); `replace` swaps the whole object.
2217
+ * `requireFree` names slots that must be unheld for the change to apply,
2218
+ * checked and applied together: the refusal (`slot-occupied`) names the
2219
+ * offending keys in `RpcError.data.slots`. Resolves to the metadata now.
2220
+ */
2221
+ async setRoomMetadata(metadata, options = {}) {
2222
+ const res = (await this.rpc('setRoomMetadata', {
2223
+ metadata,
2224
+ ...(options.replace ? { replace: true } : {}),
2225
+ ...(options.requireFree ? { requireFree: options.requireFree } : {}),
2226
+ }));
2227
+ return res?.metadata ?? null;
2228
+ }
1624
2229
  // ------------------------------------------------------------------- chat
1625
2230
  //
1626
2231
  // The room's chat rides this same socket. Someone on a chat-only socket