@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/cjs/index.js CHANGED
@@ -89,6 +89,16 @@ const VIDEO_START_BITRATE_KBPS = 2000;
89
89
  /** Why a connect() gave up: close() ran while it was under way, and close wins. */
90
90
  const closedWhileConnecting = () => new errors_js_1.RpcError('not-connected', 'the session was closed while connecting');
91
91
  /** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
92
+ /**
93
+ * The constraints that capture again what two publishes captured: each kind
94
+ * as the later publish asked for it, or as the earlier one did if the later
95
+ * one did not ask for it.
96
+ */
97
+ function mergeConstraints(earlier, later) {
98
+ if (!earlier)
99
+ return later;
100
+ return { ...earlier, ...later, audio: later.audio || earlier.audio, video: later.video || earlier.video };
101
+ }
92
102
  const unpublishedWhile = (what) => new errors_js_1.RpcError('unpublished', `the ${what} was unpublished while it was being published`);
93
103
  /**
94
104
  * mediasoup-client puts the microphone's Opus maxaveragebitrate in the answer
@@ -136,11 +146,30 @@ class StreamClient {
136
146
  // broadcaster that reconnects rejoins silently, which is worse than being
137
147
  // disconnected: the room looks healthy and shows nothing.
138
148
  wasPublishing = false;
149
+ /** The deadline of the socket being opened (openSocket). */
150
+ openDeadline;
151
+ /**
152
+ * This client's own moderation per kind: whether a moderator holds it off,
153
+ * and a revision every decision bumps. A switch-on that was waiting for the
154
+ * server when a decision arrived does not act on its reply (V072-06).
155
+ */
156
+ forcedOff = { audio: false, video: false };
157
+ moderationRevision = { audio: 0, video: 0 };
139
158
  publishConstraints = null;
140
159
  publishOptions = {};
141
160
  /** In-app music being published, kept across reconnects so it can be published again. */
142
161
  music = null;
143
162
  musicProducerId = null;
163
+ /**
164
+ * Bumped by every publishMusic() and stopMusic(). A music producer still
165
+ * being made when it changes belongs to a publish that is over: it is
166
+ * closed, not kept. publishGeneration alone missed stopMusic(), and the
167
+ * music went out after stopMusic() had returned.
168
+ */
169
+ musicOperation = 0;
170
+ // Music publishes run one at a time: the server takes one producer per
171
+ // source, so the next is produced only once the last one is settled.
172
+ musicQueue = Promise.resolve();
144
173
  screenStream = null;
145
174
  screenMode = 'detail';
146
175
  screenAudioEnabled = true;
