@voltkit-rtc/client 0.6.1 → 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 (57) hide show
  1. package/CHANGELOG.md +68 -2
  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 +654 -118
  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/token.d.ts +17 -0
  24. package/dist/cjs/token.d.ts.map +1 -0
  25. package/dist/cjs/token.js +36 -0
  26. package/dist/cjs/token.js.map +1 -0
  27. package/dist/esm/chat.d.ts +0 -9
  28. package/dist/esm/chat.d.ts.map +1 -1
  29. package/dist/esm/chat.js +16 -14
  30. package/dist/esm/chat.js.map +1 -1
  31. package/dist/esm/errors.d.ts +571 -0
  32. package/dist/esm/errors.d.ts.map +1 -0
  33. package/dist/esm/errors.js +149 -0
  34. package/dist/esm/errors.js.map +1 -0
  35. package/dist/esm/index.d.ts +298 -16
  36. package/dist/esm/index.d.ts.map +1 -1
  37. package/dist/esm/index.js +633 -109
  38. package/dist/esm/index.js.map +1 -1
  39. package/dist/esm/media-permissions.d.ts +101 -0
  40. package/dist/esm/media-permissions.d.ts.map +1 -0
  41. package/dist/esm/media-permissions.js +160 -0
  42. package/dist/esm/media-permissions.js.map +1 -0
  43. package/dist/esm/network.d.ts +80 -0
  44. package/dist/esm/network.d.ts.map +1 -0
  45. package/dist/esm/network.js +183 -0
  46. package/dist/esm/network.js.map +1 -0
  47. package/dist/esm/token.d.ts +17 -0
  48. package/dist/esm/token.d.ts.map +1 -0
  49. package/dist/esm/token.js +31 -0
  50. package/dist/esm/token.js.map +1 -0
  51. package/package.json +1 -1
  52. package/src/chat.ts +7 -14
  53. package/src/errors.ts +162 -0
  54. package/src/index.ts +775 -109
  55. package/src/media-permissions.ts +220 -0
  56. package/src/network.ts +212 -0
  57. package/src/token.ts +31 -0
package/src/index.ts CHANGED
@@ -19,6 +19,19 @@ type Transport = ms.Transport;
19
19
  type TransportOptions = ms.TransportOptions;
20
20
 
21
21
  import type { ChatEvents, ChatMessage, ChatState, SendMessageOptions } from './chat.js';
