@voltkit-rtc/client 0.7.1 → 0.7.3

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.
package/dist/esm/index.js CHANGED
@@ -58,6 +58,16 @@ const VIDEO_START_BITRATE_KBPS = 2000;
58
58
  /** Why a connect() gave up: close() ran while it was under way, and close wins. */
59
59
  const closedWhileConnecting = () => new RpcError('not-connected', 'the session was closed while connecting');
60
60
  /** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
61
+ /**
62
+ * The constraints that capture again what two publishes captured: each kind
63
+ * as the later publish asked for it, or as the earlier one did if the later
64
+ * one did not ask for it.
65
+ */
66
+ function mergeConstraints(earlier, later) {
67
+ if (!earlier)
68
+ return later;
69
+ return { ...earlier, ...later, audio: later.audio || earlier.audio, video: later.video || earlier.video };
70
+ }
61
71
  const unpublishedWhile = (what) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
62
72
  /**
63
73
  * mediasoup-client puts the microphone's Opus maxaveragebitrate in the answer
@@ -105,11 +115,30 @@ export class StreamClient {
105
115
  // broadcaster that reconnects rejoins silently, which is worse than being
106
116
  // disconnected: the room looks healthy and shows nothing.
107
117
  wasPublishing = false;
118
+ /** The deadline of the socket being opened (openSocket). */
119
+ openDeadline;
120
+ /**
121
+ * This client's own moderation per kind: whether a moderator holds it off,
122
+ * and a revision every decision bumps. A switch-on that was waiting for the
123
+ * server when a decision arrived does not act on its reply (V072-06).
124
+ */
125
+ forcedOff = { audio: false, video: false };
126
+ moderationRevision = { audio: 0, video: 0 };
108
127
  publishConstraints = null;
109
128
  publishOptions = {};
110
129
  /** In-app music being published, kept across reconnects so it can be published again. */
111
130
  music = null;
112
131
  musicProducerId = null;
132
+ /**
133
+ * Bumped by every publishMusic() and stopMusic(). A music producer still
134
+ * being made when it changes belongs to a publish that is over: it is
135
+ * closed, not kept. publishGeneration alone missed stopMusic(), and the
136
+ * music went out after stopMusic() had returned.
137
+ */
138
+ musicOperation = 0;
139
+ // Music publishes run one at a time: the server takes one producer per
140
+ // source, so the next is produced only once the last one is settled.
141
+ musicQueue = Promise.resolve();
113
142
  screenStream = null;
114
143
  screenMode = 'detail';
115
144
  screenAudioEnabled = true;