@@ -157,6 +186,8 @@ class StreamClient {
157
186
  frameRateWarmupSamples = 2;
158
187
  /** What each remote producer carries, so a pause can say "music" rather than just "audio". */
159
188
  remoteSources = new Map();
189
+ /** Identity of each pending/active subscription; cleared on close/reconnect. */
190
+ subscriptions = new Map();
160
191
  /**
161
192
  * The layers asked for each remote video, by producerId. A reconnect
162
193
  * consumes everything again, and the new consumer would otherwise start at
@@ -195,6 +226,17 @@ class StreamClient {
195
226
  /** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
196
227
  tokenExpiry;
197
228
  tokenTimer = null;
229
+ /**
230
+ * Bumped whenever the server hands this client a token (its own refresh, a
231
+ * promotion's). An updateToken() that started before then keeps the token
232
+ * it was given only if nothing newer arrived while it waited: its reply,
233
+ * or its fallback once the socket closed, used to replace a promotion's
234
+ * token, and the reconnect joined with the old one.
235
+ */
236
+ serverTokenRevision = 0;
237
+ /** updateToken() calls in call order, and the newest one whose token is held: a later call wins over an earlier one answered after it. */
238
+ tokenRequests = 0;
239
+ tokenRequestHeld = 0;
198
240
  /** Whose each received track is and whether it is switched off, by producerId. */
199
241
  remoteInfo = new Map();
200
242
  /** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
@@ -256,6 +298,17 @@ class StreamClient {
256
298
  this.setState('closed');
257
299
  this.emit('closed', reason);
258
300
  }
301
+ /**
302
+ * Over for good without the app asking: finish, then release everything
303
+ * local as close() does -- camera, microphone, screen, music, recording.
304
+ * kicked and roomClosed always did; a replaced socket, a close without
305
+ * roomClosed and a reconnect that gave up left them capturing (and the
306
+ * recording uploading) after `closed`, until the app called close().
307
+ */
308
+ end(reason) {
309
+ this.finish(reason);
310
+ this.close();
311
+ }
259
312
  stopTimers() {
260
313
  if (this.tokenTimer)
261
314
  clearTimeout(this.tokenTimer);
@@ -282,8 +335,13 @@ class StreamClient {
282
335
  let result;
283
336
  if (this.state === 'idle' || this.state === 'closed')
284
337
  this.setState('connecting');
338
+ // The socket this call opens. A later connect() replaces it, and this
339
+ // call's failure must then leave the newer socket, and the state, alone.
340
+ let socket = null;
285
341
  try {
286
- await this.openSocket();
342
+ const opening = this.openSocket();
343
+ socket = this.ws;
344
+ await opening;
287
345
  result = (await this.rpc('join', { token: this.token }));
288
346
  this.joinResult = result;
289
347
  this.device = this.createDevice();
@@ -297,7 +355,11 @@ class StreamClient {
297
355
  // A session that never got going: the app has the error. Left open,
298
356
  // the socket would be closed by the server's join deadline instead, and
299
357
  // that close would start reconnecting a session that never began.
300
- if (!this.joinedOnce) {
358
+ const superseded = this.ws !== null && this.ws !== socket;
359
+ // A reconnect attempt that failed (its join timed out, say) lets its
360
+ // socket go now too, so nothing it says later reaches the session while
361
+ // the next attempt waits its backoff (V072-05).
362
+ if ((!this.joinedOnce || this.reconnecting) && !superseded) {
301
363
  this.detachSocket();
302
364
  if (this.state === 'connecting')
303
365
  this.setState('idle');
@@ -321,6 +383,8 @@ class StreamClient {
321
383
  throw closedWhileConnecting();
322
384
  }
323
385
  const own = result.participants.find((p) => p.identity === result.identity);
386
+ this.moderate('audio', Boolean(own?.muted), true);
387
+ this.moderate('video', Boolean(own?.videoBlocked), true);
324
388
  if (own?.muted)
325
389
  this.applyLocalModeration('audio');
326
390
  if (own?.videoBlocked)
@@ -376,11 +440,23 @@ class StreamClient {
376
440
  return new Promise((resolve, reject) => {
377
441
  const ws = new WebSocket((0, token_js_1.socketUrl)(this.wsUrl, this.token));
378
442
  this.ws = ws;
443
+ // Bounded like every request (V072-05): a socket that does not open in
444
+ // time fails the attempt with `timeout` and is closed, instead of
445
+ // holding connect() -- or a reconnect attempt -- indefinitely.
446
+ this.openDeadline = setTimeout(() => {
447
+ reject(new errors_js_1.RpcError('timeout', `the connection did not open in ${RPC_TIMEOUT_MS / 1000} s`));
448
+ if (this.ws === ws)
449
+ this.detachSocket('open timed out', 'the connection did not open in time');
450
+ }, RPC_TIMEOUT_MS);
379
451
  const onOpen = () => {
452
+ clearTimeout(this.openDeadline);
380
453
  this.emit('connected');
381
454
  resolve();
382
455
  };
383
- const onError = () => reject(new errors_js_1.RpcError('connect-failed', 'websocket failed to open'));
456
+ const onError = () => {
457
+ clearTimeout(this.openDeadline);
458
+ reject(new errors_js_1.RpcError('connect-failed', 'websocket failed to open'));
459
+ };
384
460
  ws.addEventListener('open', onOpen, { once: true });
385
461
  ws.addEventListener('error', onError, { once: true });
386
462
  ws.addEventListener('message', (ev) => {
@@ -401,17 +477,16 @@ class StreamClient {
401
477
  // Another session for this identity is live. Reconnecting would
402
478
  // displace it, and it would displace us right back, forever.
403
479
  this.emit('error', new errors_js_1.RpcError('replaced', 'this identity connected from somewhere else'));
404
- // The producers outlive this close until the app calls close(); the
405
- // videoLimited sampling must not, or it reports after `closed`.
480
+ // The videoLimited sampling stops first, or it reports after `closed`.
406
481
  this.limitWatch.reset();
407
- this.finish('replaced');
482
+ this.end('replaced');
408
483
  return;
409
484
  }
410
485
  if (ev.code === CLOSE_ENDED) {
411
486
  // Normally roomClosed has already ended the session; this covers a
412
487
  // close that arrived without it.
413
488
  this.limitWatch.reset();
414
- this.finish('room-closed');
489
+ this.end('room-closed');
415
490
  return;
416
491
  }
417
492
  // Before a first successful join the app is still waiting on
@@ -424,10 +499,13 @@ class StreamClient {
424
499
  });
425
500
  });
426
501
  }
427
- detachSocket(reason = 'superseded') {
502
+ detachSocket(reason = 'superseded', why = 'the connection was replaced') {
503
+ clearTimeout(this.openDeadline);
428
504
  const ws = this.ws;
429
505
  this.ws = null;
430
- if (ws && ws.readyState <= WebSocket.OPEN) {
506
+ if (!ws)
507
+ return;
508
+ if (ws.readyState <= WebSocket.OPEN) {
431
509
  try {
432
510
  ws.close(1000, reason);
433
511
  }
@@ -435,6 +513,10 @@ class StreamClient {
435
513
  /* already closing */
436
514
  }
437
515
  }
516
+ // Its replies are no longer read, so what waits on it would only wait out
517
+ // its 15 s timeout: it fails now, as on a close. Never replayed -- the
518
+ // server may already have acted on it.
519
+ this.failPending(ws, new errors_js_1.RpcError('not-connected', why));
438
520
  }
439
521
  /**
440
522
  * A reconnect that ends the session lets go of its socket. A refused join
@@ -443,7 +525,7 @@ class StreamClient {
443
525
  * still sending.
444
526
  */
445
527
  releaseSocket() {
446
- this.detachSocket('gave up');
528
+ this.detachSocket('gave up', 'the session has ended');
447
529
  this.failAllPending(new errors_js_1.RpcError('not-connected', 'the session has ended'));
448
530
  }
449
531
  /**
@@ -464,7 +546,7 @@ class StreamClient {
464
546
  if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
465
547
  this.emit('error', new errors_js_1.RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
466
548
  this.releaseSocket();
467
- this.finish('gave-up');
549
+ this.end('gave-up');
468
550
  return;
469
551
  }
470
552
  const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
@@ -492,7 +574,7 @@ class StreamClient {
492
574
  // attempt forever and drowns out whatever actually went wrong.
493
575
  if (err instanceof errors_js_1.RpcError && (0, errors_js_1.isTerminalError)(err.code)) {
494
576
  this.releaseSocket();
495
- this.finish('gave-up');
577
+ this.end('gave-up');
496
578
  return;
497
579
  }
498
580
  }
@@ -537,6 +619,7 @@ class StreamClient {
537
619
  this.emit('closed', 'left');
538
620
  }
539
621
  teardownMedia() {
622
+ this.subscriptions.clear();
540
623
  for (const c of this.consumers.values())
541
624
  c.close();
542
625
  this.consumers.clear();
@@ -607,19 +690,29 @@ class StreamClient {
607
690
  * `tokenExpiring`. On a live connection the server checks it like a join
608
691
  * token (`bad-token`, or `token-mismatch` for another room or identity; the
609
692
  * session then keeps its token); otherwise it is kept for the next join,
610
- * which checks it there. Resolves to when it expires (unix ms), when known.
693
+ * which checks it there. Resolves to when the token now held expires (unix
694
+ * ms), when known. A newer token that arrives while this waits is kept
695
+ * instead of this one: the server's (its own refresh, or a promotion's,
696
+ * which carries the new role) or a later updateToken()'s.
611
697
  */
612
698
  async updateToken(token) {
613
699
  if (typeof token !== 'string' || token.trim() === '')
614
700
  throw new TypeError('updateToken needs a token');
615
701
  if (this.state === 'closed')
616
702
  throw new errors_js_1.RpcError('not-connected', 'this session is closed; create a new client');
703
+ const request = ++this.tokenRequests;
704
+ const revision = this.serverTokenRevision;
705
+ const keep = (expiresAt) => {
706
+ if (revision === this.serverTokenRevision && request > this.tokenRequestHeld) {
707
+ this.tokenRequestHeld = request;
708
+ this.setToken(token, expiresAt);
709
+ }
710
+ return { expiresAt: this.tokenExpiry };
711
+ };
617
712
  if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
618
713
  try {
619
714
  const res = (await this.rpc('refreshToken', { token }));
620
- const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
621
- this.setToken(token, at);
622
- return { expiresAt: this.tokenExpiry };
715
+ return keep(typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null);
623
716
  }
624
717
  catch (err) {
625
718
  // An older server has no refreshToken, and a socket that dropped
@@ -629,8 +722,7 @@ class StreamClient {
629
722
  throw err;
630
723
  }
631
724
  }
632
- this.setToken(token, null);
633
- return { expiresAt: this.tokenExpiry };
725
+ return keep(null);
634
726
  }
635
727
  /** The token reconnects use from now on, with its expiry (read from the token when not given). */
636
728
  setToken(token, expiresAt) {
@@ -691,10 +783,20 @@ class StreamClient {
691
783
  this.pending.delete(id);
692
784
  reject(new errors_js_1.RpcError('timeout', `${method} timed out`));
693
785
  }, RPC_TIMEOUT_MS);
694
- this.pending.set(id, { resolve, reject, timer });
786
+ this.pending.set(id, { resolve, reject, timer, ws: this.ws });
695
787
  this.ws.send(JSON.stringify({ id, method, params }));
696
788
  });
697
789
  }
790
+ /** Fail what was sent on `ws` and is still waiting. */
791
+ failPending(ws, err) {
792
+ for (const [id, p] of this.pending) {
793
+ if (p.ws !== ws)
794
+ continue;
795
+ clearTimeout(p.timer);
796
+ this.pending.delete(id);
797
+ p.reject(err);
798
+ }
799
+ }
698
800
  failAllPending(err) {
699
801
  for (const p of this.pending.values()) {
700
802
  clearTimeout(p.timer);
@@ -739,23 +841,21 @@ class StreamClient {
739
841
  break;
740
842
  case 'producerClosed': {
741
843
  const { producerId, identity } = data;
844
+ this.subscriptions.delete(producerId);
742
845
  this.preferredLayers.delete(producerId);
743
846
  this.receiveQualities.delete(producerId);
744
847
  this.remoteVideoLayers.delete(producerId);
745
848
  this.skippedVideos.delete(producerId);
746
849
  const consumer = this.consumers.get(producerId);
747
- if (!consumer)
748
- break; // never consumed it, or already cleaned up
749
- this.videoSlots.delete(producerId);
750
- consumer.close();
850
+ consumer?.close();
751
851
  this.consumers.delete(producerId);
752
852
  this.remoteSources.delete(producerId);
753
853
  this.remoteInfo.delete(producerId);
754
- this.emit('trackEnded', { producerId, identity });
854
+ if (consumer)
855
+ this.emit('trackEnded', { producerId, identity });
755
856
  // A video slot opened under the fan-out limit: the oldest video left
756
857
  // waiting takes it.
757
- if (consumer.kind === 'video')
758
- void this.consumeSkipped().catch((err) => this.emit('error', err));
858
+ this.releaseVideoSlot(producerId);
759
859
  break;
760
860
  }
761
861
  case 'producerPaused': {
@@ -782,6 +882,8 @@ class StreamClient {
782
882
  case 'participantMuted': {
783
883
  const wire = data;
784
884
  const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
885
+ if (e.identity && e.identity === this.joinResult?.identity)
886
+ this.moderate(e.kind, e.paused, true);
785
887
  this.updateRoster((list) => list.map((p) => p.identity !== e.identity ? p : { ...p,
786
888
  ...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
787
889
  ...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
@@ -828,6 +930,7 @@ class StreamClient {
828
930
  // in-app music: it travels as audio). Reflect it locally; a release
829
931
  // changes nothing here -- switching back on is the user's choice.
830
932
  const e = data;
933
+ this.moderate(e.kind, Boolean(e.paused));
831
934
  if (e.paused) {
832
935
  this.applyLocalModeration(e.kind);
833
936
  this.emitLocalMedia();
@@ -853,15 +956,19 @@ class StreamClient {
853
956
  // fresh one periodically so a reconnect at any point still works --
854
957
  // without this, any disconnect after the token's lifetime is terminal.
855
958
  const e = data;
856
- if (e.token)
959
+ if (e.token) {
960
+ this.serverTokenRevision += 1;
857
961
  this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
962
+ }
858
963
  break;
859
964
  }
860
965
  case 'mustReconnect': {
861
966
  const e = data;
862
967
  // A promotion hands us a fresh token carrying the new role.
863
- if (e.token)
968
+ if (e.token) {
969
+ this.serverTokenRevision += 1;
864
970
  this.setToken(e.token, null);
971
+ }
865
972
  this.emit('mustReconnect', e);
866
973
  if (e.retry) {
867
974
  this.teardownMedia();
@@ -1027,10 +1134,28 @@ class StreamClient {
1027
1134
  // leave nothing behind that a reconnect would publish again.
1028
1135
  if (generation !== this.publishGeneration)
1029
1136
  throw unpublishedWhile('stream');
1030
- this.localStream = stream;
1137
+ // A second publish adds to the first (the microphone, then the camera):
1138
+ // one local stream carries every live track, so unpublish() and close()
1139
+ // stop them all and a reconnect publishes each again. Replacing it
1140
+ // orphaned the first stream's tracks, still capturing (V072-01).
1141
+ const previous = this.localStream;
1142
+ const merged = previous !== null && previous !== stream && previous.getTracks().some((t) => t.readyState === 'live');
1143
+ if (merged) {
1144
+ const ids = new Set(stream.getTracks().map((t) => t.id));
1145
+ // The new stream's tracks first: of one kind, the newer track is the one
1146
+ // published (as before), and the older one is still stopped with it.
1147
+ this.localStream = new MediaStream([
1148
+ ...stream.getTracks(),
1149
+ ...previous.getTracks().filter((t) => t.readyState === 'live' && !ids.has(t.id)),
1150
+ ]);
1151
+ this.emit('localStreamChanged', this.localStream);
1152
+ }
1153
+ else {
1154
+ this.localStream = stream;
1155
+ }
1031
1156
  this.wasPublishing = true;
1032
1157
  if (constraints)
1033
- this.publishConstraints = constraints;
1158
+ this.publishConstraints = merged ? mergeConstraints(this.publishConstraints, constraints) : constraints;
1034
1159
  if (options) {
1035
1160
  this.publishOptions = { ...options };
1036
1161
  this.frameRateFallback = false;
@@ -1625,8 +1750,11 @@ class StreamClient {
1625
1750
  await this.stopScreen('stopped');
1626
1751
  this.emit('error', err);
1627
1752
  });
1628
- if (this.music)
1629
- await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err));
1753
+ if (this.music) {
1754
+ const music = this.music;
1755
+ const operation = this.musicOperation;
1756
+ await this.musicTask(() => this.produceMusic(music, generation, operation)).catch((err) => this.emit('error', err));
1757
+ }
1630
1758
  if (generation !== this.publishGeneration)
1631
1759
  return;
1632
1760
  let stream = this.localStream;
@@ -1860,28 +1988,43 @@ class StreamClient {
1860
1988
  if (!this.mediaPermissions.audio)
1861
1989
  throw new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project');
1862
1990
  const generation = this.publishGeneration;
1863
- if (this.music)
1864
- await this.stopMusic();
1865
- const music = new music_js_1.MusicSource(input, options);
1866
- try {
1867
- await this.produceMusic(music, generation);
1868
- // Checked again here: an unpublish() after the producer was kept has
1869
- // already closed it, and the music must not be remembered for a
1870
- // reconnect to publish again.
1871
- if (generation !== this.publishGeneration)
1991
+ // Taken now, in call order: a music publish still under way is over, and
1992
+ // a stopMusic() from here on ends this one.
1993
+ const operation = ++this.musicOperation;
1994
+ await this.musicTask(async () => {
1995
+ if (generation !== this.publishGeneration || operation !== this.musicOperation)
1872
1996
  throw unpublishedWhile('music');
1873
- }
1874
- catch (err) {
1875
- music.close();
1876
- throw err;
1877
- }
1878
- this.music = music;
1879
- this.wasPublishing = true;
1880
- this.emitLocalMedia();
1997
+ if (this.music)
1998
+ await this.closeMusic();
1999
+ const music = new music_js_1.MusicSource(input, options);
2000
+ try {
2001
+ await this.produceMusic(music, generation, operation);
2002
+ // Checked again here: an unpublish() or stopMusic() after the
2003
+ // producer was kept has already closed it, and the music must not be
2004
+ // remembered for a reconnect to publish again.
2005
+ if (generation !== this.publishGeneration || operation !== this.musicOperation)
2006
+ throw unpublishedWhile('music');
2007
+ }
2008
+ catch (err) {
2009
+ music.close();
2010
+ throw err;
2011
+ }
2012
+ this.music = music;
2013
+ this.wasPublishing = true;
2014
+ this.emitLocalMedia();
2015
+ });
1881
2016
  }
1882
- async produceMusic(music, generation) {
2017
+ musicTask(task) {
2018
+ const run = this.musicQueue.then(task, task);
2019
+ this.musicQueue = run.catch(() => undefined);
2020
+ return run;
2021
+ }
2022
+ async produceMusic(music, generation, operation) {
2023
+ // unpublish(), stopMusic() or a newer publishMusic() can run at any wait
2024
+ // below, and wins.
2025
+ const current = () => generation === this.publishGeneration && operation === this.musicOperation;
1883
2026
  const transport = await this.ensureSendTransport();
1884
- if (generation !== this.publishGeneration)
2027
+ if (!current())
1885
2028
  throw unpublishedWhile('music');
1886
2029
  const producer = await transport.produce({
1887
2030
  track: music.track,
@@ -1891,7 +2034,14 @@ class StreamClient {
1891
2034
  codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
1892
2035
  appData: { source: 'music' },
1893
2036
  });
1894
- await this.keepProducer(producer, generation, 'music');
2037
+ if (!current()) {
2038
+ producer.close();
2039
+ await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
2040
+ throw unpublishedWhile('music');
2041
+ }
2042
+ // Kept and named the music in one step, with no wait between: stopMusic()
2043
+ // finds the music producer by that name.
2044
+ this.producers.set(producer.id, producer);
1895
2045
  this.musicProducerId = producer.id;
1896
2046
  // Music switched off before a reconnect comes back paused here.
1897
2047
  await this.pauseOnServer(producer);
@@ -1920,8 +2070,17 @@ class StreamClient {
1920
2070
  await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
1921
2071
  this.emitLocalMedia();
1922
2072
  }
1923
- /** Stop publishing music. The element or stream it came from is left as it was. */
2073
+ /**
2074
+ * Stop publishing music. The element or stream it came from is left as it
2075
+ * was. A publishMusic() still under way is stopped too: it rejects with
2076
+ * `unpublished` and publishes nothing.
2077
+ */
1924
2078
  async stopMusic() {
2079
+ this.musicOperation += 1;
2080
+ await this.closeMusic();
2081
+ }
2082
+ /** Close the music being published, here and at the server. */
2083
+ async closeMusic() {
1925
2084
  const id = this.musicProducerId;
1926
2085
  this.musicProducerId = null;
1927
2086
  this.music?.close();
@@ -1941,9 +2100,11 @@ class StreamClient {
1941
2100
  });
1942
2101
  }
1943
2102
  async consume(remote) {
2103
+ if (this.closedByUs)
2104
+ return;
1944
2105
  if (!this.device)
1945
2106
  throw new errors_js_1.RpcError('not-connected', 'not connected');
1946
- if (this.consumers.has(remote.producerId))
2107
+ if (this.subscriptions.has(remote.producerId))
1947
2108
  return;
1948
2109
  // Your own camera is already local; pulling it back off the SFU wastes a
1949
2110
  // consumer and shows you a delayed copy of yourself.
@@ -1963,35 +2124,69 @@ class StreamClient {
1963
2124
  }
1964
2125
  this.videoSlots.add(remote.producerId);
1965
2126
  }
2127
+ const operation = {};
2128
+ this.subscriptions.set(remote.producerId, operation);
1966
2129
  try {
1967
- await this.consumeNow(remote);
2130
+ await this.consumeNow(remote, operation);
1968
2131
  }
1969
2132
  catch (err) {
1970
- if (!this.consumers.has(remote.producerId))
1971
- this.videoSlots.delete(remote.producerId);
2133
+ // A closed producer or replaced session has already released its slot.
2134
+ // Its late failure must neither release a newer slot nor report a ghost error.
2135
+ if (this.subscriptions.get(remote.producerId) !== operation)
2136
+ return;
2137
+ this.subscriptions.delete(remote.producerId);
2138
+ this.consumers.get(remote.producerId)?.close();
2139
+ this.consumers.delete(remote.producerId);
2140
+ this.remoteVideoLayers.delete(remote.producerId);
2141
+ this.remoteSources.delete(remote.producerId);
2142
+ this.remoteInfo.delete(remote.producerId);
2143
+ this.releaseVideoSlot(remote.producerId);
1972
2144
  throw err;
1973
2145
  }
1974
2146
  }
2147
+ /**
2148
+ * Free a video slot, and let the oldest video waiting take it -- whatever
2149
+ * freed it: an ended video, or a subscription that failed. Not while
2150
+ * reconnecting: a failure caused by the socket dropping would otherwise
2151
+ * start every waiting video on a dead connection, each failing with
2152
+ * not-connected; the reconnect's join lists them all again anyway.
2153
+ */
2154
+ releaseVideoSlot(producerId) {
2155
+ if (this.videoSlots.delete(producerId) && !this.closedByUs && this.state !== 'reconnecting')
2156
+ this.consumeSkipped();
2157
+ }
1975
2158
  /** The oldest videos left waiting at the fan-out limit take the slots that opened. */
1976
- async consumeSkipped() {
2159
+ consumeSkipped() {
1977
2160
  for (const [producerId, remote] of this.skippedVideos) {
1978
2161
  const limit = this.mediaPermissions.maxVideoSubscriptions;
1979
2162
  if (limit !== null && this.videoSlots.size >= limit)
1980
2163
  return;
1981
2164
  this.skippedVideos.delete(producerId);
1982
- await this.consume(remote);
2165
+ // consume reserves synchronously, before its first await. Consecutive
2166
+ // releases cannot admit the same producer or exceed the limit.
2167
+ void this.consume(remote).catch((err) => this.emit('error', err));
1983
2168
  }
1984
2169
  }
1985
- async consumeNow(remote) {
2170
+ async consumeNow(remote, operation) {
1986
2171
  if (!this.device)
1987
2172
  throw new errors_js_1.RpcError('not-connected', 'not connected');
2173
+ const device = this.device;
2174
+ const current = (made) => {
2175
+ if (!this.closedByUs && this.subscriptions.get(remote.producerId) === operation)
2176
+ return;
2177
+ made?.close();
2178
+ throw new errors_js_1.RpcError('not-connected', 'subscription ended during setup');
2179
+ };
1988
2180
  const transport = await this.ensureRecvTransport();
2181
+ current();
1989
2182
  const params = (await this.rpc('consume', {
1990
2183
  transportId: transport.id,
1991
2184
  producerId: remote.producerId,
1992
- rtpCapabilities: this.device.rtpCapabilities,
2185
+ rtpCapabilities: device.rtpCapabilities,
1993
2186
  }));
2187
+ current();
1994
2188
  const consumer = await transport.consume(params);
2189
+ current(consumer);
1995
2190
  this.consumers.set(remote.producerId, consumer);
1996
2191
  this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
1997
2192
  // Layers asked for before a reconnect: the new consumer gets them before
@@ -2011,9 +2206,11 @@ class StreamClient {
2011
2206
  if (layers) {
2012
2207
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
2013
2208
  }
2209
+ current(consumer);
2014
2210
  // The server created it paused. Resume only now that our end exists,
2015
2211
  // otherwise the first frames arrive before there is anything to decode them.
2016
2212
  await this.rpc('resumeConsumer', { consumerId: consumer.id });
2213
+ current(consumer);
2017
2214
  const kind = consumer.kind;
2018
2215
  const source = remote.source ?? defaultSource(kind);
2019
2216
  this.remoteSources.set(remote.producerId, source);
@@ -2072,18 +2269,53 @@ class StreamClient {
2072
2269
  this.music?.setEnabled(false);
2073
2270
  }
2074
2271
  async setKindEnabled(kind, enabled) {
2075
- for (const producer of this.producers.values()) {
2076
- // The music has its own switch. Muting the microphone must not stop the song.
2077
- if (producer.kind !== kind || producer.appData.source !== defaultSource(kind))
2272
+ const generation = this.publishGeneration;
2273
+ // Unpublished or closed while waiting: nothing is left to switch, and the
2274
+ // call resolves, as it does with nothing published (V072-03).
2275
+ const ended = () => generation !== this.publishGeneration || this.closedByUs;
2276
+ // The music has its own switch. Muting the microphone must not stop the song.
2277
+ const targets = [...this.producers.values()].filter((p) => p.kind === kind && p.appData.source === defaultSource(kind));
2278
+ for (const producer of targets) {
2279
+ if (ended())
2280
+ return;
2281
+ if (this.producers.get(producer.id) !== producer || producer.closed)
2078
2282
  continue;
2283
+ const revision = this.moderationRevision[kind];
2079
2284
  if (!enabled)
2080
2285
  producer.pause();
2081
- await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
2082
- if (enabled)
2083
- producer.resume();
2286
+ try {
2287
+ await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
2288
+ }
2289
+ catch (err) {
2290
+ if (ended())
2291
+ return;
2292
+ throw err;
2293
+ }
2294
+ if (ended())
2295
+ return;
2296
+ if (!enabled || this.producers.get(producer.id) !== producer || producer.closed)
2297
+ continue;
2298
+ if (revision !== this.moderationRevision[kind] || this.forcedOff[kind]) {
2299
+ // A moderator decided while this waited: the older request does not
2300
+ // undo it. It stays off here, and the server is told so too, since
2301
+ // which of the two it applied last cannot be known from here.
2302
+ void this.rpc('pauseProducer', { producerId: producer.id, paused: true }).catch(() => undefined);
2303
+ this.emitLocalMedia();
2304
+ throw new errors_js_1.RpcError('force-muted', `a moderator changed your ${kind} while this request waited; it stays off`);
2305
+ }
2306
+ producer.resume();
2084
2307
  }
2085
2308
  this.emitLocalMedia();
2086
2309
  }
2310
+ /** Record a moderation decision ('' = both kinds). A roster snapshot counts only where it changes what is known. */
2311
+ moderate(kind, paused, snapshot = false) {
2312
+ for (const k of kind ? [kind] : ['audio', 'video']) {
2313
+ if (snapshot && this.forcedOff[k] === paused)
2314
+ continue;
2315
+ this.forcedOff[k] = paused;
2316
+ this.moderationRevision[k] += 1;
2317
+ }
2318
+ }
2087
2319
  /** Stop publishing entirely and release the local devices. */
2088
2320
  async unpublish() {
2089
2321
  void this.stopRecording().catch(error => this.emit('error', error));