22
+ import { isTerminalError, RpcError } from './errors.js';
23
+ import {
24
+ above60fpsCap,
25
+ AUDIO_SETTINGS,
26
+ qualityAt,
27
+ resolvePermissions,
28
+ type AudioQuality,
29
+ type JoinBilling,
30
+ type JoinMedia,
31
+ type MediaPermissions,
32
+ } from './media-permissions.js';
33
+ import { NetworkQualityWatch, type NetworkQuality } from './network.js';
34
+ import { socketUrl, TOKEN_EXPIRING_LEAD_MS, tokenExpiresAt } from './token.js';
22
35
  import {
23
36
  chooseCodec,
24
37
  currentEnv,
@@ -66,6 +79,13 @@ export { AudioMixer, MusicSource } from './music.js';
66
79
  export type { MusicInput, MusicOptions, TrackSource } from './music.js';
67
80
  export type { BroadcastEvents, BroadcastStartOptions, BroadcastState } from './broadcast.js';
68
81
  export { ChatClient, ChatRpcError } from './chat.js';
82
+ export { RpcError, ERROR_CODES, errorInfo, isTerminalError } from './errors.js';
83
+ export type { ErrorCode, ErrorInfo, ErrorOrigin } from './errors.js';
84
+ export { AUDIO_QUALITIES, AUDIO_SETTINGS, DEFAULT_MEDIA_PERMISSIONS, resolvePermissions } from './media-permissions.js';
85
+ export type { AudioQuality, BillingMode, JoinBilling, JoinMedia, MediaPermissions, MediaProfile } from './media-permissions.js';
86
+ export { NETWORK_QUALITY_INTERVAL_MS, NETWORK_QUALITY_LEVELS, networkLevel } from './network.js';
87
+ export type { NetworkQuality, NetworkQualityLevel } from './network.js';
88
+ export { TOKEN_EXPIRING_LEAD_MS, tokenExpiresAt } from './token.js';
69
89
  export { allowedQuality, layerBitrate, videoEncodings, VIDEO_QUALITIES } from './quality.js';
70
90
  export type { LayerCount, VideoEncoding, VideoPolicy, VideoQuality, VideoLayer, ReceiveQuality, ScreenMode } from './quality.js';
71
91
  export type { CodecPreference } from './device.js';
@@ -89,6 +109,59 @@ export interface UsageLimitNotice {
89
109
  endsAt: number;
90
110
  }
91
111
 
112
+ /**
113
+ * Wallet billing: what the project may still spend covers less than the
114
+ * grace period at the current burn, or (`cleared: true`) a recharge or a
115
+ * lower burn lifted it back above it.
116
+ */
117
+ export interface WalletLowNotice {
118
+ /** Seconds the funds cover at the current burn; -1 on a cleared notice when nothing limits them. */
119
+ remainingSeconds: number;
120
+ /** True once the warning is over: hide the banner, and the `usageLimit` countdown sent beside it is cancelled. */
121
+ cleared?: boolean;
122
+ }
123
+
124
+ /** The token this client would reconnect with lapses soon, and nobody has refreshed it. */
125
+ export interface TokenExpiringNotice {
126
+ /** When it expires, in ms since the epoch. */
127
+ expiresAt: number;
128
+ secondsLeft: number;
129
+ }
130
+
131
+ /** Where a session stands, in the order a session goes through them. `closed` is final; the `closed` event says why. */
132
+ export const CONNECTION_STATES = Object.freeze(['idle', 'connecting', 'connected', 'reconnecting', 'closed'] as const);
133
+ export type ConnectionState = (typeof CONNECTION_STATES)[number];
134
+
135
+ export type ClosedReason = 'replaced' | 'kicked' | 'room-closed' | 'gave-up' | 'left';
136
+
137
+ /** A slot changed hands, went away, came back, was locked or was freed. */
138
+ export interface ClaimChange {
139
+ key: string;
140
+ /** Null when the slot is now free or blocked. */
141
+ identity: string | null;
142
+ state: 'held' | 'away' | 'free' | 'blocked';
143
+ /** `claimed`, `moved`, `released`, `disconnected`, `reconnected`, `expired`, `blocked` or `unblocked`. */
144
+ reason: string;
145
+ }
146
+
147
+ /** A track this client is receiving. */
148
+ export interface RemoteTrack {
149
+ producerId: string;
150
+ identity: string;
151
+ kind: 'audio' | 'video';
152
+ source: TrackSource;
153
+ track: MediaStreamTrack;
154
+ /** Its publisher has it switched off (or a moderator does). */
155
+ paused: boolean;
156
+ }
157
+
158
+ /** What this client publishes, as `localMediaChanged` reports it. */
159
+ export interface LocalMediaState {
160
+ micEnabled: boolean;
161
+ camEnabled: boolean;
162
+ musicEnabled: boolean;
163
+ }
164
+
92
165
  export interface JoinResult {
93
166
  roomId: string;
94
167
  identity: string;
@@ -110,9 +183,24 @@ export interface JoinResult {
110
183
  * on a publisher's incoming bitrate. Absent from older servers: 1080p.
111
184
  */
112
185
  video?: VideoPolicy;
186
+ /**
187
+ * The project's media settings, the modes they allow and the fan-out
188
+ * limits. Absent from older servers; `mediaPermissions` resolves either.
189
+ */
190
+ media?: JoinMedia;
191
+ /** How this session is billed. Absent from older servers. */
192
+ billing?: JoinBilling;
113
193
  }
114
194
 
115
195
  export interface PublishOptions {
196
+ /**
197
+ * The microphone: `'std'` (the default) is mono Opus up to 64 kbps, billed
198
+ * as standard audio; `'hd'` is stereo Opus up to 128 kbps, billed as HD
199
+ * audio. HD in a project limited to standard audio sends standard and warns
200
+ * on `error` with `audio-quality-not-allowed`. Change it live with
201
+ * `setAudioQuality`. Music (`publishMusic`) is not affected.
202
+ */
203
+ audioQuality?: AudioQuality;
116
204
  /** 30 by default. Only the top layer uses 60; capture at 60 too. */
117
205
  frameRate?: 30 | 60;
118
206
  /**
@@ -158,6 +246,23 @@ export interface ScreenPublishOptions {
158
246
  stream?: MediaStream;
159
247
  }
160
248
 
249
+ /**
250
+ * The handlers mediasoup-client can make its Device with. `ReactNative106` is
251
+ * reserved for future native hosts (react-native-webrtc); nothing published
252
+ * uses it yet.
253
+ */
254
+ export type DeviceHandlerName = 'Chrome111' | 'Chrome74' | 'Firefox120' | 'Safari12' | 'ReactNative106';
255
+
256
+ /** `new StreamClient(wsUrl, token, options)`. Everything here is optional; a browser needs none of it. */
257
+ export interface ClientOptions {
258
+ /**
259
+ * The mediasoup-client handler to make the Device with, instead of detecting
260
+ * the browser from its user agent. Leave it unset in browsers; it is
261
+ * reserved for future native hosts, which have no user agent to detect.
262
+ */
263
+ handlerName?: DeviceHandlerName;
264
+ }
265
+
161
266
  export interface SlotClaim {
162
267
  key: string;
163
268
  identity: string;
@@ -193,6 +298,8 @@ const defaultSource = (kind: 'audio' | 'video'): TrackSource => (kind === 'audio
193
298
  export interface ClientEvents extends ChatEvents {
194
299
  connected: () => void;
195
300
  disconnected: (reason: string) => void;
301
+ /** idle → connecting → connected ⇄ reconnecting → closed. Read it any time as `connectionState`. */
302
+ connectionStateChanged: (e: { state: ConnectionState; previous: ConnectionState }) => void;
196
303
  joined: (result: JoinResult) => void;
197
304
  track: (info: {
198
305
  identity: string;
@@ -208,6 +315,8 @@ export interface ClientEvents extends ChatEvents {
208
315
  participantJoined: (p: ParticipantInfo) => void;
209
316
  participantLeft: (p: { identity: string; reason?: string }) => void;
210
317
  participantMuted: (e: { identity: string; kind?: 'audio' | 'video' | ''; paused: boolean }) => void;
318
+ /** Someone's display metadata changed (their own `setMetadata`). */
319
+ participantUpdated: (e: { identity: string; metadata: unknown }) => void;
211
320
  roleChanged: (e: { identity: string; role: Role }) => void;
212
321
  handRaised: (e: { identity: string; handRaised: boolean; metadata?: Record<string, unknown>; reason?: string }) => void;
213
322
  /**
@@ -216,25 +325,58 @@ export interface ClientEvents extends ChatEvents {
216
325
  * switch it back on.
217
326
  */
218
327
  forceMuted: (e: { by: string; kind?: 'audio' | 'video' | ''; paused: boolean }) => void;
328
+ /**
329
+ * A slot changed. **Draw `away` as taken (greyed), never as free**: the
330
+ * holder's connection dropped and the slot is kept for them. `info.claims`
331
+ * and `info.blockedSlots` are kept current from these.
332
+ */
333
+ claimChanged: (e: ClaimChange) => void;
334
+ /** Room metadata changed (a moderator's `setRoomMetadata`); persisted, so `info.metadata` is kept current too. */
335
+ roomMetadataChanged: (e: { metadata: unknown; by?: string }) => void;
219
336
  mustReconnect: (e: { reason: string; token?: string; retry: boolean }) => void;
220
337
  roomClosed: (e: { reason: string }) => void;
221
338
  kicked: (e: { by: string; reason?: string }) => void;
222
339
  screenShareStarted: (e: { stream: MediaStream; mode: ScreenMode }) => void;
223
340
  screenShareEnded: (e: { reason: 'stopped' | 'track-ended' }) => void;
341
+ /**
342
+ * A failure, or a warning that changes nothing else: an `RpcError` whose
343
+ * `code` is in `ERROR_CODES` (`frame-rate-fallback`,
344
+ * `audio-quality-not-allowed`, ...). Switch on the code.
345
+ */
224
346
  error: (err: Error) => void;
225
- localMediaChanged: (state: { micEnabled: boolean; camEnabled: boolean; musicEnabled: boolean }) => void;
347
+ localMediaChanged: (state: LocalMediaState) => void;
226
348
  /** The local stream was replaced, typically after reacquiring devices on a reconnect. */
227
349
  localStreamChanged: (stream: MediaStream | null) => void;
228
350
  /** Media stopped arriving but the connection is intact -- the publisher is reconnecting. */
229
351
  streamInterrupted: (e: { reason: string }) => void;
230
352
  /** The session ended and will not recover. Nothing further will be emitted. */
231
- closed: (reason: 'replaced' | 'kicked' | 'room-closed' | 'gave-up' | 'left') => void;
353
+ closed: (reason: ClosedReason) => void;
232
354
  /**
233
355
  * The account behind this session ran out of minutes or reached a usage
234
356
  * limit. The session ends at `endsAt` (after `graceSeconds`) unless the
235
357
  * account is topped up first -- tell the user, and stop asking them to wait.
236
358
  */
237
359
  usageLimit: (e: UsageLimitNotice) => void;
360
+ /**
361
+ * Wallet billing: the funds this project may spend cover less than the
362
+ * grace period at the current burn, repeated while it lasts; the room closes
363
+ * when they reach zero unless the wallet is recharged. A notice with
364
+ * `cleared: true` ends the warning (and the `usageLimit` countdown beside it).
365
+ */
366
+ walletLow: (e: WalletLowNotice) => void;
367
+ /**
368
+ * The token this client would reconnect with expires in about a minute and
369
+ * the server has not refreshed it (it stops when the account may no longer
370
+ * be used, or the socket is down). Mint a new one on your backend and hand it
371
+ * over with `updateToken`.
372
+ */
373
+ tokenExpiring: (e: TokenExpiringNotice) => void;
374
+ /**
375
+ * Round trip, packet loss and available bandwidth to the media server, about
376
+ * every 3 s while listened to and media is flowing, with a level for the UI
377
+ * (thresholds in the README). Sampled only while someone listens.
378
+ */
379
+ networkQuality: (e: NetworkQuality) => void;
238
380
  /**
239
381
  * Your camera video is being held back by this device (`cpu`: too busy to
240
382
  * encode it), by the upload (`bandwidth`), or by something else (`other`);
@@ -248,33 +390,22 @@ export interface ClientEvents extends ChatEvents {
248
390
 
249
391
  type Handler = (...args: never[]) => void;
250
392
 
251
- /** An error returned by the server, carrying its machine-readable code. */
252
- export class RpcError extends Error {
253
- constructor(readonly code: string, message: string) {
254
- super(`${code}: ${message}`);
255
- this.name = 'RpcError';
256
- }
257
- }
258
-
259
- /**
260
- * Failures that reconnecting cannot fix. A room that has ended is not coming
261
- * back, and a token that is wrong will be wrong next time too -- retrying
262
- * these just floods the log and burns the attempt budget that a genuine
263
- * network blip needs.
264
- */
265
- const TERMINAL_JOIN_ERRORS = new Set([
266
- 'no-room',
267
- 'room-ended',
268
- 'wrong-tenant',
269
- 'bad-token',
270
- 'no-token',
271
- 'forbidden',
272
- // The account cannot use the service until it is topped up or restored.
273
- 'quota-exhausted',
274
- 'daily-limit-reached',
275
- 'monthly-limit-reached',
276
- 'tenant-suspended',
277
- ]);
393
+ // Every event a StreamClient emits. Typed against ClientEvents, so adding an
394
+ // event without listing it here (or listing one that does not exist) fails to
395
+ // compile; the contract check compares this list with docs/sdk-contract.json.
396
+ const EVENT_NAMES: { readonly [K in keyof ClientEvents]-?: true } = {
397
+ connected: true, disconnected: true, connectionStateChanged: true, joined: true,
398
+ track: true, trackEnded: true, trackMuted: true,
399
+ participantJoined: true, participantLeft: true, participantMuted: true, participantUpdated: true,
400
+ roleChanged: true, handRaised: true, forceMuted: true, claimChanged: true, roomMetadataChanged: true,
401
+ mustReconnect: true, roomClosed: true, kicked: true, streamInterrupted: true, closed: true,
402
+ usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true,
403
+ screenShareStarted: true, screenShareEnded: true, error: true, localMediaChanged: true, localStreamChanged: true,
404
+ message: true, messageDeleted: true, chatMuted: true, typing: true,
405
+ };
406
+
407
+ /** The names of every event `StreamClient.on` accepts. */
408
+ export const CLIENT_EVENTS = Object.freeze(Object.keys(EVENT_NAMES) as (keyof ClientEvents)[]);
278
409
 
279
410
  interface Pending {
280
411
  resolve: (v: unknown) => void;
@@ -306,7 +437,26 @@ const isLayer = (value: unknown): value is number => Number.isInteger(value) &&
306
437
  const VIDEO_START_BITRATE_KBPS = 2000;
307
438
 
308
439
  /** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
309
- const unpublishedWhile = (what: string) => `the ${what} was unpublished while it was being published`;
440
+ const unpublishedWhile = (what: string) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
441
+
442
+ /**
443
+ * mediasoup-client puts the microphone's Opus maxaveragebitrate in the answer
444
+ * SDP only, so the server would never see it. Declare it in the producer's
445
+ * parameters too: the media node bills HD on a declared value above 64,000
446
+ * (spec §4) and refuses one in a project limited to standard audio, so a
447
+ * standard microphone declares 64,000 and an HD one 128,000. Music and screen
448
+ * audio go out as they always did.
449
+ */
450
+ function declareAudioQuality(rtpParameters: ms.RtpParameters, appData: Record<string, unknown>): ms.RtpParameters {
451
+ const quality = appData.audioQuality;
452
+ if (appData.source !== 'mic' || (quality !== 'std' && quality !== 'hd')) return rtpParameters;
453
+ const maxaveragebitrate = AUDIO_SETTINGS[quality].maxBitrate;
454
+ return {
455
+ ...rtpParameters,
456
+ codecs: rtpParameters.codecs.map((codec) => (codec.mimeType.toLowerCase() === 'audio/opus'
457
+ ? { ...codec, parameters: { ...codec.parameters, maxaveragebitrate } } : codec)),
458
+ };
459
+ }
310
460
 
311
461
  export class StreamClient {
312
462
  private ws: WebSocket | null = null;
@@ -384,9 +534,33 @@ export class StreamClient {
384
534
  undefined,
385
535
  () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; },
386
536
  );
537
+ /** Where the session stands; see connectionStateChanged. */
538
+ private state: ConnectionState = 'idle';
539
+ /** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
540
+ private tokenExpiry: number | null;
541
+ private tokenTimer: ReturnType<typeof setTimeout> | null = null;
542
+ /** Whose each received track is and whether it is switched off, by producerId. */
543
+ private readonly remoteInfo = new Map<string, { identity: string; kind: 'audio' | 'video'; paused: boolean }>();
544
+ /** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
545
+ private readonly skippedVideos = new Map<string, RemoteProducer>();
546
+ /** Producers whose video this client receives, or is about to: the fan-out count. */
547
+ private readonly videoSlots = new Set<string>();
548
+ /** The quality the microphone producer was made with. */
549
+ private micQuality: AudioQuality = 'std';
550
+ // Changes to the microphone producer run one at a time, as the camera's do.
551
+ private micQueue: Promise<unknown> = Promise.resolve();
552
+ // A request the project does not allow is reported once, until it is made again.
553
+ private audioCapReported = false;
554
+ private sizeCapReported = false;
555
+ private readonly networkWatch = new NetworkQualityWatch(
556
+ () => this.transportStats(),
557
+ () => this.producers.size > 0,
558
+ (quality) => this.emit('networkQuality', quality),
559
+ );
387
560
 
388
- constructor(private readonly wsUrl: string, token: string) {
561
+ constructor(private readonly wsUrl: string, token: string, private readonly options: ClientOptions = {}) {
389
562
  this.token = token;
563
+ this.tokenExpiry = tokenExpiresAt(token);
390
564
  }
391
565
 
392
566
  on<K extends keyof ClientEvents>(event: K, fn: ClientEvents[K]): () => void {
@@ -396,14 +570,43 @@ export class StreamClient {
396
570
  this.listeners.set(event, set);
397
571
  }
398
572
  set.add(fn as Handler);
399
- // videoLimited costs a getStats every 2 s, so it runs only while heard.
573
+ // videoLimited and networkQuality cost a getStats every few seconds, so
574
+ // they run only while heard.
400
575
  if (event === 'videoLimited') this.syncLimitWatch();
576
+ if (event === 'networkQuality') this.syncNetworkWatch();
401
577
  return () => {
402
578
  set!.delete(fn as Handler);
403
579
  if (event === 'videoLimited') this.syncLimitWatch();
580
+ if (event === 'networkQuality') this.syncNetworkWatch();
404
581
  };
405
582
  }
406
583
 
584
+ /** Where the session stands: idle, connecting, connected, reconnecting or closed. */
585
+ get connectionState(): ConnectionState {
586
+ return this.state;
587
+ }
588
+
589
+ private setState(next: ConnectionState): void {
590
+ if (next === this.state) return;
591
+ const previous = this.state;
592
+ this.state = next;
593
+ if (next === 'closed') this.stopTimers();
594
+ this.emit('connectionStateChanged', { state: next, previous });
595
+ }
596
+
597
+ /** Over for good: the state, then the event that says why. */
598
+ private finish(reason: ClosedReason): void {
599
+ this.closedByUs = true;
600
+ this.setState('closed');
601
+ this.emit('closed', reason);
602
+ }
603
+
604
+ private stopTimers(): void {
605
+ if (this.tokenTimer) clearTimeout(this.tokenTimer);
606
+ this.tokenTimer = null;
607
+ this.networkWatch.set(false);
608
+ }
609
+
407
610
  private listenerCount(event: keyof ClientEvents): number {
408
611
  return this.listeners.get(event)?.size ?? 0;
409
612
  }
@@ -422,6 +625,7 @@ export class StreamClient {
422
625
 
423
626
  async connect(): Promise<JoinResult> {
424
627
  let result: JoinResult;
628
+ if (this.state === 'idle' || this.state === 'closed') this.setState('connecting');
425
629
  try {
426
630
  await this.openSocket();
427
631
  result = (await this.rpc('join', { token: this.token })) as JoinResult;
@@ -433,7 +637,10 @@ export class StreamClient {
433
637
  // A session that never got going: the app has the error. Left open,
434
638
  // the socket would be closed by the server's join deadline instead, and
435
639
  // that close would start reconnecting a session that never began.
436
- if (!this.joinedOnce) this.detachSocket();
640
+ if (!this.joinedOnce) {
641
+ this.detachSocket();
642
+ if (this.state === 'connecting') this.setState('idle');
643
+ }
437
644
  throw err;
438
645
  }
439
646
  // Joined and set up: the next drop gets every reconnect attempt again.
@@ -442,6 +649,9 @@ export class StreamClient {
442
649
  // retried about twice a second, forever.
443
650
  this.reconnectAttempt = 0;
444
651
  this.joinedOnce = true;
652
+ this.skippedVideos.clear();
653
+ this.setState('connected');
654
+ this.scheduleTokenExpiring();
445
655
 
446
656
  // A role or mute can change while the socket is away. The fresh roster
447
657
  // decides what may return; reconnecting never turns a blocked device on.
@@ -477,12 +687,18 @@ export class StreamClient {
477
687
  if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
478
688
  await this.republish().catch((err) => this.emit('error', err as Error));
479
689
  }
690
+ this.syncNetworkWatch();
480
691
  return result;
481
692
  }
482
693
 
483
- /** A new mediasoup Device for each join. Its own method so tests, which have no WebRTC, can stand one in. */
694
+ /**
695
+ * A new mediasoup Device for each join: the handler named in the options, or
696
+ * the one detected from the browser. Its own method so tests, which have no
697
+ * WebRTC, can stand one in.
698
+ */
484
699
  private createDevice(): Device {
485
- return new Device();
700
+ const { handlerName } = this.options;
701
+ return handlerName ? new Device({ handlerName }) : new Device();
486
702
  }
487
703
 
488
704
  private openSocket(): Promise<void> {
@@ -492,16 +708,14 @@ export class StreamClient {
492
708
  this.detachSocket();
493
709
 
494
710
  return new Promise((resolve, reject) => {
495
- const url = new URL(this.wsUrl);
496
- url.searchParams.set('token', this.token);
497
- const ws = new WebSocket(url.toString());
711
+ const ws = new WebSocket(socketUrl(this.wsUrl, this.token));
498
712
  this.ws = ws;
499
713
 
500
714
  const onOpen = () => {
501
715
  this.emit('connected');
502
716
  resolve();
503
717
  };
504
- const onError = () => reject(new Error('websocket failed to open'));
718
+ const onError = () => reject(new RpcError('connect-failed', 'websocket failed to open'));
505
719
 
506
720
  ws.addEventListener('open', onOpen, { once: true });
507
721
  ws.addEventListener('error', onError, { once: true });
@@ -513,32 +727,32 @@ export class StreamClient {
513
727
  // Only the socket we currently consider ours may drive reconnection.
514
728
  if (this.ws !== ws) return;
515
729
  this.ws = null;
516
- this.failAllPending(new Error(`socket closed (${ev.code})`));
730
+ this.failAllPending(new RpcError('not-connected', `socket closed (${ev.code})`));
517
731
  this.emit('disconnected', ev.reason || String(ev.code));
518
732
 
519
733
  if (this.closedByUs) return;
520
734
  if (ev.code === CLOSE_REPLACED) {
521
735
  // Another session for this identity is live. Reconnecting would
522
736
  // displace it, and it would displace us right back, forever.
523
- this.emit('error', new Error('this identity connected from somewhere else'));
524
- this.closedByUs = true;
737
+ this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
525
738
  // The producers outlive this close until the app calls close(); the
526
739
  // videoLimited sampling must not, or it reports after `closed`.
527
740
  this.limitWatch.reset();
528
- this.emit('closed', 'replaced');
741
+ this.finish('replaced');
529
742
  return;
530
743
  }
531
744
  if (ev.code === CLOSE_ENDED) {
532
745
  // Normally roomClosed has already ended the session; this covers a
533
746
  // close that arrived without it.
534
- this.closedByUs = true;
535
747
  this.limitWatch.reset();
536
- this.emit('closed', 'room-closed');
748
+ this.finish('room-closed');
537
749
  return;
538
750
  }
539
751
  // Before a first successful join the app is still waiting on
540
752
  // connect(), which reports the failure; there is nothing to restore.
541
753
  if (!this.joinedOnce) return;
754
+ this.setState('reconnecting');
755
+ this.networkWatch.set(false);
542
756
  void this.scheduleReconnect();
543
757
  });
544
758
  });
@@ -571,9 +785,8 @@ export class StreamClient {
571
785
  while (!this.closedByUs) {
572
786
  this.reconnectAttempt += 1;
573
787
  if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
574
- this.emit('error', new Error(`gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
575
- this.closedByUs = true;
576
- this.emit('closed', 'gave-up');
788
+ this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
789
+ this.finish('gave-up');
577
790
  return;
578
791
  }
579
792
  const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
@@ -589,12 +802,11 @@ export class StreamClient {
589
802
  if (this.ws) return;
590
803
  } catch (err) {
591
804
  this.emit('error', err as Error);
592
- // Stop on failures a retry cannot resolve. Hammering a room that has
593
- // ended produces one error per attempt forever and drowns out
594
- // whatever actually went wrong.
595
- if (err instanceof RpcError && TERMINAL_JOIN_ERRORS.has(err.code)) {
596
- this.closedByUs = true;
597
- this.emit('closed', 'gave-up');
805
+ // Stop on failures a retry cannot resolve (the registry's terminal
806
+ // codes). Hammering a room that has ended produces one error per
807
+ // attempt forever and drowns out whatever actually went wrong.
808
+ if (err instanceof RpcError && isTerminalError(err.code)) {
809
+ this.finish('gave-up');
598
810
  return;
599
811
  }
600
812
  }
@@ -621,6 +833,7 @@ export class StreamClient {
621
833
  this.music = null;
622
834
  // Nothing is emitted after closing, so not even a clearing videoLimited.
623
835
  this.limitWatch.reset();
836
+ this.skippedVideos.clear();
624
837
  // Producers no longer stop their tracks, so releasing the camera and
625
838
  // microphone is ours to do -- otherwise the device light stays on.
626
839
  this.localStream?.getTracks().forEach((t) => t.stop());
@@ -628,12 +841,16 @@ export class StreamClient {
628
841
  const ws = this.ws;
629
842
  this.ws = null;
630
843
  ws?.close(1000, 'client closed');
844
+ this.setState('closed');
631
845
  if (wasOpen) this.emit('closed', 'left');
632
846
  }
633
847
 
634
848
  private teardownMedia(): void {
635
849
  for (const c of this.consumers.values()) c.close();
636
850
  this.consumers.clear();
851
+ this.remoteInfo.clear();
852
+ this.skippedVideos.clear();
853
+ this.videoSlots.clear();
637
854
  for (const p of this.producers.values()) p.close();
638
855
  this.producers.clear();
639
856
  this.musicProducerId = null;
@@ -649,25 +866,142 @@ export class StreamClient {
649
866
  this.sendTransportPending = null;
650
867
  this.recvTransportPending = null;
651
868
  this.device = null;
869
+ this.syncNetworkWatch();
652
870
  }
653
871
 
654
- /** Everything currently known about the room, for rendering. */
872
+ /**
873
+ * Everything currently known about the room, for rendering: the join
874
+ * result, kept current -- participants, roles, hands, mutes, display
875
+ * metadata, slots and room metadata follow the events.
876
+ */
655
877
  get info(): JoinResult | null {
656
878
  return this.joinResult;
657
879
  }
658
880
 
881
+ /** Who is in the room now, this client included; kept current from the roster events. */
882
+ get participants(): ParticipantInfo[] {
883
+ return [...(this.joinResult?.participants ?? [])];
884
+ }
885
+
886
+ /** Every track this client is receiving now. */
887
+ get remoteTracks(): RemoteTrack[] {
888
+ const out: RemoteTrack[] = [];
889
+ for (const [producerId, consumer] of this.consumers) {
890
+ const info = this.remoteInfo.get(producerId);
891
+ if (!info || consumer.closed) continue;
892
+ out.push({
893
+ producerId, identity: info.identity, kind: info.kind, track: consumer.track,
894
+ source: this.remoteSources.get(producerId) ?? defaultSource(info.kind), paused: info.paused,
895
+ });
896
+ }
897
+ return out;
898
+ }
899
+
900
+ /**
901
+ * What this project lets this client publish: from the join result's
902
+ * `media` (servers with wallet billing), else its `video` policy, else
903
+ * everything before a join. Offer only these in your UI; the SDK refuses
904
+ * or caps anything else with the server's own codes.
905
+ */
906
+ get mediaPermissions(): MediaPermissions {
907
+ return resolvePermissions(this.joinResult);
908
+ }
909
+
910
+ /** How this session is billed: `legacy` (minutes) or `wallet`; null before a join or on older servers. */
911
+ get billingMode(): 'legacy' | 'wallet' | null {
912
+ return this.joinResult?.billing?.mode ?? null;
913
+ }
914
+
915
+ // ------------------------------------------------------------ token refresh
916
+
917
+ /**
918
+ * Hand the session a newer room token, minted by your backend for the same
919
+ * room and identity (`POST /v1/rooms/{roomId}/tokens`), typically on
920
+ * `tokenExpiring`. On a live connection the server checks it like a join
921
+ * token (`bad-token`, or `token-mismatch` for another room or identity; the
922
+ * session then keeps its token); otherwise it is kept for the next join,
923
+ * which checks it there. Resolves to when it expires (unix ms), when known.
924
+ */
925
+ async updateToken(token: string): Promise<{ expiresAt: number | null }> {
926
+ if (typeof token !== 'string' || token.trim() === '') throw new TypeError('updateToken needs a token');
927
+ if (this.state === 'closed') throw new RpcError('not-connected', 'this session is closed; create a new client');
928
+ if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
929
+ try {
930
+ const res = (await this.rpc('refreshToken', { token })) as { expiresAt?: unknown } | undefined;
931
+ const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
932
+ this.setToken(token, at);
933
+ return { expiresAt: this.tokenExpiry };
934
+ } catch (err) {
935
+ // An older server has no refreshToken, and a socket that dropped
936
+ // during the call is reconnecting: either way keep the token for the
937
+ // next join, which checks it.
938
+ if (!(err instanceof RpcError) || (err.code !== 'unknown-method' && err.code !== 'not-connected')) throw err;
939
+ }
940
+ }
941
+ this.setToken(token, null);
942
+ return { expiresAt: this.tokenExpiry };
943
+ }
944
+
945
+ /** The token reconnects use from now on, with its expiry (read from the token when not given). */
946
+ private setToken(token: string, expiresAt: number | null): void {
947
+ this.token = token;
948
+ this.tokenExpiry = expiresAt ?? tokenExpiresAt(token);
949
+ this.scheduleTokenExpiring();
950
+ }
951
+
952
+ /**
953
+ * Warn TOKEN_EXPIRING_LEAD_MS before the token held for reconnects
954
+ * expires. Every new token -- the server's refresh, a promotion's, the
955
+ * app's -- reschedules it, so it fires only when nobody refreshed it.
956
+ */
957
+ private scheduleTokenExpiring(): void {
958
+ if (this.tokenTimer) clearTimeout(this.tokenTimer);
959
+ this.tokenTimer = null;
960
+ const expiresAt = this.tokenExpiry;
961
+ if (expiresAt === null || this.state === 'idle' || this.state === 'closed') return;
962
+ // setTimeout cannot wait longer than about 24.8 days.
963
+ const delay = Math.min(Math.max(0, expiresAt - TOKEN_EXPIRING_LEAD_MS - Date.now()), 2_147_000_000);
964
+ this.tokenTimer = setTimeout(() => {
965
+ this.tokenTimer = null;
966
+ if (this.state === 'closed' || this.tokenExpiry !== expiresAt) return;
967
+ if (expiresAt - TOKEN_EXPIRING_LEAD_MS > Date.now()) {
968
+ this.scheduleTokenExpiring(); // woke early from a clamped wait
969
+ return;
970
+ }
971
+ this.emit('tokenExpiring', { expiresAt, secondsLeft: Math.max(0, Math.round((expiresAt - Date.now()) / 1000)) });
972
+ }, delay);
973
+ }
974
+
975
+ // ------------------------------------------------------------ network quality
976
+
977
+ /** networkQuality sampling runs while someone listens and the session is connected. */
978
+ private syncNetworkWatch(): void {
979
+ this.networkWatch.set(!this.closedByUs && this.state === 'connected' && this.listenerCount('networkQuality') > 0);
980
+ }
981
+
982
+ /** Each live media transport's statistics, keyed by transport so a new one starts fresh. */
983
+ private async transportStats(): Promise<Map<string, Iterable<Record<string, unknown>>>> {
984
+ const out = new Map<string, Iterable<Record<string, unknown>>>();
985
+ for (const transport of [this.sendTransport, this.recvTransport]) {
986
+ if (!transport || transport.closed) continue;
987
+ const report = await transport.getStats();
988
+ out.set(transport.id, report.values() as Iterable<Record<string, unknown>>);
989
+ }
990
+ return out;
991
+ }
992
+
659
993
  // -------------------------------------------------------------------- rpc
660
994
 
661
995
  private rpc(method: string, params: unknown = {}): Promise<unknown> {
662
996
  return new Promise((resolve, reject) => {
663
997
  if (this.ws?.readyState !== WebSocket.OPEN) {
664
- reject(new Error('socket is not open'));
998
+ reject(new RpcError('not-connected', 'socket is not open'));
665
999
  return;
666
1000
  }
667
1001
  const id = this.nextId++;
668
1002
  const timer = setTimeout(() => {
669
1003
  this.pending.delete(id);
670
- reject(new Error(`${method} timed out`));
1004
+ reject(new RpcError('timeout', `${method} timed out`));
671
1005
  }, RPC_TIMEOUT_MS);
672
1006
  this.pending.set(id, { resolve, reject, timer });
673
1007
  this.ws.send(JSON.stringify({ id, method, params }));
@@ -697,8 +1031,8 @@ export class StreamClient {
697
1031
  this.pending.delete(msg.id);
698
1032
  if (msg.ok) p.resolve(msg.result);
699
1033
  else {
700
- const e = msg.error as { code?: string; message?: string } | undefined;
701
- p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown'));
1034
+ const e = msg.error as { code?: string; message?: string; data?: unknown } | undefined;
1035
+ p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown', e?.data));
702
1036
  }
703
1037
  return;
704
1038
  }
@@ -717,16 +1051,24 @@ export class StreamClient {
717
1051
  this.preferredLayers.delete(producerId);
718
1052
  this.receiveQualities.delete(producerId);
719
1053
  this.remoteVideoLayers.delete(producerId);
1054
+ this.skippedVideos.delete(producerId);
720
1055
  const consumer = this.consumers.get(producerId);
721
1056
  if (!consumer) break; // never consumed it, or already cleaned up
1057
+ this.videoSlots.delete(producerId);
722
1058
  consumer.close();
723
1059
  this.consumers.delete(producerId);
724
1060
  this.remoteSources.delete(producerId);
1061
+ this.remoteInfo.delete(producerId);
725
1062
  this.emit('trackEnded', { producerId, identity });
1063
+ // A video slot opened under the fan-out limit: the oldest video left
1064
+ // waiting takes it.
1065
+ if (consumer.kind === 'video') void this.consumeSkipped().catch((err) => this.emit('error', err as Error));
726
1066
  break;
727
1067
  }
728
1068
  case 'producerPaused': {
729
1069
  const p = data as { producerId: string; identity: string; kind: 'audio' | 'video'; paused: boolean };
1070
+ const info = this.remoteInfo.get(p.producerId);
1071
+ if (info) info.paused = Boolean(p.paused);
730
1072
  this.emit('trackMuted', {
731
1073
  identity: p.identity,
732
1074
  kind: p.kind,
@@ -736,26 +1078,40 @@ export class StreamClient {
736
1078
  });
737
1079
  break;
738
1080
  }
739
- case 'participantJoined':
740
- this.emit('participantJoined', data as ParticipantInfo);
1081
+ case 'participantJoined': {
1082
+ const p = data as ParticipantInfo;
1083
+ // A reconnecting participant joins again: replace, never duplicate.
1084
+ this.updateRoster((list) => [...list.filter((q) => q.identity !== p.identity), p]);
1085
+ this.emit('participantJoined', p);
741
1086
  break;
1087
+ }
742
1088
  case 'participantMuted': {
743
1089
  const wire = data as { identity: string; kind?: 'audio' | 'video' | ''; muted?: boolean; paused?: boolean };
744
1090
  const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
745
- if (this.joinResult) this.joinResult.participants = this.joinResult.participants.map((p) =>
1091
+ this.updateRoster((list) => list.map((p) =>
746
1092
  p.identity !== e.identity ? p : { ...p,
747
1093
  ...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
748
1094
  ...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
749
- });
1095
+ }));
750
1096
  this.emit('participantMuted', e);
751
1097
  break;
752
1098
  }
753
- case 'participantLeft':
754
- this.emit('participantLeft', data as { identity: string; reason?: string });
1099
+ case 'participantUpdated': {
1100
+ const e = data as { identity: string; metadata?: unknown };
1101
+ this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, metadata: e.metadata })));
1102
+ this.emit('participantUpdated', { identity: e.identity, metadata: e.metadata ?? null });
1103
+ break;
1104
+ }
1105
+ case 'participantLeft': {
1106
+ const e = data as { identity: string; reason?: string };
1107
+ this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
1108
+ this.emit('participantLeft', e);
755
1109
  break;
1110
+ }
756
1111
  case 'roleChanged': {
757
1112
  const e = data as { identity: string; role: Role };
758
1113
  if (e.identity === this.joinResult?.identity) this.joinResult.role = e.role;
1114
+ this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, role: e.role })));
759
1115
  this.emit('roleChanged', e);
760
1116
  // Demoted: the server has already closed our producers. Release the
761
1117
  // camera and microphone too, instead of capturing for nobody.
@@ -766,9 +1122,13 @@ export class StreamClient {
766
1122
  break;
767
1123
  }
768
1124
  case 'handRaised':
769
- case 'handLowered':
1125
+ case 'handLowered': {
1126
+ const e = data as { identity: string; handRaised: boolean; metadata?: Record<string, unknown> };
1127
+ this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p
1128
+ : { ...p, handRaised: Boolean(e.handRaised), ...(e.handRaised ? { handMetadata: e.metadata } : { handMetadata: undefined }) })));
770
1129
  this.emit('handRaised', data as { identity: string; handRaised: boolean });
771
1130
  break;
1131
+ }
772
1132
  case 'forceMuted': {
773
1133
  // The server has already paused that kind (an audio mute includes
774
1134
  // in-app music: it travels as audio). Reflect it locally; a release
@@ -781,18 +1141,30 @@ export class StreamClient {
781
1141
  this.emit('forceMuted', e);
782
1142
  break;
783
1143
  }
1144
+ case 'claimChanged': {
1145
+ const e = data as { key: string; identity?: string | null; state: ClaimChange['state']; reason?: string };
1146
+ this.applyClaim(e);
1147
+ this.emit('claimChanged', { key: e.key, identity: e.identity || null, state: e.state, reason: e.reason ?? '' });
1148
+ break;
1149
+ }
1150
+ case 'roomMetadataChanged': {
1151
+ const e = data as { metadata?: unknown; by?: string };
1152
+ if (this.joinResult) this.joinResult.metadata = e.metadata;
1153
+ this.emit('roomMetadataChanged', { metadata: e.metadata ?? null, ...(e.by ? { by: e.by } : {}) });
1154
+ break;
1155
+ }
784
1156
  case 'tokenRefreshed': {
785
1157
  // A room outlives its join token many times over. The server hands us a
786
1158
  // fresh one periodically so a reconnect at any point still works --
787
1159
  // without this, any disconnect after the token's lifetime is terminal.
788
- const e = data as { token?: string; role?: Role };
789
- if (e.token) this.token = e.token;
1160
+ const e = data as { token?: string; role?: Role; expiresAt?: number };
1161
+ if (e.token) this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
790
1162
  break;
791
1163
  }
792
1164
  case 'mustReconnect': {
793
1165
  const e = data as { reason: string; token?: string; retry: boolean };
794
1166
  // A promotion hands us a fresh token carrying the new role.
795
- if (e.token) this.token = e.token;
1167
+ if (e.token) this.setToken(e.token, null);
796
1168
  this.emit('mustReconnect', e);
797
1169
  if (e.retry) {
798
1170
  this.teardownMedia();
@@ -808,16 +1180,22 @@ export class StreamClient {
808
1180
  case 'usageLimit':
809
1181
  this.emit('usageLimit', d as never);
810
1182
  break;
1183
+ case 'walletLow': {
1184
+ const e = data as { remainingSeconds?: unknown; cleared?: unknown };
1185
+ const remainingSeconds = typeof e?.remainingSeconds === 'number' && Number.isFinite(e.remainingSeconds) ? e.remainingSeconds : 0;
1186
+ this.emit('walletLow', e?.cleared === true ? { remainingSeconds, cleared: true } : { remainingSeconds });
1187
+ break;
1188
+ }
811
1189
  case 'roomClosed':
812
1190
  this.closedByUs = true;
813
1191
  this.emit('roomClosed', d as never);
814
- this.emit('closed', 'room-closed');
1192
+ this.finish('room-closed');
815
1193
  this.close();
816
1194
  break;
817
1195
  case 'kicked':
818
1196
  this.closedByUs = true;
819
1197
  this.emit('kicked', d as never);
820
- this.emit('closed', 'kicked');
1198
+ this.finish('kicked');
821
1199
  this.close();
822
1200
  break;
823
1201
  case 'message':
@@ -837,6 +1215,23 @@ export class StreamClient {
837
1215
  }
838
1216
  }
839
1217
 
1218
+ /** Change the roster the join result holds; a new array each time, so a renderer sees the change. */
1219
+ private updateRoster(change: (list: ParticipantInfo[]) => ParticipantInfo[]): void {
1220
+ if (this.joinResult) this.joinResult.participants = change(this.joinResult.participants ?? []);
1221
+ }
1222
+
1223
+ /** Keep the join result's slots current: claims held or away, and the moderator's locks. */
1224
+ private applyClaim(e: { key: string; identity?: string | null; state: string }): void {
1225
+ const join = this.joinResult;
1226
+ if (!join) return;
1227
+ const claims = (join.claims ?? []).filter((c) => c.key !== e.key);
1228
+ let blocked = (join.blockedSlots ?? []).filter((key) => key !== e.key);
1229
+ if ((e.state === 'held' || e.state === 'away') && e.identity) claims.push({ key: e.key, identity: e.identity, state: e.state });
1230
+ if (e.state === 'blocked') blocked = [...blocked, e.key];
1231
+ join.claims = claims;
1232
+ join.blockedSlots = blocked;
1233
+ }
1234
+
840
1235
  // ------------------------------------------------------------------ media
841
1236
 
842
1237
  private ensureSendTransport(): Promise<Transport> {
@@ -849,7 +1244,7 @@ export class StreamClient {
849
1244
  }
850
1245
 
851
1246
  private async createSendTransport(): Promise<Transport> {
852
- if (!this.device) throw new Error('not connected');
1247
+ if (!this.device) throw new RpcError('not-connected', 'not connected');
853
1248
 
854
1249
  const params = (await this.rpc('createTransport', { direction: 'send' })) as TransportOptions;
855
1250
  const transport = this.device.createSendTransport({
@@ -863,7 +1258,10 @@ export class StreamClient {
863
1258
  .catch(fail);
864
1259
  });
865
1260
  transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
866
- this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source, videoLayers: appData.videoLayers })
1261
+ this.rpc('produce', {
1262
+ transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
1263
+ appData, source: appData.source, videoLayers: appData.videoLayers,
1264
+ })
867
1265
  .then((res) => ok({ id: (res as { producerId: string }).producerId }))
868
1266
  .catch(fail);
869
1267
  });
@@ -882,7 +1280,7 @@ export class StreamClient {
882
1280
  }
883
1281
 
884
1282
  private async createRecvTransport(): Promise<Transport> {
885
- if (!this.device) throw new Error('not connected');
1283
+ if (!this.device) throw new RpcError('not-connected', 'not connected');
886
1284
 
887
1285
  const params = (await this.rpc('createTransport', { direction: 'recv' })) as TransportOptions;
888
1286
  const transport = this.device.createRecvTransport({
@@ -912,11 +1310,24 @@ export class StreamClient {
912
1310
  * kept for a reconnect's republish.
913
1311
  */
914
1312
  async publish(stream: MediaStream, constraints?: MediaStreamConstraints, options?: PublishOptions): Promise<void> {
1313
+ // A kind the project switched off is refused here, before anything is
1314
+ // produced, with the code the controller would answer with.
1315
+ const permissions = this.mediaPermissions;
1316
+ if (!permissions.audio && stream.getAudioTracks().length > 0) {
1317
+ throw new RpcError('media-disabled', 'audio is switched off for this project');
1318
+ }
1319
+ if (!permissions.video && stream.getVideoTracks().length > 0) {
1320
+ throw new RpcError('media-disabled', 'video is switched off for this project');
1321
+ }
1322
+ await this.publishStream(stream, constraints, options);
1323
+ }
1324
+
1325
+ private async publishStream(stream: MediaStream, constraints?: MediaStreamConstraints, options?: PublishOptions): Promise<void> {
915
1326
  const generation = this.publishGeneration;
916
1327
  const transport = await this.ensureSendTransport();
917
1328
  // unpublish() ran first: this publish is over before it began, and must
918
1329
  // leave nothing behind that a reconnect would publish again.
919
- if (generation !== this.publishGeneration) throw new Error(unpublishedWhile('stream'));
1330
+ if (generation !== this.publishGeneration) throw unpublishedWhile('stream');
920
1331
  this.localStream = stream;
921
1332
  this.wasPublishing = true;
922
1333
  if (constraints) this.publishConstraints = constraints;
@@ -924,39 +1335,144 @@ export class StreamClient {
924
1335
  this.publishOptions = { ...options };
925
1336
  this.frameRateFallback = false;
926
1337
  this.frameRateLowSamples = 0;
1338
+ this.audioCapReported = false;
1339
+ this.sizeCapReported = false;
927
1340
  }
1341
+ // Only a reconnect's republish gets here with a kind switched off since:
1342
+ // what is still allowed goes out, and the rest is reported.
1343
+ const permissions = this.mediaPermissions;
928
1344
 
929
1345
  const audio = stream.getAudioTracks()[0];
930
- if (audio) {
931
- const producer = await transport.produce({
932
- track: audio,
933
- // Keep the track alive when the producer closes: a reconnect closes
934
- // every producer, and a stopped track cannot be republished.
935
- stopTracks: false,
936
- codecOptions: { opusStereo: true, opusDtx: true, opusFec: true },
937
- appData: { source: 'mic' },
938
- });
939
- await this.keepProducer(producer, generation, 'microphone');
940
- // A microphone handed over switched off, or muted before a reconnect.
941
- await this.pauseOnServer(producer);
1346
+ if (audio && !permissions.audio) {
1347
+ this.emit('error', new RpcError('media-disabled', 'audio is switched off for this project; the microphone is not published'));
1348
+ } else if (audio) {
1349
+ await this.micTask(() => this.produceMic(transport, this.localStream?.getAudioTracks()[0] ?? audio, generation));
942
1350
  }
943
1351
 
944
1352
  const video = stream.getVideoTracks()[0];
945
- if (video) {
1353
+ if (video && !permissions.video) {
1354
+ this.emit('error', new RpcError('media-disabled', 'video is switched off for this project; the camera is not published'));
1355
+ } else if (video) {
946
1356
  // The track is read when the camera's turn comes: one handed to
947
1357
  // replaceVideoTrack while this publish was under way is the one to send.
948
1358
  await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
949
1359
  }
950
1360
  }
951
1361
 
1362
+ // ------------------------------------------------------------ audio quality
1363
+
1364
+ /**
1365
+ * The microphone's audio quality: what it is being sent at while
1366
+ * published, else what the next publish will use, after the project's limit.
1367
+ */
1368
+ get audioQuality(): AudioQuality {
1369
+ if (this.micProducer()) return this.micQuality;
1370
+ return this.publishOptions.audioQuality === 'hd' && this.mediaPermissions.audioQualityMax === 'hd' ? 'hd' : 'std';
1371
+ }
1372
+
1373
+ /**
1374
+ * Switch the microphone between standard (mono, up to 64 kbps) and HD
1375
+ * (stereo, up to 128 kbps) while live. The codec settings are fixed per
1376
+ * producer, so the microphone's producer is replaced: listeners hear a gap
1377
+ * of a fraction of a second, and a microphone that is off stays off. Rejects
1378
+ * with `audio-quality-not-allowed` when the project allows standard audio
1379
+ * only. Resolves to the quality in use.
1380
+ */
1381
+ async setAudioQuality(quality: AudioQuality): Promise<AudioQuality> {
1382
+ if (quality !== 'std' && quality !== 'hd') throw new RangeError("setAudioQuality: expected 'std' or 'hd'");
1383
+ if (quality === 'hd' && this.mediaPermissions.audioQualityMax !== 'hd') {
1384
+ throw new RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project');
1385
+ }
1386
+ this.publishOptions = { ...this.publishOptions, audioQuality: quality };
1387
+ this.audioCapReported = false;
1388
+ await this.micTask(async () => {
1389
+ const current = this.micProducer();
1390
+ const track = current?.track;
1391
+ if (!current || !track || track.readyState !== 'live' || this.micQuality === quality) return;
1392
+ await this.reproduceMic(current, track);
1393
+ });
1394
+ return this.audioQuality;
1395
+ }
1396
+
1397
+ private micProducer(): Producer | undefined {
1398
+ for (const p of this.producers.values()) {
1399
+ if (p.kind === 'audio' && (p.appData as { source?: string }).source === 'mic') return p;
1400
+ }
1401
+ return undefined;
1402
+ }
1403
+
1404
+ private micTask<T>(task: () => Promise<T>): Promise<T> {
1405
+ const run = this.micQueue.then(task, task);
1406
+ this.micQueue = run.catch(() => undefined);
1407
+ return run;
1408
+ }
1409
+
1410
+ /** The audio quality to send: the request, capped to the project's, with the cap reported once. */
1411
+ private audioQualityFor(): AudioQuality {
1412
+ if (this.publishOptions.audioQuality !== 'hd') return 'std';
1413
+ if (this.mediaPermissions.audioQualityMax === 'hd') return 'hd';
1414
+ if (!this.audioCapReported) {
1415
+ this.audioCapReported = true;
1416
+ this.emit('error', new RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project; publishing standard audio'));
1417
+ }
1418
+ return 'std';
1419
+ }
1420
+
1421
+ /**
1422
+ * Produce the microphone at the audio quality in force. A track handed over
1423
+ * switched off (or paused before a reconnect or a quality change) starts
1424
+ * paused, on both ends.
1425
+ */
1426
+ private async produceMic(transport: Transport, track: MediaStreamTrack, generation: number): Promise<Producer> {
1427
+ if (generation !== this.publishGeneration) throw unpublishedWhile('microphone');
1428
+ const quality = this.audioQualityFor();
1429
+ const settings = AUDIO_SETTINGS[quality];
1430
+ const producer = await transport.produce({
1431
+ track,
1432
+ // Keep the track alive when the producer closes: a reconnect closes
1433
+ // every producer, and a stopped track cannot be republished.
1434
+ stopTracks: false,
1435
+ // The answer's maxaveragebitrate sets the encoder's rate and the
1436
+ // sender's maxBitrate holds it there: standard must stay at or under
1437
+ // 64 kbps, which is what the server measures to bill it, so its sender
1438
+ // is held a little under the declared rate (Opus VBR overshoots).
1439
+ encodings: [{ maxBitrate: settings.senderMaxBitrate }],
1440
+ codecOptions: { opusStereo: settings.stereo, opusDtx: true, opusFec: true, opusMaxAverageBitrate: settings.maxBitrate },
1441
+ appData: { source: 'mic', audioQuality: quality },
1442
+ });
1443
+ await this.keepProducer(producer, generation, 'microphone');
1444
+ this.micQuality = quality;
1445
+ // A microphone handed over switched off, or muted before a reconnect.
1446
+ await this.pauseOnServer(producer);
1447
+ return producer;
1448
+ }
1449
+
1450
+ /** Close the microphone producer and produce its track again at the quality now in force. */
1451
+ private async reproduceMic(current: Producer, track: MediaStreamTrack): Promise<Producer> {
1452
+ const generation = this.publishGeneration;
1453
+ current.close();
1454
+ this.producers.delete(current.id);
1455
+ // The server allows one producer per source: the old one goes first.
1456
+ await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
1457
+ return this.produceMic(await this.ensureSendTransport(), track, generation);
1458
+ }
1459
+
952
1460
  /** What this project may publish. Known once joined; 1080p before that. */
953
1461
  get videoPolicy(): VideoPolicy {
954
1462
  return this.joinResult?.video ?? DEFAULT_VIDEO_POLICY;
955
1463
  }
956
1464
 
957
- /** The quality video is being sent at, after the project's limit. */
1465
+ /**
1466
+ * The quality video is being sent at, after the project's limit: at 60 fps
1467
+ * the largest size the project allows at 60, otherwise its 30 fps maximum.
1468
+ */
958
1469
  get videoQuality(): VideoQuality {
959
- return allowedQuality(this.publishOptions.quality, this.videoPolicy);
1470
+ return qualityAt(this.publishOptions.quality, this.videoFrameRate, this.mediaPermissions);
1471
+ }
1472
+
1473
+ /** The camera's frame rate: what it is being sent at while published, else 30 until a publish decides. */
1474
+ get videoFrameRate(): 30 | 60 {
1475
+ return this.cameraProducer() ? this.cameraFrameRate : 30;
960
1476
  }
961
1477
 
962
1478
  /**
@@ -967,6 +1483,7 @@ export class StreamClient {
967
1483
  */
968
1484
  async setVideoQuality(quality: VideoQuality): Promise<VideoQuality> {
969
1485
  this.publishOptions = { ...this.publishOptions, quality };
1486
+ this.sizeCapReported = false;
970
1487
  await this.cameraTask(async () => {
971
1488
  const current = this.cameraProducer();
972
1489
  const track = current?.track;
@@ -976,12 +1493,24 @@ export class StreamClient {
976
1493
  return this.videoQuality;
977
1494
  }
978
1495
 
979
- /** Change the camera frame rate without stopping screen, microphone or music. */
1496
+ /**
1497
+ * Change the camera frame rate without stopping screen, microphone or music.
1498
+ * 60 where the project allows none stays at 30 and warns on `error` with
1499
+ * `frame-rate-not-allowed`, without touching the camera; a camera or device
1500
+ * that cannot keep 60 up falls back with `frame-rate-fallback`. Resolves to
1501
+ * the frame rate used.
1502
+ */
980
1503
  async setVideoFrameRate(frameRate: 30 | 60): Promise<30 | 60> {
981
1504
  if (frameRate !== 30 && frameRate !== 60) throw new RangeError('setVideoFrameRate: expected 30 or 60');
982
1505
  this.publishOptions = { ...this.publishOptions, frameRate };
983
1506
  this.frameRateFallback = false;
984
1507
  this.frameRateLowSamples = 0;
1508
+ this.sizeCapReported = false;
1509
+ if (frameRate === 60 && !this.allows60()) {
1510
+ this.frameRateFallback = true;
1511
+ this.emit('error', new RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
1512
+ return this.videoFrameRate;
1513
+ }
985
1514
  await this.cameraTask(async () => {
986
1515
  const current = this.cameraProducer();
987
1516
  const track = current?.track;
@@ -990,7 +1519,27 @@ export class StreamClient {
990
1519
  catch { /* Existing capture settings decide whether 60 fps is possible. */ }
991
1520
  await this.reproduceCamera(current, track);
992
1521
  });
993
- return this.cameraProducer() ? this.cameraFrameRate : this.videoPolicy.maxFrameRate === 30 ? 30 : frameRate;
1522
+ return this.cameraProducer() ? this.cameraFrameRate : this.allows60() ? frameRate : 30;
1523
+ }
1524
+
1525
+ /** Whether this project allows any 60 fps mode. */
1526
+ private allows60(): boolean {
1527
+ const permissions = this.mediaPermissions;
1528
+ return permissions.maxFrameRate === 60 && permissions.maxVideoQuality60 !== null;
1529
+ }
1530
+
1531
+ /**
1532
+ * The quality to send at a frame rate. A request above what 60 fps allows
1533
+ * is sent at the largest size it does allow, and said so once.
1534
+ */
1535
+ private cameraQuality(frameRate: 30 | 60): VideoQuality {
1536
+ const permissions = this.mediaPermissions;
1537
+ if (frameRate === 60 && !this.sizeCapReported && above60fpsCap(this.publishOptions.quality, permissions)) {
1538
+ this.sizeCapReported = true;
1539
+ this.emit('error', new RpcError('video-quality-not-allowed',
1540
+ `${this.publishOptions.quality} at 60 fps is not enabled for this project; publishing ${permissions.maxVideoQuality60} at 60 fps`));
1541
+ }
1542
+ return qualityAt(this.publishOptions.quality, frameRate, permissions);
994
1543
  }
995
1544
 
996
1545
  /**
@@ -1033,7 +1582,8 @@ export class StreamClient {
1033
1582
  const paused = current.paused;
1034
1583
  try {
1035
1584
  const { width = 0, height = 0 } = track.getSettings();
1036
- const next = videoEncodings(width, height, this.videoQuality, this.layerCount(), this.frameRateFor(track));
1585
+ const frameRate = this.frameRateFor(track);
1586
+ const next = videoEncodings(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
1037
1587
  const previousSize = current.track?.getSettings();
1038
1588
  if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
1039
1589
  previousSize?.width === width && previousSize?.height === height) {
@@ -1222,7 +1772,7 @@ export class StreamClient {
1222
1772
  generation: number,
1223
1773
  paused = !track.enabled,
1224
1774
  ): Promise<Producer> {
1225
- if (generation !== this.publishGeneration) throw new Error(unpublishedWhile('camera'));
1775
+ if (generation !== this.publishGeneration) throw unpublishedWhile('camera');
1226
1776
  // mediasoup-client starts a producer paused exactly when its track is
1227
1777
  // off. Setting the track to the camera's state first makes the producer
1228
1778
  // start in it, and a camera that is off never sends a frame of the new
@@ -1233,8 +1783,9 @@ export class StreamClient {
1233
1783
  // the device, so the browser sends every layer asked for (see
1234
1784
  // videoEncodings).
1235
1785
  const { width = 0, height = 0 } = track.getSettings();
1236
- const quality = this.videoQuality;
1786
+ // The frame rate first: the size the project allows depends on it.
1237
1787
  this.cameraFrameRate = this.frameRateFor(track);
1788
+ const quality = this.cameraQuality(this.cameraFrameRate);
1238
1789
  const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
1239
1790
  let codecs: RtpCodecCapability[] | undefined;
1240
1791
  try {
@@ -1283,7 +1834,7 @@ export class StreamClient {
1283
1834
  if (generation !== this.publishGeneration) {
1284
1835
  producer.close();
1285
1836
  await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1286
- throw new Error(unpublishedWhile(what));
1837
+ throw unpublishedWhile(what);
1287
1838
  }
1288
1839
  this.producers.set(producer.id, producer);
1289
1840
  }
@@ -1327,7 +1878,7 @@ export class StreamClient {
1327
1878
  /** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
1328
1879
  private reportCodecFallback(message: string): void {
1329
1880
  if (normalizeCodec(this.publishOptions.codec) === 'h264') {
1330
- this.emit('error', new Error(message));
1881
+ this.emit('error', new RpcError('codec-fallback', message));
1331
1882
  } else if (!this.codecFallbackLogged) {
1332
1883
  this.codecFallbackLogged = true;
1333
1884
  console.warn(`[voltkit-rtc] ${message}`);
@@ -1381,7 +1932,7 @@ export class StreamClient {
1381
1932
 
1382
1933
  if (!live) {
1383
1934
  if (!this.publishConstraints) {
1384
- this.emit('error', new Error('cannot restore the broadcast: the devices are gone'));
1935
+ this.emit('error', new RpcError('devices-gone', 'cannot restore the broadcast: the devices are gone'));
1385
1936
  return;
1386
1937
  }
1387
1938
  const ended = stream;
@@ -1401,7 +1952,7 @@ export class StreamClient {
1401
1952
  }
1402
1953
  if (!stream) return;
1403
1954
 
1404
- await this.publish(stream);
1955
+ await this.publishStream(stream);
1405
1956
  }
1406
1957
 
1407
1958
  // ------------------------------------------------------------- screen sharing
@@ -1409,6 +1960,7 @@ export class StreamClient {
1409
1960
  /** Publish a screen beside the camera. Call from a click so capture permission can open. */
1410
1961
  async publishScreen(options: ScreenPublishOptions = {}): Promise<MediaStream> {
1411
1962
  if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost') throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
1963
+ if (!this.mediaPermissions.video) throw new RpcError('media-disabled', 'video is switched off for this project');
1412
1964
  if (this.screenStream || this.screenPending) throw new RpcError('screen-share-active', 'a screen share is already active');
1413
1965
  if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion') throw new TypeError('publishScreen: mode must be detail or motion');
1414
1966
  const mode = options.mode ?? 'detail';
@@ -1425,7 +1977,7 @@ export class StreamClient {
1425
1977
  });
1426
1978
  const video = stream.getVideoTracks()[0];
1427
1979
  if (!video || video.readyState !== 'live') throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
1428
- if (generation !== this.screenGeneration || this.closedByUs) throw new Error(unpublishedWhile('screen'));
1980
+ if (generation !== this.screenGeneration || this.closedByUs) throw unpublishedWhile('screen');
1429
1981
  this.screenStream = stream;
1430
1982
  this.screenMode = mode;
1431
1983
  this.screenAudioEnabled = options.audio !== false;
@@ -1434,7 +1986,7 @@ export class StreamClient {
1434
1986
  this.screenEndedListener = onEnded;
1435
1987
  video.addEventListener('ended', onEnded, { once: true });
1436
1988
  await this.restoreScreen();
1437
- if (generation !== this.screenGeneration || video.readyState !== 'live') throw new Error(unpublishedWhile('screen'));
1989
+ if (generation !== this.screenGeneration || video.readyState !== 'live') throw unpublishedWhile('screen');
1438
1990
  this.screenStarted = true;
1439
1991
  this.wasPublishing = true;
1440
1992
  this.emit('screenShareStarted', { stream, mode });
@@ -1487,14 +2039,14 @@ export class StreamClient {
1487
2039
  const { width = 0, height = 0 } = video.getSettings();
1488
2040
  const encodings = screenEncodings(width, height, this.screenMode);
1489
2041
  // A project's explicit safety restriction also applies to screen video.
1490
- const cap = topLayerSize(width, height, this.videoPolicy.maxQuality);
2042
+ const cap = topLayerSize(width, height, this.mediaPermissions.maxVideoQuality);
1491
2043
  const screenScale = encodings.at(-1)!.scaleResolutionDownBy;
1492
2044
  if (cap.scale > screenScale) encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
1493
2045
  const keep = async (producer: Producer) => {
1494
2046
  if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
1495
2047
  producer.close();
1496
2048
  await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1497
- throw new Error(unpublishedWhile('screen'));
2049
+ throw unpublishedWhile('screen');
1498
2050
  }
1499
2051
  this.producers.set(producer.id, producer);
1500
2052
  this.screenProducerIds.add(producer.id);
@@ -1505,7 +2057,12 @@ export class StreamClient {
1505
2057
  codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
1506
2058
  appData: { source: 'screen', videoLayers: describeVideoLayers(width, height, encodings) },
1507
2059
  }));
1508
- const audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
2060
+ let audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
2061
+ if (audio && this.screenAudioEnabled && !this.mediaPermissions.audio) {
2062
+ // The screen goes out; its sound cannot in a project without audio.
2063
+ this.emit('error', new RpcError('media-disabled', 'audio is switched off for this project; the screen is shared without its sound'));
2064
+ audio = undefined;
2065
+ }
1509
2066
  if (audio && this.screenAudioEnabled && generation === this.screenGeneration) await keep(await transport.produce({
1510
2067
  track: audio, stopTracks: false,
1511
2068
  codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128000 },
@@ -1513,12 +2070,24 @@ export class StreamClient {
1513
2070
  }));
1514
2071
  }
1515
2072
 
2073
+ /**
2074
+ * 60 only when asked for, allowed by the project, captured at 50 or more and
2075
+ * not fallen back from. The project's refusal and the camera's are reported
2076
+ * apart, each once: `frame-rate-not-allowed` is the server's own code for a
2077
+ * 60 fps producer the project does not allow, `frame-rate-fallback` means
2078
+ * the camera or device cannot provide it.
2079
+ */
1516
2080
  private frameRateFor(track: MediaStreamTrack): 30 | 60 {
1517
2081
  if (this.publishOptions.frameRate !== 60 || this.frameRateFallback) return 30;
2082
+ if (!this.allows60()) {
2083
+ this.frameRateFallback = true;
2084
+ this.emit('error', new RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
2085
+ return 30;
2086
+ }
1518
2087
  const captured = track.getSettings().frameRate;
1519
- if (this.videoPolicy.maxFrameRate === 30 || (typeof captured === 'number' && captured < 50)) {
2088
+ if (typeof captured === 'number' && captured < 50) {
1520
2089
  this.frameRateFallback = true;
1521
- this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera or project; publishing at 30 fps'));
2090
+ this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera; publishing at 30 fps'));
1522
2091
  return 30;
1523
2092
  }
1524
2093
  return 60;
@@ -1563,6 +2132,7 @@ export class StreamClient {
1563
2132
  * the user has not interacted with.
1564
2133
  */
1565
2134
  async publishMusic(input: MusicInput, options?: MusicOptions): Promise<void> {
2135
+ if (!this.mediaPermissions.audio) throw new RpcError('media-disabled', 'audio is switched off for this project');
1566
2136
  const generation = this.publishGeneration;
1567
2137
  if (this.music) await this.stopMusic();
1568
2138
  const music = new MusicSource(input, options);
@@ -1571,7 +2141,7 @@ export class StreamClient {
1571
2141
  // Checked again here: an unpublish() after the producer was kept has
1572
2142
  // already closed it, and the music must not be remembered for a
1573
2143
  // reconnect to publish again.
1574
- if (generation !== this.publishGeneration) throw new Error(unpublishedWhile('music'));
2144
+ if (generation !== this.publishGeneration) throw unpublishedWhile('music');
1575
2145
  } catch (err) {
1576
2146
  music.close();
1577
2147
  throw err;
@@ -1583,7 +2153,7 @@ export class StreamClient {
1583
2153
 
1584
2154
  private async produceMusic(music: MusicSource, generation: number): Promise<void> {
1585
2155
  const transport = await this.ensureSendTransport();
1586
- if (generation !== this.publishGeneration) throw new Error(unpublishedWhile('music'));
2156
+ if (generation !== this.publishGeneration) throw unpublishedWhile('music');
1587
2157
  const producer = await transport.produce({
1588
2158
  track: music.track,
1589
2159
  stopTracks: false,
@@ -1646,12 +2216,46 @@ export class StreamClient {
1646
2216
  }
1647
2217
 
1648
2218
  private async consume(remote: RemoteProducer): Promise<void> {
1649
- if (!this.device) throw new Error('not connected');
2219
+ if (!this.device) throw new RpcError('not-connected', 'not connected');
1650
2220
  if (this.consumers.has(remote.producerId)) return;
1651
2221
  // Your own camera is already local; pulling it back off the SFU wastes a
1652
2222
  // consumer and shows you a delayed copy of yourself.
1653
2223
  if (remote.identity === this.joinResult?.identity) return;
2224
+ // A wallet session receives at most media.fanout.maxVideoSubscriptions
2225
+ // videos; the media node refuses the next. Wait for a slot instead of
2226
+ // asking for a refusal, and say so once per video.
2227
+ if (remote.kind === 'video' && !this.videoSlots.has(remote.producerId)) {
2228
+ const limit = this.mediaPermissions.maxVideoSubscriptions;
2229
+ if (limit !== null && this.videoSlots.size >= limit) {
2230
+ if (!this.skippedVideos.has(remote.producerId)) {
2231
+ this.skippedVideos.set(remote.producerId, remote);
2232
+ this.emit('error', new RpcError('video-subscription-limit',
2233
+ `already receiving ${limit} videos, the most this project allows; ${remote.identity}'s video waits for one to end`));
2234
+ }
2235
+ return;
2236
+ }
2237
+ this.videoSlots.add(remote.producerId);
2238
+ }
2239
+ try {
2240
+ await this.consumeNow(remote);
2241
+ } catch (err) {
2242
+ if (!this.consumers.has(remote.producerId)) this.videoSlots.delete(remote.producerId);
2243
+ throw err;
2244
+ }
2245
+ }
1654
2246
 
2247
+ /** The oldest videos left waiting at the fan-out limit take the slots that opened. */
2248
+ private async consumeSkipped(): Promise<void> {
2249
+ for (const [producerId, remote] of this.skippedVideos) {
2250
+ const limit = this.mediaPermissions.maxVideoSubscriptions;
2251
+ if (limit !== null && this.videoSlots.size >= limit) return;
2252
+ this.skippedVideos.delete(producerId);
2253
+ await this.consume(remote);
2254
+ }
2255
+ }
2256
+
2257
+ private async consumeNow(remote: RemoteProducer): Promise<void> {
2258
+ if (!this.device) throw new RpcError('not-connected', 'not connected');
1655
2259
  const transport = await this.ensureRecvTransport();
1656
2260
  const params = (await this.rpc('consume', {
1657
2261
  transportId: transport.id,
@@ -1683,6 +2287,7 @@ export class StreamClient {
1683
2287
  const kind = consumer.kind as 'audio' | 'video';
1684
2288
  const source = remote.source ?? defaultSource(kind);
1685
2289
  this.remoteSources.set(remote.producerId, source);
2290
+ this.remoteInfo.set(remote.producerId, { identity: remote.identity, kind, paused: Boolean(remote.paused) });
1686
2291
  this.emit('track', {
1687
2292
  identity: remote.identity,
1688
2293
  kind,
@@ -1822,6 +2427,67 @@ export class StreamClient {
1822
2427
  endRoom(): Promise<unknown> { return this.rpc('endRoom'); }
1823
2428
  getRoomState(): Promise<unknown> { return this.rpc('getRoomState'); }
1824
2429
 
2430
+ // ------------------------------------------------------- slots and metadata
2431
+ //
2432
+ // A slot is a named thing in the room that at most one participant holds at
2433
+ // a time: a seat on a board, a guest spot. Keys are yours; the server never
2434
+ // learns that `seat-13` is a seat. `info.claims` and `info.blockedSlots`
2435
+ // seed a board on join and follow claimChanged.
2436
+
2437
+ /**
2438
+ * Take a slot, or (moderators) grant one by naming `identity`. One claim
2439
+ * per identity: a second moves you. Refused with `slot-taken` when someone
2440
+ * holds it (away included) and `slot-blocked` when a moderator locked it.
2441
+ * Claim first, then promote: if the slot has gone, nothing has happened yet.
2442
+ */
2443
+ async claim(key: string, identity?: string): Promise<void> {
2444
+ await this.rpc('claim', identity ? { key, identity } : { key });
2445
+ }
2446
+
2447
+ /** Give up a slot. Moderators may release anyone's. */
2448
+ async release(key: string): Promise<void> {
2449
+ await this.rpc('release', { key });
2450
+ }
2451
+
2452
+ /**
2453
+ * Lock a slot so nobody may claim it. Moderators only. Refused with
2454
+ * `slot-occupied` while someone holds it: taking a seat from a person using
2455
+ * it is two deliberate acts, not a side effect of one tap.
2456
+ */
2457
+ async blockSlot(key: string): Promise<void> {
2458
+ await this.rpc('blockSlot', { key });
2459
+ }
2460
+
2461
+ /** Unlock a slot. Moderators only. */
2462
+ async unblockSlot(key: string): Promise<void> {
2463
+ await this.rpc('unblockSlot', { key });
2464
+ }
2465
+
2466
+ /** Replace your own display metadata; the room hears it as participantUpdated. */
2467
+ async setMetadata(metadata: Record<string, unknown>): Promise<void> {
2468
+ await this.rpc('setMetadata', { metadata });
2469
+ }
2470
+
2471
+ /**
2472
+ * Change room-scoped metadata. Moderators only, and persisted, so a client
2473
+ * joining later reads it from `info.metadata`. Merges one key deep by
2474
+ * default (a null value deletes its key); `replace` swaps the whole object.
2475
+ * `requireFree` names slots that must be unheld for the change to apply,
2476
+ * checked and applied together: the refusal (`slot-occupied`) names the
2477
+ * offending keys in `RpcError.data.slots`. Resolves to the metadata now.
2478
+ */
2479
+ async setRoomMetadata(
2480
+ metadata: Record<string, unknown>,
2481
+ options: { replace?: boolean; requireFree?: string[] } = {},
2482
+ ): Promise<unknown> {
2483
+ const res = (await this.rpc('setRoomMetadata', {
2484
+ metadata,
2485
+ ...(options.replace ? { replace: true } : {}),
2486
+ ...(options.requireFree ? { requireFree: options.requireFree } : {}),
2487
+ })) as { metadata?: unknown } | undefined;
2488
+ return res?.metadata ?? null;
2489
+ }
2490
+
1825
2491
  // ------------------------------------------------------------------- chat
1826
2492
  //
1827
2493
  // The room's chat rides this same socket. Someone on a chat-only socket