@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/src/index.ts CHANGED
@@ -349,7 +349,11 @@ export interface ClientEvents extends ChatEvents {
349
349
  localStreamChanged: (stream: MediaStream | null) => void;
350
350
  /** Media stopped arriving but the connection is intact -- the publisher is reconnecting. */
351
351
  streamInterrupted: (e: { reason: string }) => void;
352
- /** The session ended and will not recover. Nothing further will be emitted. */
352
+ /**
353
+ * The session ended and will not recover. Nothing further will be emitted.
354
+ * Whatever the reason, the camera, microphone, screen, music and recording
355
+ * are released with it, as close() does.
356
+ */
353
357
  closed: (reason: ClosedReason) => void;
354
358
  /**
355
359
  * The account behind this session ran out of minutes or reached a usage
@@ -411,6 +415,8 @@ interface Pending {
411
415
  resolve: (v: unknown) => void;
412
416
  reject: (e: Error) => void;
413
417
  timer: ReturnType<typeof setTimeout>;
418
+ /** The socket it was sent on: only that socket can answer it. */
419
+ ws: WebSocket;
414
420
  }
415
421
 
416
422
  const RPC_TIMEOUT_MS = 15_000;
@@ -440,6 +446,16 @@ const VIDEO_START_BITRATE_KBPS = 2000;
440
446
  const closedWhileConnecting = () => new RpcError('not-connected', 'the session was closed while connecting');
441
447
 
442
448
  /** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
449
+ /**
450
+ * The constraints that capture again what two publishes captured: each kind
451
+ * as the later publish asked for it, or as the earlier one did if the later
452
+ * one did not ask for it.
453
+ */
454
+ function mergeConstraints(earlier: MediaStreamConstraints | null, later: MediaStreamConstraints): MediaStreamConstraints {
455
+ if (!earlier) return later;
456
+ return { ...earlier, ...later, audio: later.audio || earlier.audio, video: later.video || earlier.video };
457
+ }
458
+
443
459
  const unpublishedWhile = (what: string) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
444
460
 
445
461
  /**
@@ -486,11 +502,30 @@ export class StreamClient {
486
502
  // broadcaster that reconnects rejoins silently, which is worse than being
487
503
  // disconnected: the room looks healthy and shows nothing.
488
504
  private wasPublishing = false;
505
+ /** The deadline of the socket being opened (openSocket). */
506
+ private openDeadline: ReturnType<typeof setTimeout> | undefined;
507
+ /**
508
+ * This client's own moderation per kind: whether a moderator holds it off,
509
+ * and a revision every decision bumps. A switch-on that was waiting for the
510
+ * server when a decision arrived does not act on its reply (V072-06).
511
+ */
512
+ private forcedOff = { audio: false, video: false };
513
+ private moderationRevision = { audio: 0, video: 0 };
489
514
  private publishConstraints: MediaStreamConstraints | null = null;
490
515
  private publishOptions: PublishOptions = {};
491
516
  /** In-app music being published, kept across reconnects so it can be published again. */
492
517
  private music: MusicSource | null = null;
493
518
  private musicProducerId: string | null = null;
519
+ /**
520
+ * Bumped by every publishMusic() and stopMusic(). A music producer still
521
+ * being made when it changes belongs to a publish that is over: it is
522
+ * closed, not kept. publishGeneration alone missed stopMusic(), and the
523
+ * music went out after stopMusic() had returned.
524
+ */
525
+ private musicOperation = 0;
526
+ // Music publishes run one at a time: the server takes one producer per
527
+ // source, so the next is produced only once the last one is settled.
528
+ private musicQueue: Promise<unknown> = Promise.resolve();
494
529
  private screenStream: MediaStream | null = null;
495
530
  private screenMode: ScreenMode = 'detail';
496
531
  private screenAudioEnabled = true;