@@ -126,6 +155,8 @@ export class StreamClient {
126
155
  frameRateWarmupSamples = 2;
127
156
  /** What each remote producer carries, so a pause can say "music" rather than just "audio". */
128
157
  remoteSources = new Map();
158
+ /** Identity of each pending/active subscription; cleared on close/reconnect. */
159
+ subscriptions = new Map();
129
160
  /**
130
161
  * The layers asked for each remote video, by producerId. A reconnect
131
162
  * consumes everything again, and the new consumer would otherwise start at
@@ -164,6 +195,17 @@ export class StreamClient {
164
195
  /** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
165
196
  tokenExpiry;
166
197
  tokenTimer = null;
198
+ /**
199
+ * Bumped whenever the server hands this client a token (its own refresh, a
200
+ * promotion's). An updateToken() that started before then keeps the token
201
+ * it was given only if nothing newer arrived while it waited: its reply,
202
+ * or its fallback once the socket closed, used to replace a promotion's
203
+ * token, and the reconnect joined with the old one.
204
+ */
205
+ serverTokenRevision = 0;
206
+ /** updateToken() calls in call order, and the newest one whose token is held: a later call wins over an earlier one answered after it. */
207
+ tokenRequests = 0;
208
+ tokenRequestHeld = 0;
167
209
  /** Whose each received track is and whether it is switched off, by producerId. */
168
210
  remoteInfo = new Map();
169
211
  /** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
@@ -225,6 +267,17 @@ export class StreamClient {
225
267
  this.setState('closed');
226
268
  this.emit('closed', reason);
227
269
  }
270
+ /**
271
+ * Over for good without the app asking: finish, then release everything
272
+ * local as close() does -- camera, microphone, screen, music, recording.
273
+ * kicked and roomClosed always did; a replaced socket, a close without
274
+ * roomClosed and a reconnect that gave up left them capturing (and the
275
+ * recording uploading) after `closed`, until the app called close().
276
+ */
277
+ end(reason) {
278
+ this.finish(reason);
279
+ this.close();
280
+ }
228
281
  stopTimers() {
229
282
  if (this.tokenTimer)
230
283
  clearTimeout(this.tokenTimer);
@@ -251,8 +304,13 @@ export class StreamClient {
251
304
  let result;
252
305
  if (this.state === 'idle' || this.state === 'closed')
253
306
  this.setState('connecting');
307
+ // The socket this call opens. A later connect() replaces it, and this
308
+ // call's failure must then leave the newer socket, and the state, alone.
309
+ let socket = null;
254
310
  try {
255
- await this.openSocket();
311
+ const opening = this.openSocket();
312
+ socket = this.ws;
313
+ await opening;
256
314
  result = (await this.rpc('join', { token: this.token }));
257
315
  this.joinResult = result;
258
316
  this.device = this.createDevice();
@@ -266,7 +324,11 @@ export class StreamClient {
266
324
  // A session that never got going: the app has the error. Left open,
267
325
  // the socket would be closed by the server's join deadline instead, and
268
326
  // that close would start reconnecting a session that never began.
269
- if (!this.joinedOnce) {
327
+ const superseded = this.ws !== null && this.ws !== socket;
328
+ // A reconnect attempt that failed (its join timed out, say) lets its
329
+ // socket go now too, so nothing it says later reaches the session while
330
+ // the next attempt waits its backoff (V072-05).
331
+ if ((!this.joinedOnce || this.reconnecting) && !superseded) {
270
332
  this.detachSocket();
271
333
  if (this.state === 'connecting')
272
334
  this.setState('idle');
@@ -290,6 +352,8 @@ export class StreamClient {
290
352
  throw closedWhileConnecting();
291
353
  }
292
354
  const own = result.participants.find((p) => p.identity === result.identity);
355
+ this.moderate('audio', Boolean(own?.muted), true);
356
+ this.moderate('video', Boolean(own?.videoBlocked), true);
293
357
  if (own?.muted)
294
358
  this.applyLocalModeration('audio');
295
359
  if (own?.videoBlocked)
@@ -345,11 +409,23 @@ export class StreamClient {
345
409
  return new Promise((resolve, reject) => {
346
410
  const ws = new WebSocket(socketUrl(this.wsUrl, this.token));
347
411
  this.ws = ws;
412
+ // Bounded like every request (V072-05): a socket that does not open in
413
+ // time fails the attempt with `timeout` and is closed, instead of
414
+ // holding connect() -- or a reconnect attempt -- indefinitely.
415
+ this.openDeadline = setTimeout(() => {
416
+ reject(new RpcError('timeout', `the connection did not open in ${RPC_TIMEOUT_MS / 1000} s`));
417
+ if (this.ws === ws)
418
+ this.detachSocket('open timed out', 'the connection did not open in time');
419
+ }, RPC_TIMEOUT_MS);
348
420
  const onOpen = () => {
421
+ clearTimeout(this.openDeadline);
349
422
  this.emit('connected');
350
423
  resolve();
351
424
  };
352
- const onError = () => reject(new RpcError('connect-failed', 'websocket failed to open'));
425
+ const onError = () => {
426
+ clearTimeout(this.openDeadline);
427
+ reject(new RpcError('connect-failed', 'websocket failed to open'));
428
+ };
353
429
  ws.addEventListener('open', onOpen, { once: true });
354
430
  ws.addEventListener('error', onError, { once: true });
355
431
  ws.addEventListener('message', (ev) => {
@@ -370,17 +446,16 @@ export class StreamClient {
370
446
  // Another session for this identity is live. Reconnecting would
371
447
  // displace it, and it would displace us right back, forever.
372
448
  this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
373
- // The producers outlive this close until the app calls close(); the
374
- // videoLimited sampling must not, or it reports after `closed`.
449
+ // The videoLimited sampling stops first, or it reports after `closed`.
375
450
  this.limitWatch.reset();
376
- this.finish('replaced');
451
+ this.end('replaced');
377
452
  return;
378
453
  }
379
454
  if (ev.code === CLOSE_ENDED) {
380
455
  // Normally roomClosed has already ended the session; this covers a
381
456
  // close that arrived without it.
382
457
  this.limitWatch.reset();
383
- this.finish('room-closed');
458
+ this.end('room-closed');
384
459
  return;
385
460
  }
386
461
  // Before a first successful join the app is still waiting on
@@ -393,10 +468,13 @@ export class StreamClient {
393
468
  });
394
469
  });
395
470
  }
396
- detachSocket(reason = 'superseded') {
471
+ detachSocket(reason = 'superseded', why = 'the connection was replaced') {
472
+ clearTimeout(this.openDeadline);
397
473
  const ws = this.ws;
398
474
  this.ws = null;
399
- if (ws && ws.readyState <= WebSocket.OPEN) {
475
+ if (!ws)
476
+ return;
477
+ if (ws.readyState <= WebSocket.OPEN) {
400
478
  try {
401
479
  ws.close(1000, reason);
402
480
  }
@@ -404,6 +482,10 @@ export class StreamClient {
404
482
  /* already closing */
405
483
  }
406
484
  }
485
+ // Its replies are no longer read, so what waits on it would only wait out
486
+ // its 15 s timeout: it fails now, as on a close. Never replayed -- the
487
+ // server may already have acted on it.
488
+ this.failPending(ws, new RpcError('not-connected', why));
407
489
  }
408
490
  /**
409
491
  * A reconnect that ends the session lets go of its socket. A refused join
@@ -412,7 +494,7 @@ export class StreamClient {
412
494
  * still sending.
413
495
  */
414
496
  releaseSocket() {
415
- this.detachSocket('gave up');
497
+ this.detachSocket('gave up', 'the session has ended');
416
498
  this.failAllPending(new RpcError('not-connected', 'the session has ended'));
417
499
  }
418
500
  /**
@@ -433,7 +515,7 @@ export class StreamClient {
433
515
  if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
434
516
  this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
435
517
  this.releaseSocket();
436
- this.finish('gave-up');
518
+ this.end('gave-up');
437
519
  return;
438
520
  }
439
521
  const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
@@ -461,7 +543,7 @@ export class StreamClient {
461
543
  // attempt forever and drowns out whatever actually went wrong.
462
544
  if (err instanceof RpcError && isTerminalError(err.code)) {
463
545
  this.releaseSocket();
464
- this.finish('gave-up');
546
+ this.end('gave-up');
465
547
  return;
466
548
  }
467
549
  }
@@ -506,6 +588,7 @@ export class StreamClient {
506
588
  this.emit('closed', 'left');
507
589
  }
508
590
  teardownMedia() {
591
+ this.subscriptions.clear();
509
592
  for (const c of this.consumers.values())
510
593
  c.close();
511
594
  this.consumers.clear();
@@ -576,19 +659,29 @@ export class StreamClient {
576
659
  * `tokenExpiring`. On a live connection the server checks it like a join
577
660
  * token (`bad-token`, or `token-mismatch` for another room or identity; the
578
661
  * session then keeps its token); otherwise it is kept for the next join,
579
- * which checks it there. Resolves to when it expires (unix ms), when known.
662
+ * which checks it there. Resolves to when the token now held expires (unix
663
+ * ms), when known. A newer token that arrives while this waits is kept
664
+ * instead of this one: the server's (its own refresh, or a promotion's,
665
+ * which carries the new role) or a later updateToken()'s.
580
666
  */
581
667
  async updateToken(token) {
582
668
  if (typeof token !== 'string' || token.trim() === '')
583
669
  throw new TypeError('updateToken needs a token');
584
670
  if (this.state === 'closed')
585
671
  throw new RpcError('not-connected', 'this session is closed; create a new client');
672
+ const request = ++this.tokenRequests;
673
+ const revision = this.serverTokenRevision;
674
+ const keep = (expiresAt) => {
675
+ if (revision === this.serverTokenRevision && request > this.tokenRequestHeld) {
676
+ this.tokenRequestHeld = request;
677
+ this.setToken(token, expiresAt);
678
+ }
679
+ return { expiresAt: this.tokenExpiry };
680
+ };
586
681
  if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
587
682
  try {
588
683
  const res = (await this.rpc('refreshToken', { token }));
589
- const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
590
- this.setToken(token, at);
591
- return { expiresAt: this.tokenExpiry };
684
+ return keep(typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null);
592
685
  }
593
686
  catch (err) {
594
687
  // An older server has no refreshToken, and a socket that dropped
@@ -598,8 +691,7 @@ export class StreamClient {
598
691
  throw err;
599
692
  }
600
693
  }
601
- this.setToken(token, null);
602
- return { expiresAt: this.tokenExpiry };
694
+ return keep(null);
603
695
  }
604
696
  /** The token reconnects use from now on, with its expiry (read from the token when not given). */
605
697
  setToken(token, expiresAt) {
@@ -660,10 +752,20 @@ export class StreamClient {
660
752
  this.pending.delete(id);
661
753
  reject(new RpcError('timeout', `${method} timed out`));
662
754
  }, RPC_TIMEOUT_MS);
663
- this.pending.set(id, { resolve, reject, timer });
755
+ this.pending.set(id, { resolve, reject, timer, ws: this.ws });
664
756
  this.ws.send(JSON.stringify({ id, method, params }));
665
757
  });
666
758
  }
759
+ /** Fail what was sent on `ws` and is still waiting. */
760
+ failPending(ws, err) {
761
+ for (const [id, p] of this.pending) {
762
+ if (p.ws !== ws)
763
+ continue;
764
+ clearTimeout(p.timer);
765
+ this.pending.delete(id);
766
+ p.reject(err);
767
+ }
768
+ }
667
769
  failAllPending(err) {
668
770
  for (const p of this.pending.values()) {
669
771
  clearTimeout(p.timer);
@@ -708,23 +810,21 @@ export class StreamClient {
708
810
  break;
709
811
  case 'producerClosed': {
710
812
  const { producerId, identity } = data;
813
+ this.subscriptions.delete(producerId);
711
814
  this.preferredLayers.delete(producerId);
712
815
  this.receiveQualities.delete(producerId);
713
816
  this.remoteVideoLayers.delete(producerId);
714
817
  this.skippedVideos.delete(producerId);
715
818
  const consumer = this.consumers.get(producerId);
716
- if (!consumer)
717
- break; // never consumed it, or already cleaned up
718
- this.videoSlots.delete(producerId);
719
- consumer.close();
819
+ consumer?.close();
720
820
  this.consumers.delete(producerId);
721
821
  this.remoteSources.delete(producerId);
722
822
  this.remoteInfo.delete(producerId);
723
- this.emit('trackEnded', { producerId, identity });
823
+ if (consumer)
824
+ this.emit('trackEnded', { producerId, identity });
724
825
  // A video slot opened under the fan-out limit: the oldest video left
725
826
  // waiting takes it.
726
- if (consumer.kind === 'video')
727
- void this.consumeSkipped().catch((err) => this.emit('error', err));
827
+ this.releaseVideoSlot(producerId);
728
828
  break;
729
829
  }
730
830
  case 'producerPaused': {
@@ -751,6 +851,8 @@ export class StreamClient {
751
851
  case 'participantMuted': {
752
852
  const wire = data;
753
853
  const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
854
+ if (e.identity && e.identity === this.joinResult?.identity)
855
+ this.moderate(e.kind, e.paused, true);
754
856
  this.updateRoster((list) => list.map((p) => p.identity !== e.identity ? p : { ...p,
755
857
  ...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
756
858
  ...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
@@ -797,6 +899,7 @@ export class StreamClient {
797
899
  // in-app music: it travels as audio). Reflect it locally; a release
798
900
  // changes nothing here -- switching back on is the user's choice.
799
901
  const e = data;
902
+ this.moderate(e.kind, Boolean(e.paused));
800
903
  if (e.paused) {
801
904
  this.applyLocalModeration(e.kind);
802
905
  this.emitLocalMedia();
@@ -822,15 +925,19 @@ export class StreamClient {
822
925
  // fresh one periodically so a reconnect at any point still works --
823
926
  // without this, any disconnect after the token's lifetime is terminal.
824
927
  const e = data;
825
- if (e.token)
928
+ if (e.token) {
929
+ this.serverTokenRevision += 1;
826
930
  this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
931
+ }
827
932
  break;
828
933
  }
829
934
  case 'mustReconnect': {
830
935
  const e = data;
831
936
  // A promotion hands us a fresh token carrying the new role.
832
- if (e.token)
937
+ if (e.token) {
938
+ this.serverTokenRevision += 1;
833
939
  this.setToken(e.token, null);
940
+ }
834
941
  this.emit('mustReconnect', e);
835
942
  if (e.retry) {
836
943
  this.teardownMedia();
@@ -996,10 +1103,28 @@ export class StreamClient {
996
1103
  // leave nothing behind that a reconnect would publish again.
997
1104
  if (generation !== this.publishGeneration)
998
1105
  throw unpublishedWhile('stream');
999
- this.localStream = stream;
1106
+ // A second publish adds to the first (the microphone, then the camera):
1107
+ // one local stream carries every live track, so unpublish() and close()
1108
+ // stop them all and a reconnect publishes each again. Replacing it
1109
+ // orphaned the first stream's tracks, still capturing (V072-01).
1110
+ const previous = this.localStream;
1111
+ const merged = previous !== null && previous !== stream && previous.getTracks().some((t) => t.readyState === 'live');
1112
+ if (merged) {
1113
+ const ids = new Set(stream.getTracks().map((t) => t.id));
1114
+ // The new stream's tracks first: of one kind, the newer track is the one
1115
+ // published (as before), and the older one is still stopped with it.
1116
+ this.localStream = new MediaStream([
1117
+ ...stream.getTracks(),
1118
+ ...previous.getTracks().filter((t) => t.readyState === 'live' && !ids.has(t.id)),
1119
+ ]);
1120
+ this.emit('localStreamChanged', this.localStream);
1121
+ }
1122
+ else {
1123
+ this.localStream = stream;
1124
+ }
1000
1125
  this.wasPublishing = true;
1001
1126
  if (constraints)
1002
- this.publishConstraints = constraints;
1127
+ this.publishConstraints = merged ? mergeConstraints(this.publishConstraints, constraints) : constraints;
1003
1128
  if (options) {
1004
1129
  this.publishOptions = { ...options };
1005
1130
  this.frameRateFallback = false;
@@ -1594,8 +1719,11 @@ export class StreamClient {
1594
1719
  await this.stopScreen('stopped');
1595
1720
  this.emit('error', err);
1596
1721
  });
1597
- if (this.music)
1598
- await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err));
1722
+ if (this.music) {
1723
+ const music = this.music;
1724
+ const operation = this.musicOperation;
1725
+ await this.musicTask(() => this.produceMusic(music, generation, operation)).catch((err) => this.emit('error', err));
1726
+ }
1599
1727
  if (generation !== this.publishGeneration)
1600
1728
  return;
1601
1729
  let stream = this.localStream;
@@ -1829,28 +1957,43 @@ export class StreamClient {
1829
1957
  if (!this.mediaPermissions.audio)
1830
1958
  throw new RpcError('media-disabled', 'audio is switched off for this project');
1831
1959
  const generation = this.publishGeneration;
1832
- if (this.music)
1833
- await this.stopMusic();
1834
- const music = new MusicSource(input, options);
1835
- try {
1836
- await this.produceMusic(music, generation);
1837
- // Checked again here: an unpublish() after the producer was kept has
1838
- // already closed it, and the music must not be remembered for a
1839
- // reconnect to publish again.
1840
- if (generation !== this.publishGeneration)
1960
+ // Taken now, in call order: a music publish still under way is over, and
1961
+ // a stopMusic() from here on ends this one.
1962
+ const operation = ++this.musicOperation;
1963
+ await this.musicTask(async () => {
1964
+ if (generation !== this.publishGeneration || operation !== this.musicOperation)
1841
1965
  throw unpublishedWhile('music');
1842
- }
1843
- catch (err) {
1844
- music.close();
1845
- throw err;
1846
- }
1847
- this.music = music;
1848
- this.wasPublishing = true;
1849
- this.emitLocalMedia();
1966
+ if (this.music)
1967
+ await this.closeMusic();
1968
+ const music = new MusicSource(input, options);
1969
+ try {
1970
+ await this.produceMusic(music, generation, operation);
1971
+ // Checked again here: an unpublish() or stopMusic() after the
1972
+ // producer was kept has already closed it, and the music must not be
1973
+ // remembered for a reconnect to publish again.
1974
+ if (generation !== this.publishGeneration || operation !== this.musicOperation)
1975
+ throw unpublishedWhile('music');
1976
+ }
1977
+ catch (err) {
1978
+ music.close();
1979
+ throw err;
1980
+ }
1981
+ this.music = music;
1982
+ this.wasPublishing = true;
1983
+ this.emitLocalMedia();
1984
+ });
1850
1985
  }
1851
- async produceMusic(music, generation) {
1986
+ musicTask(task) {
1987
+ const run = this.musicQueue.then(task, task);
1988
+ this.musicQueue = run.catch(() => undefined);
1989
+ return run;
1990
+ }
1991
+ async produceMusic(music, generation, operation) {
1992
+ // unpublish(), stopMusic() or a newer publishMusic() can run at any wait
1993
+ // below, and wins.
1994
+ const current = () => generation === this.publishGeneration && operation === this.musicOperation;
1852
1995
  const transport = await this.ensureSendTransport();
1853
- if (generation !== this.publishGeneration)
1996
+ if (!current())
1854
1997
  throw unpublishedWhile('music');
1855
1998
  const producer = await transport.produce({
1856
1999
  track: music.track,
@@ -1860,7 +2003,14 @@ export class StreamClient {
1860
2003
  codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
1861
2004
  appData: { source: 'music' },
1862
2005
  });
1863
- await this.keepProducer(producer, generation, 'music');
2006
+ if (!current()) {
2007
+ producer.close();
2008
+ await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
2009
+ throw unpublishedWhile('music');
2010
+ }
2011
+ // Kept and named the music in one step, with no wait between: stopMusic()
2012
+ // finds the music producer by that name.
2013
+ this.producers.set(producer.id, producer);
1864
2014
  this.musicProducerId = producer.id;
1865
2015
  // Music switched off before a reconnect comes back paused here.
1866
2016
  await this.pauseOnServer(producer);
@@ -1889,8 +2039,17 @@ export class StreamClient {
1889
2039
  await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
1890
2040
  this.emitLocalMedia();
1891
2041
  }
1892
- /** Stop publishing music. The element or stream it came from is left as it was. */
2042
+ /**
2043
+ * Stop publishing music. The element or stream it came from is left as it
2044
+ * was. A publishMusic() still under way is stopped too: it rejects with
2045
+ * `unpublished` and publishes nothing.
2046
+ */
1893
2047
  async stopMusic() {
2048
+ this.musicOperation += 1;
2049
+ await this.closeMusic();
2050
+ }
2051
+ /** Close the music being published, here and at the server. */
2052
+ async closeMusic() {
1894
2053
  const id = this.musicProducerId;
1895
2054
  this.musicProducerId = null;
1896
2055
  this.music?.close();
@@ -1910,9 +2069,11 @@ export class StreamClient {
1910
2069
  });
1911
2070
  }
1912
2071
  async consume(remote) {
2072
+ if (this.closedByUs)
2073
+ return;
1913
2074
  if (!this.device)
1914
2075
  throw new RpcError('not-connected', 'not connected');
1915
- if (this.consumers.has(remote.producerId))
2076
+ if (this.subscriptions.has(remote.producerId))
1916
2077
  return;
1917
2078
  // Your own camera is already local; pulling it back off the SFU wastes a
1918
2079
  // consumer and shows you a delayed copy of yourself.
@@ -1932,35 +2093,69 @@ export class StreamClient {
1932
2093
  }
1933
2094
  this.videoSlots.add(remote.producerId);
1934
2095
  }
2096
+ const operation = {};
2097
+ this.subscriptions.set(remote.producerId, operation);
1935
2098
  try {
1936
- await this.consumeNow(remote);
2099
+ await this.consumeNow(remote, operation);
1937
2100
  }
1938
2101
  catch (err) {
1939
- if (!this.consumers.has(remote.producerId))
1940
- this.videoSlots.delete(remote.producerId);
2102
+ // A closed producer or replaced session has already released its slot.
2103
+ // Its late failure must neither release a newer slot nor report a ghost error.
2104
+ if (this.subscriptions.get(remote.producerId) !== operation)
2105
+ return;
2106
+ this.subscriptions.delete(remote.producerId);
2107
+ this.consumers.get(remote.producerId)?.close();
2108
+ this.consumers.delete(remote.producerId);
2109
+ this.remoteVideoLayers.delete(remote.producerId);
2110
+ this.remoteSources.delete(remote.producerId);
2111
+ this.remoteInfo.delete(remote.producerId);
2112
+ this.releaseVideoSlot(remote.producerId);
1941
2113
  throw err;
1942
2114
  }
1943
2115
  }
2116
+ /**
2117
+ * Free a video slot, and let the oldest video waiting take it -- whatever
2118
+ * freed it: an ended video, or a subscription that failed. Not while
2119
+ * reconnecting: a failure caused by the socket dropping would otherwise
2120
+ * start every waiting video on a dead connection, each failing with
2121
+ * not-connected; the reconnect's join lists them all again anyway.
2122
+ */
2123
+ releaseVideoSlot(producerId) {
2124
+ if (this.videoSlots.delete(producerId) && !this.closedByUs && this.state !== 'reconnecting')
2125
+ this.consumeSkipped();
2126
+ }
1944
2127
  /** The oldest videos left waiting at the fan-out limit take the slots that opened. */
1945
- async consumeSkipped() {
2128
+ consumeSkipped() {
1946
2129
  for (const [producerId, remote] of this.skippedVideos) {
1947
2130
  const limit = this.mediaPermissions.maxVideoSubscriptions;
1948
2131
  if (limit !== null && this.videoSlots.size >= limit)
1949
2132
  return;
1950
2133
  this.skippedVideos.delete(producerId);
1951
- await this.consume(remote);
2134
+ // consume reserves synchronously, before its first await. Consecutive
2135
+ // releases cannot admit the same producer or exceed the limit.
2136
+ void this.consume(remote).catch((err) => this.emit('error', err));
1952
2137
  }
1953
2138
  }
1954
- async consumeNow(remote) {
2139
+ async consumeNow(remote, operation) {
1955
2140
  if (!this.device)
1956
2141
  throw new RpcError('not-connected', 'not connected');
2142
+ const device = this.device;
2143
+ const current = (made) => {
2144
+ if (!this.closedByUs && this.subscriptions.get(remote.producerId) === operation)
2145
+ return;
2146
+ made?.close();
2147
+ throw new RpcError('not-connected', 'subscription ended during setup');
2148
+ };
1957
2149
  const transport = await this.ensureRecvTransport();
2150
+ current();
1958
2151
  const params = (await this.rpc('consume', {
1959
2152
  transportId: transport.id,
1960
2153
  producerId: remote.producerId,
1961
- rtpCapabilities: this.device.rtpCapabilities,
2154
+ rtpCapabilities: device.rtpCapabilities,
1962
2155
  }));
2156
+ current();
1963
2157
  const consumer = await transport.consume(params);
2158
+ current(consumer);
1964
2159
  this.consumers.set(remote.producerId, consumer);
1965
2160
  this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
1966
2161
  // Layers asked for before a reconnect: the new consumer gets them before
@@ -1980,9 +2175,11 @@ export class StreamClient {
1980
2175
  if (layers) {
1981
2176
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
1982
2177
  }
2178
+ current(consumer);
1983
2179
  // The server created it paused. Resume only now that our end exists,
1984
2180
  // otherwise the first frames arrive before there is anything to decode them.
1985
2181
  await this.rpc('resumeConsumer', { consumerId: consumer.id });
2182
+ current(consumer);
1986
2183
  const kind = consumer.kind;
1987
2184
  const source = remote.source ?? defaultSource(kind);
1988
2185
  this.remoteSources.set(remote.producerId, source);
@@ -2041,18 +2238,53 @@ export class StreamClient {
2041
2238
  this.music?.setEnabled(false);
2042
2239
  }
2043
2240
  async setKindEnabled(kind, enabled) {
2044
- for (const producer of this.producers.values()) {
2045
- // The music has its own switch. Muting the microphone must not stop the song.
2046
- if (producer.kind !== kind || producer.appData.source !== defaultSource(kind))
2241
+ const generation = this.publishGeneration;
2242
+ // Unpublished or closed while waiting: nothing is left to switch, and the
2243
+ // call resolves, as it does with nothing published (V072-03).
2244
+ const ended = () => generation !== this.publishGeneration || this.closedByUs;
2245
+ // The music has its own switch. Muting the microphone must not stop the song.
2246
+ const targets = [...this.producers.values()].filter((p) => p.kind === kind && p.appData.source === defaultSource(kind));
2247
+ for (const producer of targets) {
2248
+ if (ended())
2249
+ return;
2250
+ if (this.producers.get(producer.id) !== producer || producer.closed)
2047
2251
  continue;
2252
+ const revision = this.moderationRevision[kind];
2048
2253
  if (!enabled)
2049
2254
  producer.pause();
2050
- await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
2051
- if (enabled)
2052
- producer.resume();
2255
+ try {
2256
+ await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
2257
+ }
2258
+ catch (err) {
2259
+ if (ended())
2260
+ return;
2261
+ throw err;
2262
+ }
2263
+ if (ended())
2264
+ return;
2265
+ if (!enabled || this.producers.get(producer.id) !== producer || producer.closed)
2266
+ continue;
2267
+ if (revision !== this.moderationRevision[kind] || this.forcedOff[kind]) {
2268
+ // A moderator decided while this waited: the older request does not
2269
+ // undo it. It stays off here, and the server is told so too, since
2270
+ // which of the two it applied last cannot be known from here.
2271
+ void this.rpc('pauseProducer', { producerId: producer.id, paused: true }).catch(() => undefined);
2272
+ this.emitLocalMedia();
2273
+ throw new RpcError('force-muted', `a moderator changed your ${kind} while this request waited; it stays off`);
2274
+ }
2275
+ producer.resume();
2053
2276
  }
2054
2277
  this.emitLocalMedia();
2055
2278
  }
2279
+ /** Record a moderation decision ('' = both kinds). A roster snapshot counts only where it changes what is known. */
2280
+ moderate(kind, paused, snapshot = false) {
2281
+ for (const k of kind ? [kind] : ['audio', 'video']) {
2282
+ if (snapshot && this.forcedOff[k] === paused)
2283
+ continue;
2284
+ this.forcedOff[k] = paused;
2285
+ this.moderationRevision[k] += 1;
2286
+ }
2287
+ }
2056
2288
  /** Stop publishing entirely and release the local devices. */
2057
2289
  async unpublish() {
2058
2290
  void this.stopRecording().catch(error => this.emit('error', error));