@@ -507,6 +542,8 @@ export class StreamClient {
507
542
  private frameRateWarmupSamples = 2;
508
543
  /** What each remote producer carries, so a pause can say "music" rather than just "audio". */
509
544
  private readonly remoteSources = new Map<string, TrackSource>();
545
+ /** Identity of each pending/active subscription; cleared on close/reconnect. */
546
+ private readonly subscriptions = new Map<string, object>();
510
547
  /**
511
548
  * The layers asked for each remote video, by producerId. A reconnect
512
549
  * consumes everything again, and the new consumer would otherwise start at
@@ -550,6 +587,17 @@ export class StreamClient {
550
587
  /** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
551
588
  private tokenExpiry: number | null;
552
589
  private tokenTimer: ReturnType<typeof setTimeout> | null = null;
590
+ /**
591
+ * Bumped whenever the server hands this client a token (its own refresh, a
592
+ * promotion's). An updateToken() that started before then keeps the token
593
+ * it was given only if nothing newer arrived while it waited: its reply,
594
+ * or its fallback once the socket closed, used to replace a promotion's
595
+ * token, and the reconnect joined with the old one.
596
+ */
597
+ private serverTokenRevision = 0;
598
+ /** updateToken() calls in call order, and the newest one whose token is held: a later call wins over an earlier one answered after it. */
599
+ private tokenRequests = 0;
600
+ private tokenRequestHeld = 0;
553
601
  /** Whose each received track is and whether it is switched off, by producerId. */
554
602
  private readonly remoteInfo = new Map<string, { identity: string; kind: 'audio' | 'video'; paused: boolean }>();
555
603
  /** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
@@ -613,6 +661,18 @@ export class StreamClient {
613
661
  this.emit('closed', reason);
614
662
  }
615
663
 
664
+ /**
665
+ * Over for good without the app asking: finish, then release everything
666
+ * local as close() does -- camera, microphone, screen, music, recording.
667
+ * kicked and roomClosed always did; a replaced socket, a close without
668
+ * roomClosed and a reconnect that gave up left them capturing (and the
669
+ * recording uploading) after `closed`, until the app called close().
670
+ */
671
+ private end(reason: ClosedReason): void {
672
+ this.finish(reason);
673
+ this.close();
674
+ }
675
+
616
676
  private stopTimers(): void {
617
677
  if (this.tokenTimer) clearTimeout(this.tokenTimer);
618
678
  this.tokenTimer = null;
@@ -640,8 +700,13 @@ export class StreamClient {
640
700
  const closed = () => lifecycle !== this.lifecycle;
641
701
  let result: JoinResult;
642
702
  if (this.state === 'idle' || this.state === 'closed') this.setState('connecting');
703
+ // The socket this call opens. A later connect() replaces it, and this
704
+ // call's failure must then leave the newer socket, and the state, alone.
705
+ let socket: WebSocket | null = null;
643
706
  try {
644
- await this.openSocket();
707
+ const opening = this.openSocket();
708
+ socket = this.ws;
709
+ await opening;
645
710
  result = (await this.rpc('join', { token: this.token })) as JoinResult;
646
711
  this.joinResult = result;
647
712
 
@@ -654,7 +719,11 @@ export class StreamClient {
654
719
  // A session that never got going: the app has the error. Left open,
655
720
  // the socket would be closed by the server's join deadline instead, and
656
721
  // that close would start reconnecting a session that never began.
657
- if (!this.joinedOnce) {
722
+ const superseded = this.ws !== null && this.ws !== socket;
723
+ // A reconnect attempt that failed (its join timed out, say) lets its
724
+ // socket go now too, so nothing it says later reaches the session while
725
+ // the next attempt waits its backoff (V072-05).
726
+ if ((!this.joinedOnce || this.reconnecting) && !superseded) {
658
727
  this.detachSocket();
659
728
  if (this.state === 'connecting') this.setState('idle');
660
729
  }
@@ -677,6 +746,8 @@ export class StreamClient {
677
746
  if (closed()) throw closedWhileConnecting();
678
747
  }
679
748
  const own = result.participants.find((p) => p.identity === result.identity);
749
+ this.moderate('audio', Boolean(own?.muted), true);
750
+ this.moderate('video', Boolean(own?.videoBlocked), true);
680
751
  if (own?.muted) this.applyLocalModeration('audio');
681
752
  if (own?.videoBlocked) this.applyLocalModeration('video');
682
753
  this.emit('joined', result);
@@ -732,11 +803,22 @@ export class StreamClient {
732
803
  const ws = new WebSocket(socketUrl(this.wsUrl, this.token));
733
804
  this.ws = ws;
734
805
 
806
+ // Bounded like every request (V072-05): a socket that does not open in
807
+ // time fails the attempt with `timeout` and is closed, instead of
808
+ // holding connect() -- or a reconnect attempt -- indefinitely.
809
+ this.openDeadline = setTimeout(() => {
810
+ reject(new RpcError('timeout', `the connection did not open in ${RPC_TIMEOUT_MS / 1000} s`));
811
+ if (this.ws === ws) this.detachSocket('open timed out', 'the connection did not open in time');
812
+ }, RPC_TIMEOUT_MS);
735
813
  const onOpen = () => {
814
+ clearTimeout(this.openDeadline);
736
815
  this.emit('connected');
737
816
  resolve();
738
817
  };
739
- const onError = () => reject(new RpcError('connect-failed', 'websocket failed to open'));
818
+ const onError = () => {
819
+ clearTimeout(this.openDeadline);
820
+ reject(new RpcError('connect-failed', 'websocket failed to open'));
821
+ };
740
822
 
741
823
  ws.addEventListener('open', onOpen, { once: true });
742
824
  ws.addEventListener('error', onError, { once: true });
@@ -756,17 +838,16 @@ export class StreamClient {
756
838
  // Another session for this identity is live. Reconnecting would
757
839
  // displace it, and it would displace us right back, forever.
758
840
  this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
759
- // The producers outlive this close until the app calls close(); the
760
- // videoLimited sampling must not, or it reports after `closed`.
841
+ // The videoLimited sampling stops first, or it reports after `closed`.
761
842
  this.limitWatch.reset();
762
- this.finish('replaced');
843
+ this.end('replaced');
763
844
  return;
764
845
  }
765
846
  if (ev.code === CLOSE_ENDED) {
766
847
  // Normally roomClosed has already ended the session; this covers a
767
848
  // close that arrived without it.
768
849
  this.limitWatch.reset();
769
- this.finish('room-closed');
850
+ this.end('room-closed');
770
851
  return;
771
852
  }
772
853
  // Before a first successful join the app is still waiting on
@@ -779,16 +860,22 @@ export class StreamClient {
779
860
  });
780
861
  }
781
862
 
782
- private detachSocket(reason = 'superseded'): void {
863
+ private detachSocket(reason = 'superseded', why = 'the connection was replaced'): void {
864
+ clearTimeout(this.openDeadline);
783
865
  const ws = this.ws;
784
866
  this.ws = null;
785
- if (ws && ws.readyState <= WebSocket.OPEN) {
867
+ if (!ws) return;
868
+ if (ws.readyState <= WebSocket.OPEN) {
786
869
  try {
787
870
  ws.close(1000, reason);
788
871
  } catch {
789
872
  /* already closing */
790
873
  }
791
874
  }
875
+ // Its replies are no longer read, so what waits on it would only wait out
876
+ // its 15 s timeout: it fails now, as on a close. Never replayed -- the
877
+ // server may already have acted on it.
878
+ this.failPending(ws, new RpcError('not-connected', why));
792
879
  }
793
880
 
794
881
  /**
@@ -798,7 +885,7 @@ export class StreamClient {
798
885
  * still sending.
799
886
  */
800
887
  private releaseSocket(): void {
801
- this.detachSocket('gave up');
888
+ this.detachSocket('gave up', 'the session has ended');
802
889
  this.failAllPending(new RpcError('not-connected', 'the session has ended'));
803
890
  }
804
891
 
@@ -819,7 +906,7 @@ export class StreamClient {
819
906
  if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
820
907
  this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
821
908
  this.releaseSocket();
822
- this.finish('gave-up');
909
+ this.end('gave-up');
823
910
  return;
824
911
  }
825
912
  const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
@@ -843,7 +930,7 @@ export class StreamClient {
843
930
  // attempt forever and drowns out whatever actually went wrong.
844
931
  if (err instanceof RpcError && isTerminalError(err.code)) {
845
932
  this.releaseSocket();
846
- this.finish('gave-up');
933
+ this.end('gave-up');
847
934
  return;
848
935
  }
849
936
  }
@@ -888,6 +975,7 @@ export class StreamClient {
888
975
  }
889
976
 
890
977
  private teardownMedia(): void {
978
+ this.subscriptions.clear();
891
979
  for (const c of this.consumers.values()) c.close();
892
980
  this.consumers.clear();
893
981
  this.remoteInfo.clear();
@@ -962,17 +1050,27 @@ export class StreamClient {
962
1050
  * `tokenExpiring`. On a live connection the server checks it like a join
963
1051
  * token (`bad-token`, or `token-mismatch` for another room or identity; the
964
1052
  * session then keeps its token); otherwise it is kept for the next join,
965
- * which checks it there. Resolves to when it expires (unix ms), when known.
1053
+ * which checks it there. Resolves to when the token now held expires (unix
1054
+ * ms), when known. A newer token that arrives while this waits is kept
1055
+ * instead of this one: the server's (its own refresh, or a promotion's,
1056
+ * which carries the new role) or a later updateToken()'s.
966
1057
  */
967
1058
  async updateToken(token: string): Promise<{ expiresAt: number | null }> {
968
1059
  if (typeof token !== 'string' || token.trim() === '') throw new TypeError('updateToken needs a token');
969
1060
  if (this.state === 'closed') throw new RpcError('not-connected', 'this session is closed; create a new client');
1061
+ const request = ++this.tokenRequests;
1062
+ const revision = this.serverTokenRevision;
1063
+ const keep = (expiresAt: number | null) => {
1064
+ if (revision === this.serverTokenRevision && request > this.tokenRequestHeld) {
1065
+ this.tokenRequestHeld = request;
1066
+ this.setToken(token, expiresAt);
1067
+ }
1068
+ return { expiresAt: this.tokenExpiry };
1069
+ };
970
1070
  if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
971
1071
  try {
972
1072
  const res = (await this.rpc('refreshToken', { token })) as { expiresAt?: unknown } | undefined;
973
- const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
974
- this.setToken(token, at);
975
- return { expiresAt: this.tokenExpiry };
1073
+ return keep(typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null);
976
1074
  } catch (err) {
977
1075
  // An older server has no refreshToken, and a socket that dropped
978
1076
  // during the call is reconnecting: either way keep the token for the
@@ -980,8 +1078,7 @@ export class StreamClient {
980
1078
  if (!(err instanceof RpcError) || (err.code !== 'unknown-method' && err.code !== 'not-connected')) throw err;
981
1079
  }
982
1080
  }
983
- this.setToken(token, null);
984
- return { expiresAt: this.tokenExpiry };
1081
+ return keep(null);
985
1082
  }
986
1083
 
987
1084
  /** The token reconnects use from now on, with its expiry (read from the token when not given). */
@@ -1045,11 +1142,21 @@ export class StreamClient {
1045
1142
  this.pending.delete(id);
1046
1143
  reject(new RpcError('timeout', `${method} timed out`));
1047
1144
  }, RPC_TIMEOUT_MS);
1048
- this.pending.set(id, { resolve, reject, timer });
1145
+ this.pending.set(id, { resolve, reject, timer, ws: this.ws });
1049
1146
  this.ws.send(JSON.stringify({ id, method, params }));
1050
1147
  });
1051
1148
  }
1052
1149
 
1150
+ /** Fail what was sent on `ws` and is still waiting. */
1151
+ private failPending(ws: WebSocket, err: Error): void {
1152
+ for (const [id, p] of this.pending) {
1153
+ if (p.ws !== ws) continue;
1154
+ clearTimeout(p.timer);
1155
+ this.pending.delete(id);
1156
+ p.reject(err);
1157
+ }
1158
+ }
1159
+
1053
1160
  private failAllPending(err: Error): void {
1054
1161
  for (const p of this.pending.values()) {
1055
1162
  clearTimeout(p.timer);
@@ -1093,21 +1200,20 @@ export class StreamClient {
1093
1200
  break;
1094
1201
  case 'producerClosed': {
1095
1202
  const { producerId, identity } = data as { producerId: string; identity: string };
1203
+ this.subscriptions.delete(producerId);
1096
1204
  this.preferredLayers.delete(producerId);
1097
1205
  this.receiveQualities.delete(producerId);
1098
1206
  this.remoteVideoLayers.delete(producerId);
1099
1207
  this.skippedVideos.delete(producerId);
1100
1208
  const consumer = this.consumers.get(producerId);
1101
- if (!consumer) break; // never consumed it, or already cleaned up
1102
- this.videoSlots.delete(producerId);
1103
- consumer.close();
1209
+ consumer?.close();
1104
1210
  this.consumers.delete(producerId);
1105
1211
  this.remoteSources.delete(producerId);
1106
1212
  this.remoteInfo.delete(producerId);
1107
- this.emit('trackEnded', { producerId, identity });
1213
+ if (consumer) this.emit('trackEnded', { producerId, identity });
1108
1214
  // A video slot opened under the fan-out limit: the oldest video left
1109
1215
  // waiting takes it.
1110
- if (consumer.kind === 'video') void this.consumeSkipped().catch((err) => this.emit('error', err as Error));
1216
+ this.releaseVideoSlot(producerId);
1111
1217
  break;
1112
1218
  }
1113
1219
  case 'producerPaused': {
@@ -1133,6 +1239,7 @@ export class StreamClient {
1133
1239
  case 'participantMuted': {
1134
1240
  const wire = data as { identity: string; kind?: 'audio' | 'video' | ''; muted?: boolean; paused?: boolean };
1135
1241
  const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
1242
+ if (e.identity && e.identity === this.joinResult?.identity) this.moderate(e.kind, e.paused, true);
1136
1243
  this.updateRoster((list) => list.map((p) =>
1137
1244
  p.identity !== e.identity ? p : { ...p,
1138
1245
  ...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
@@ -1179,6 +1286,7 @@ export class StreamClient {
1179
1286
  // in-app music: it travels as audio). Reflect it locally; a release
1180
1287
  // changes nothing here -- switching back on is the user's choice.
1181
1288
  const e = data as { by: string; kind?: 'audio' | 'video' | ''; paused: boolean };
1289
+ this.moderate(e.kind, Boolean(e.paused));
1182
1290
  if (e.paused) {
1183
1291
  this.applyLocalModeration(e.kind);
1184
1292
  this.emitLocalMedia();
@@ -1203,13 +1311,19 @@ export class StreamClient {
1203
1311
  // fresh one periodically so a reconnect at any point still works --
1204
1312
  // without this, any disconnect after the token's lifetime is terminal.
1205
1313
  const e = data as { token?: string; role?: Role; expiresAt?: number };
1206
- if (e.token) this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
1314
+ if (e.token) {
1315
+ this.serverTokenRevision += 1;
1316
+ this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
1317
+ }
1207
1318
  break;
1208
1319
  }
1209
1320
  case 'mustReconnect': {
1210
1321
  const e = data as { reason: string; token?: string; retry: boolean };
1211
1322
  // A promotion hands us a fresh token carrying the new role.
1212
- if (e.token) this.setToken(e.token, null);
1323
+ if (e.token) {
1324
+ this.serverTokenRevision += 1;
1325
+ this.setToken(e.token, null);
1326
+ }
1213
1327
  this.emit('mustReconnect', e);
1214
1328
  if (e.retry) {
1215
1329
  this.teardownMedia();
@@ -1377,9 +1491,26 @@ export class StreamClient {
1377
1491
  // unpublish() ran first: this publish is over before it began, and must
1378
1492
  // leave nothing behind that a reconnect would publish again.
1379
1493
  if (generation !== this.publishGeneration) throw unpublishedWhile('stream');
1380
- this.localStream = stream;
1494
+ // A second publish adds to the first (the microphone, then the camera):
1495
+ // one local stream carries every live track, so unpublish() and close()
1496
+ // stop them all and a reconnect publishes each again. Replacing it
1497
+ // orphaned the first stream's tracks, still capturing (V072-01).
1498
+ const previous = this.localStream;
1499
+ const merged = previous !== null && previous !== stream && previous.getTracks().some((t) => t.readyState === 'live');
1500
+ if (merged) {
1501
+ const ids = new Set(stream.getTracks().map((t) => t.id));
1502
+ // The new stream's tracks first: of one kind, the newer track is the one
1503
+ // published (as before), and the older one is still stopped with it.
1504
+ this.localStream = new MediaStream([
1505
+ ...stream.getTracks(),
1506
+ ...previous.getTracks().filter((t) => t.readyState === 'live' && !ids.has(t.id)),
1507
+ ]);
1508
+ this.emit('localStreamChanged', this.localStream);
1509
+ } else {
1510
+ this.localStream = stream;
1511
+ }
1381
1512
  this.wasPublishing = true;
1382
- if (constraints) this.publishConstraints = constraints;
1513
+ if (constraints) this.publishConstraints = merged ? mergeConstraints(this.publishConstraints, constraints) : constraints;
1383
1514
  if (options) {
1384
1515
  this.publishOptions = { ...options };
1385
1516
  this.frameRateFallback = false;
@@ -1972,7 +2103,11 @@ export class StreamClient {
1972
2103
  if (this.screenStream === screen) await this.stopScreen('stopped');
1973
2104
  this.emit('error', err as Error);
1974
2105
  });
1975
- if (this.music) await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err as Error));
2106
+ if (this.music) {
2107
+ const music = this.music;
2108
+ const operation = this.musicOperation;
2109
+ await this.musicTask(() => this.produceMusic(music, generation, operation)).catch((err) => this.emit('error', err as Error));
2110
+ }
1976
2111
  if (generation !== this.publishGeneration) return;
1977
2112
 
1978
2113
  let stream = this.localStream;
@@ -2183,26 +2318,41 @@ export class StreamClient {
2183
2318
  async publishMusic(input: MusicInput, options?: MusicOptions): Promise<void> {
2184
2319
  if (!this.mediaPermissions.audio) throw new RpcError('media-disabled', 'audio is switched off for this project');
2185
2320
  const generation = this.publishGeneration;
2186
- if (this.music) await this.stopMusic();
2187
- const music = new MusicSource(input, options);
2188
- try {
2189
- await this.produceMusic(music, generation);
2190
- // Checked again here: an unpublish() after the producer was kept has
2191
- // already closed it, and the music must not be remembered for a
2192
- // reconnect to publish again.
2193
- if (generation !== this.publishGeneration) throw unpublishedWhile('music');
2194
- } catch (err) {
2195
- music.close();
2196
- throw err;
2197
- }
2198
- this.music = music;
2199
- this.wasPublishing = true;
2200
- this.emitLocalMedia();
2321
+ // Taken now, in call order: a music publish still under way is over, and
2322
+ // a stopMusic() from here on ends this one.
2323
+ const operation = ++this.musicOperation;
2324
+ await this.musicTask(async () => {
2325
+ if (generation !== this.publishGeneration || operation !== this.musicOperation) throw unpublishedWhile('music');
2326
+ if (this.music) await this.closeMusic();
2327
+ const music = new MusicSource(input, options);
2328
+ try {
2329
+ await this.produceMusic(music, generation, operation);
2330
+ // Checked again here: an unpublish() or stopMusic() after the
2331
+ // producer was kept has already closed it, and the music must not be
2332
+ // remembered for a reconnect to publish again.
2333
+ if (generation !== this.publishGeneration || operation !== this.musicOperation) throw unpublishedWhile('music');
2334
+ } catch (err) {
2335
+ music.close();
2336
+ throw err;
2337
+ }
2338
+ this.music = music;
2339
+ this.wasPublishing = true;
2340
+ this.emitLocalMedia();
2341
+ });
2201
2342
  }
2202
2343
 
2203
- private async produceMusic(music: MusicSource, generation: number): Promise<void> {
2344
+ private musicTask<T>(task: () => Promise<T>): Promise<T> {
2345
+ const run = this.musicQueue.then(task, task);
2346
+ this.musicQueue = run.catch(() => undefined);
2347
+ return run;
2348
+ }
2349
+
2350
+ private async produceMusic(music: MusicSource, generation: number, operation: number): Promise<void> {
2351
+ // unpublish(), stopMusic() or a newer publishMusic() can run at any wait
2352
+ // below, and wins.
2353
+ const current = () => generation === this.publishGeneration && operation === this.musicOperation;
2204
2354
  const transport = await this.ensureSendTransport();
2205
- if (generation !== this.publishGeneration) throw unpublishedWhile('music');
2355
+ if (!current()) throw unpublishedWhile('music');
2206
2356
  const producer = await transport.produce({
2207
2357
  track: music.track,
2208
2358
  stopTracks: false,
@@ -2211,7 +2361,14 @@ export class StreamClient {
2211
2361
  codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
2212
2362
  appData: { source: 'music' },
2213
2363
  });
2214
- await this.keepProducer(producer, generation, 'music');
2364
+ if (!current()) {
2365
+ producer.close();
2366
+ await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
2367
+ throw unpublishedWhile('music');
2368
+ }
2369
+ // Kept and named the music in one step, with no wait between: stopMusic()
2370
+ // finds the music producer by that name.
2371
+ this.producers.set(producer.id, producer);
2215
2372
  this.musicProducerId = producer.id;
2216
2373
  // Music switched off before a reconnect comes back paused here.
2217
2374
  await this.pauseOnServer(producer);
@@ -2242,8 +2399,18 @@ export class StreamClient {
2242
2399
  this.emitLocalMedia();
2243
2400
  }
2244
2401
 
2245
- /** Stop publishing music. The element or stream it came from is left as it was. */
2402
+ /**
2403
+ * Stop publishing music. The element or stream it came from is left as it
2404
+ * was. A publishMusic() still under way is stopped too: it rejects with
2405
+ * `unpublished` and publishes nothing.
2406
+ */
2246
2407
  async stopMusic(): Promise<void> {
2408
+ this.musicOperation += 1;
2409
+ await this.closeMusic();
2410
+ }
2411
+
2412
+ /** Close the music being published, here and at the server. */
2413
+ private async closeMusic(): Promise<void> {
2247
2414
  const id = this.musicProducerId;
2248
2415
  this.musicProducerId = null;
2249
2416
  this.music?.close();
@@ -2265,8 +2432,9 @@ export class StreamClient {
2265
2432
  }
2266
2433
 
2267
2434
  private async consume(remote: RemoteProducer): Promise<void> {
2435
+ if (this.closedByUs) return;
2268
2436
  if (!this.device) throw new RpcError('not-connected', 'not connected');
2269
- if (this.consumers.has(remote.producerId)) return;
2437
+ if (this.subscriptions.has(remote.producerId)) return;
2270
2438
  // Your own camera is already local; pulling it back off the SFU wastes a
2271
2439
  // consumer and shows you a delayed copy of yourself.
2272
2440
  if (remote.identity === this.joinResult?.identity) return;
@@ -2285,34 +2453,67 @@ export class StreamClient {
2285
2453
  }
2286
2454
  this.videoSlots.add(remote.producerId);
2287
2455
  }
2456
+ const operation = {};
2457
+ this.subscriptions.set(remote.producerId, operation);
2288
2458
  try {
2289
- await this.consumeNow(remote);
2459
+ await this.consumeNow(remote, operation);
2290
2460
  } catch (err) {
2291
- if (!this.consumers.has(remote.producerId)) this.videoSlots.delete(remote.producerId);
2461
+ // A closed producer or replaced session has already released its slot.
2462
+ // Its late failure must neither release a newer slot nor report a ghost error.
2463
+ if (this.subscriptions.get(remote.producerId) !== operation) return;
2464
+ this.subscriptions.delete(remote.producerId);
2465
+ this.consumers.get(remote.producerId)?.close();
2466
+ this.consumers.delete(remote.producerId);
2467
+ this.remoteVideoLayers.delete(remote.producerId);
2468
+ this.remoteSources.delete(remote.producerId);
2469
+ this.remoteInfo.delete(remote.producerId);
2470
+ this.releaseVideoSlot(remote.producerId);
2292
2471
  throw err;
2293
2472
  }
2294
2473
  }
2295
2474
 
2475
+ /**
2476
+ * Free a video slot, and let the oldest video waiting take it -- whatever
2477
+ * freed it: an ended video, or a subscription that failed. Not while
2478
+ * reconnecting: a failure caused by the socket dropping would otherwise
2479
+ * start every waiting video on a dead connection, each failing with
2480
+ * not-connected; the reconnect's join lists them all again anyway.
2481
+ */
2482
+ private releaseVideoSlot(producerId: string): void {
2483
+ if (this.videoSlots.delete(producerId) && !this.closedByUs && this.state !== 'reconnecting') this.consumeSkipped();
2484
+ }
2485
+
2296
2486
  /** The oldest videos left waiting at the fan-out limit take the slots that opened. */
2297
- private async consumeSkipped(): Promise<void> {
2487
+ private consumeSkipped(): void {
2298
2488
  for (const [producerId, remote] of this.skippedVideos) {
2299
2489
  const limit = this.mediaPermissions.maxVideoSubscriptions;
2300
2490
  if (limit !== null && this.videoSlots.size >= limit) return;
2301
2491
  this.skippedVideos.delete(producerId);
2302
- await this.consume(remote);
2492
+ // consume reserves synchronously, before its first await. Consecutive
2493
+ // releases cannot admit the same producer or exceed the limit.
2494
+ void this.consume(remote).catch((err) => this.emit('error', err as Error));
2303
2495
  }
2304
2496
  }
2305
2497
 
2306
- private async consumeNow(remote: RemoteProducer): Promise<void> {
2498
+ private async consumeNow(remote: RemoteProducer, operation: object): Promise<void> {
2307
2499
  if (!this.device) throw new RpcError('not-connected', 'not connected');
2500
+ const device = this.device;
2501
+ const current = (made?: Consumer) => {
2502
+ if (!this.closedByUs && this.subscriptions.get(remote.producerId) === operation) return;
2503
+ made?.close();
2504
+ throw new RpcError('not-connected', 'subscription ended during setup');
2505
+ };
2308
2506
  const transport = await this.ensureRecvTransport();
2507
+ current();
2309
2508
  const params = (await this.rpc('consume', {
2310
2509
  transportId: transport.id,
2311
2510
  producerId: remote.producerId,
2312
- rtpCapabilities: this.device.rtpCapabilities,
2511
+ rtpCapabilities: device.rtpCapabilities,
2313
2512
  })) as { id: string; producerId: string; kind: 'audio' | 'video'; rtpParameters: never };
2314
2513
 
2514
+ current();
2315
2515
  const consumer = await transport.consume(params);
2516
+ current(consumer);
2316
2517
  this.consumers.set(remote.producerId, consumer);
2317
2518
  this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? (params as { videoLayers?: VideoLayer[] }).videoLayers ?? []);
2318
2519
 
@@ -2328,10 +2529,12 @@ export class StreamClient {
2328
2529
  if (layers) {
2329
2530
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
2330
2531
  }
2532
+ current(consumer);
2331
2533
 
2332
2534
  // The server created it paused. Resume only now that our end exists,
2333
2535
  // otherwise the first frames arrive before there is anything to decode them.
2334
2536
  await this.rpc('resumeConsumer', { consumerId: consumer.id });
2537
+ current(consumer);
2335
2538
 
2336
2539
  const kind = consumer.kind as 'audio' | 'video';
2337
2540
  const source = remote.source ?? defaultSource(kind);
@@ -2396,16 +2599,47 @@ export class StreamClient {
2396
2599
  }
2397
2600
 
2398
2601
  private async setKindEnabled(kind: 'audio' | 'video', enabled: boolean): Promise<void> {
2399
- for (const producer of this.producers.values()) {
2400
- // The music has its own switch. Muting the microphone must not stop the song.
2401
- if (producer.kind !== kind || producer.appData.source !== defaultSource(kind)) continue;
2602
+ const generation = this.publishGeneration;
2603
+ // Unpublished or closed while waiting: nothing is left to switch, and the
2604
+ // call resolves, as it does with nothing published (V072-03).
2605
+ const ended = () => generation !== this.publishGeneration || this.closedByUs;
2606
+ // The music has its own switch. Muting the microphone must not stop the song.
2607
+ const targets = [...this.producers.values()].filter((p) => p.kind === kind && p.appData.source === defaultSource(kind));
2608
+ for (const producer of targets) {
2609
+ if (ended()) return;
2610
+ if (this.producers.get(producer.id) !== producer || producer.closed) continue;
2611
+ const revision = this.moderationRevision[kind];
2402
2612
  if (!enabled) producer.pause();
2403
- await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
2404
- if (enabled) producer.resume();
2613
+ try {
2614
+ await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
2615
+ } catch (err) {
2616
+ if (ended()) return;
2617
+ throw err;
2618
+ }
2619
+ if (ended()) return;
2620
+ if (!enabled || this.producers.get(producer.id) !== producer || producer.closed) continue;
2621
+ if (revision !== this.moderationRevision[kind] || this.forcedOff[kind]) {
2622
+ // A moderator decided while this waited: the older request does not
2623
+ // undo it. It stays off here, and the server is told so too, since
2624
+ // which of the two it applied last cannot be known from here.
2625
+ void this.rpc('pauseProducer', { producerId: producer.id, paused: true }).catch(() => undefined);
2626
+ this.emitLocalMedia();
2627
+ throw new RpcError('force-muted', `a moderator changed your ${kind} while this request waited; it stays off`);
2628
+ }
2629
+ producer.resume();
2405
2630
  }
2406
2631
  this.emitLocalMedia();
2407
2632
  }
2408
2633
 
2634
+ /** Record a moderation decision ('' = both kinds). A roster snapshot counts only where it changes what is known. */
2635
+ private moderate(kind: 'audio' | 'video' | '' | undefined, paused: boolean, snapshot = false): void {
2636
+ for (const k of kind ? [kind] : (['audio', 'video'] as const)) {
2637
+ if (snapshot && this.forcedOff[k] === paused) continue;
2638
+ this.forcedOff[k] = paused;
2639
+ this.moderationRevision[k] += 1;
2640
+ }
2641
+ }
2642
+
2409
2643
  /** Stop publishing entirely and release the local devices. */
2410
2644
  async unpublish(): Promise<void> {
2411
2645
  void this.stopRecording().catch(error => this.emit('error', error as Error));