@voltkit-rtc/client 0.7.1 → 0.7.2

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;
@@ -491,6 +497,16 @@ export class StreamClient {
491
497
  /** In-app music being published, kept across reconnects so it can be published again. */
492
498
  private music: MusicSource | null = null;
493
499
  private musicProducerId: string | null = null;
500
+ /**
501
+ * Bumped by every publishMusic() and stopMusic(). A music producer still
502
+ * being made when it changes belongs to a publish that is over: it is
503
+ * closed, not kept. publishGeneration alone missed stopMusic(), and the
504
+ * music went out after stopMusic() had returned.
505
+ */
506
+ private musicOperation = 0;
507
+ // Music publishes run one at a time: the server takes one producer per
508
+ // source, so the next is produced only once the last one is settled.
509
+ private musicQueue: Promise<unknown> = Promise.resolve();
494
510
  private screenStream: MediaStream | null = null;
495
511
  private screenMode: ScreenMode = 'detail';
496
512
  private screenAudioEnabled = true;
@@ -507,6 +523,8 @@ export class StreamClient {
507
523
  private frameRateWarmupSamples = 2;
508
524
  /** What each remote producer carries, so a pause can say "music" rather than just "audio". */
509
525
  private readonly remoteSources = new Map<string, TrackSource>();
526
+ /** Identity of each pending/active subscription; cleared on close/reconnect. */
527
+ private readonly subscriptions = new Map<string, object>();
510
528
  /**
511
529
  * The layers asked for each remote video, by producerId. A reconnect
512
530
  * consumes everything again, and the new consumer would otherwise start at
@@ -550,6 +568,17 @@ export class StreamClient {
550
568
  /** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
551
569
  private tokenExpiry: number | null;
552
570
  private tokenTimer: ReturnType<typeof setTimeout> | null = null;
571
+ /**
572
+ * Bumped whenever the server hands this client a token (its own refresh, a
573
+ * promotion's). An updateToken() that started before then keeps the token
574
+ * it was given only if nothing newer arrived while it waited: its reply,
575
+ * or its fallback once the socket closed, used to replace a promotion's
576
+ * token, and the reconnect joined with the old one.
577
+ */
578
+ private serverTokenRevision = 0;
579
+ /** updateToken() calls in call order, and the newest one whose token is held: a later call wins over an earlier one answered after it. */
580
+ private tokenRequests = 0;
581
+ private tokenRequestHeld = 0;
553
582
  /** Whose each received track is and whether it is switched off, by producerId. */
554
583
  private readonly remoteInfo = new Map<string, { identity: string; kind: 'audio' | 'video'; paused: boolean }>();
555
584
  /** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
@@ -613,6 +642,18 @@ export class StreamClient {
613
642
  this.emit('closed', reason);
614
643
  }
615
644
 
645
+ /**
646
+ * Over for good without the app asking: finish, then release everything
647
+ * local as close() does -- camera, microphone, screen, music, recording.
648
+ * kicked and roomClosed always did; a replaced socket, a close without
649
+ * roomClosed and a reconnect that gave up left them capturing (and the
650
+ * recording uploading) after `closed`, until the app called close().
651
+ */
652
+ private end(reason: ClosedReason): void {
653
+ this.finish(reason);
654
+ this.close();
655
+ }
656
+
616
657
  private stopTimers(): void {
617
658
  if (this.tokenTimer) clearTimeout(this.tokenTimer);
618
659
  this.tokenTimer = null;
@@ -640,8 +681,13 @@ export class StreamClient {
640
681
  const closed = () => lifecycle !== this.lifecycle;
641
682
  let result: JoinResult;
642
683
  if (this.state === 'idle' || this.state === 'closed') this.setState('connecting');
684
+ // The socket this call opens. A later connect() replaces it, and this
685
+ // call's failure must then leave the newer socket, and the state, alone.
686
+ let socket: WebSocket | null = null;
643
687
  try {
644
- await this.openSocket();
688
+ const opening = this.openSocket();
689
+ socket = this.ws;
690
+ await opening;
645
691
  result = (await this.rpc('join', { token: this.token })) as JoinResult;
646
692
  this.joinResult = result;
647
693
 
@@ -654,7 +700,8 @@ export class StreamClient {
654
700
  // A session that never got going: the app has the error. Left open,
655
701
  // the socket would be closed by the server's join deadline instead, and
656
702
  // that close would start reconnecting a session that never began.
657
- if (!this.joinedOnce) {
703
+ const superseded = this.ws !== null && this.ws !== socket;
704
+ if (!this.joinedOnce && !superseded) {
658
705
  this.detachSocket();
659
706
  if (this.state === 'connecting') this.setState('idle');
660
707
  }
@@ -756,17 +803,16 @@ export class StreamClient {
756
803
  // Another session for this identity is live. Reconnecting would
757
804
  // displace it, and it would displace us right back, forever.
758
805
  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`.
806
+ // The videoLimited sampling stops first, or it reports after `closed`.
761
807
  this.limitWatch.reset();
762
- this.finish('replaced');
808
+ this.end('replaced');
763
809
  return;
764
810
  }
765
811
  if (ev.code === CLOSE_ENDED) {
766
812
  // Normally roomClosed has already ended the session; this covers a
767
813
  // close that arrived without it.
768
814
  this.limitWatch.reset();
769
- this.finish('room-closed');
815
+ this.end('room-closed');
770
816
  return;
771
817
  }
772
818
  // Before a first successful join the app is still waiting on
@@ -779,16 +825,21 @@ export class StreamClient {
779
825
  });
780
826
  }
781
827
 
782
- private detachSocket(reason = 'superseded'): void {
828
+ private detachSocket(reason = 'superseded', why = 'the connection was replaced'): void {
783
829
  const ws = this.ws;
784
830
  this.ws = null;
785
- if (ws && ws.readyState <= WebSocket.OPEN) {
831
+ if (!ws) return;
832
+ if (ws.readyState <= WebSocket.OPEN) {
786
833
  try {
787
834
  ws.close(1000, reason);
788
835
  } catch {
789
836
  /* already closing */
790
837
  }
791
838
  }
839
+ // Its replies are no longer read, so what waits on it would only wait out
840
+ // its 15 s timeout: it fails now, as on a close. Never replayed -- the
841
+ // server may already have acted on it.
842
+ this.failPending(ws, new RpcError('not-connected', why));
792
843
  }
793
844
 
794
845
  /**
@@ -798,7 +849,7 @@ export class StreamClient {
798
849
  * still sending.
799
850
  */
800
851
  private releaseSocket(): void {
801
- this.detachSocket('gave up');
852
+ this.detachSocket('gave up', 'the session has ended');
802
853
  this.failAllPending(new RpcError('not-connected', 'the session has ended'));
803
854
  }
804
855
 
@@ -819,7 +870,7 @@ export class StreamClient {
819
870
  if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
820
871
  this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
821
872
  this.releaseSocket();
822
- this.finish('gave-up');
873
+ this.end('gave-up');
823
874
  return;
824
875
  }
825
876
  const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
@@ -843,7 +894,7 @@ export class StreamClient {
843
894
  // attempt forever and drowns out whatever actually went wrong.
844
895
  if (err instanceof RpcError && isTerminalError(err.code)) {
845
896
  this.releaseSocket();
846
- this.finish('gave-up');
897
+ this.end('gave-up');
847
898
  return;
848
899
  }
849
900
  }
@@ -888,6 +939,7 @@ export class StreamClient {
888
939
  }
889
940
 
890
941
  private teardownMedia(): void {
942
+ this.subscriptions.clear();
891
943
  for (const c of this.consumers.values()) c.close();
892
944
  this.consumers.clear();
893
945
  this.remoteInfo.clear();
@@ -962,17 +1014,27 @@ export class StreamClient {
962
1014
  * `tokenExpiring`. On a live connection the server checks it like a join
963
1015
  * token (`bad-token`, or `token-mismatch` for another room or identity; the
964
1016
  * 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.
1017
+ * which checks it there. Resolves to when the token now held expires (unix
1018
+ * ms), when known. A newer token that arrives while this waits is kept
1019
+ * instead of this one: the server's (its own refresh, or a promotion's,
1020
+ * which carries the new role) or a later updateToken()'s.
966
1021
  */
967
1022
  async updateToken(token: string): Promise<{ expiresAt: number | null }> {
968
1023
  if (typeof token !== 'string' || token.trim() === '') throw new TypeError('updateToken needs a token');
969
1024
  if (this.state === 'closed') throw new RpcError('not-connected', 'this session is closed; create a new client');
1025
+ const request = ++this.tokenRequests;
1026
+ const revision = this.serverTokenRevision;
1027
+ const keep = (expiresAt: number | null) => {
1028
+ if (revision === this.serverTokenRevision && request > this.tokenRequestHeld) {
1029
+ this.tokenRequestHeld = request;
1030
+ this.setToken(token, expiresAt);
1031
+ }
1032
+ return { expiresAt: this.tokenExpiry };
1033
+ };
970
1034
  if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
971
1035
  try {
972
1036
  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 };
1037
+ return keep(typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null);
976
1038
  } catch (err) {
977
1039
  // An older server has no refreshToken, and a socket that dropped
978
1040
  // during the call is reconnecting: either way keep the token for the
@@ -980,8 +1042,7 @@ export class StreamClient {
980
1042
  if (!(err instanceof RpcError) || (err.code !== 'unknown-method' && err.code !== 'not-connected')) throw err;
981
1043
  }
982
1044
  }
983
- this.setToken(token, null);
984
- return { expiresAt: this.tokenExpiry };
1045
+ return keep(null);
985
1046
  }
986
1047
 
987
1048
  /** The token reconnects use from now on, with its expiry (read from the token when not given). */
@@ -1045,11 +1106,21 @@ export class StreamClient {
1045
1106
  this.pending.delete(id);
1046
1107
  reject(new RpcError('timeout', `${method} timed out`));
1047
1108
  }, RPC_TIMEOUT_MS);
1048
- this.pending.set(id, { resolve, reject, timer });
1109
+ this.pending.set(id, { resolve, reject, timer, ws: this.ws });
1049
1110
  this.ws.send(JSON.stringify({ id, method, params }));
1050
1111
  });
1051
1112
  }
1052
1113
 
1114
+ /** Fail what was sent on `ws` and is still waiting. */
1115
+ private failPending(ws: WebSocket, err: Error): void {
1116
+ for (const [id, p] of this.pending) {
1117
+ if (p.ws !== ws) continue;
1118
+ clearTimeout(p.timer);
1119
+ this.pending.delete(id);
1120
+ p.reject(err);
1121
+ }
1122
+ }
1123
+
1053
1124
  private failAllPending(err: Error): void {
1054
1125
  for (const p of this.pending.values()) {
1055
1126
  clearTimeout(p.timer);
@@ -1093,21 +1164,20 @@ export class StreamClient {
1093
1164
  break;
1094
1165
  case 'producerClosed': {
1095
1166
  const { producerId, identity } = data as { producerId: string; identity: string };
1167
+ this.subscriptions.delete(producerId);
1096
1168
  this.preferredLayers.delete(producerId);
1097
1169
  this.receiveQualities.delete(producerId);
1098
1170
  this.remoteVideoLayers.delete(producerId);
1099
1171
  this.skippedVideos.delete(producerId);
1100
1172
  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();
1173
+ consumer?.close();
1104
1174
  this.consumers.delete(producerId);
1105
1175
  this.remoteSources.delete(producerId);
1106
1176
  this.remoteInfo.delete(producerId);
1107
- this.emit('trackEnded', { producerId, identity });
1177
+ if (consumer) this.emit('trackEnded', { producerId, identity });
1108
1178
  // A video slot opened under the fan-out limit: the oldest video left
1109
1179
  // waiting takes it.
1110
- if (consumer.kind === 'video') void this.consumeSkipped().catch((err) => this.emit('error', err as Error));
1180
+ this.releaseVideoSlot(producerId);
1111
1181
  break;
1112
1182
  }
1113
1183
  case 'producerPaused': {
@@ -1203,13 +1273,19 @@ export class StreamClient {
1203
1273
  // fresh one periodically so a reconnect at any point still works --
1204
1274
  // without this, any disconnect after the token's lifetime is terminal.
1205
1275
  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);
1276
+ if (e.token) {
1277
+ this.serverTokenRevision += 1;
1278
+ this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
1279
+ }
1207
1280
  break;
1208
1281
  }
1209
1282
  case 'mustReconnect': {
1210
1283
  const e = data as { reason: string; token?: string; retry: boolean };
1211
1284
  // A promotion hands us a fresh token carrying the new role.
1212
- if (e.token) this.setToken(e.token, null);
1285
+ if (e.token) {
1286
+ this.serverTokenRevision += 1;
1287
+ this.setToken(e.token, null);
1288
+ }
1213
1289
  this.emit('mustReconnect', e);
1214
1290
  if (e.retry) {
1215
1291
  this.teardownMedia();
@@ -1972,7 +2048,11 @@ export class StreamClient {
1972
2048
  if (this.screenStream === screen) await this.stopScreen('stopped');
1973
2049
  this.emit('error', err as Error);
1974
2050
  });
1975
- if (this.music) await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err as Error));
2051
+ if (this.music) {
2052
+ const music = this.music;
2053
+ const operation = this.musicOperation;
2054
+ await this.musicTask(() => this.produceMusic(music, generation, operation)).catch((err) => this.emit('error', err as Error));
2055
+ }
1976
2056
  if (generation !== this.publishGeneration) return;
1977
2057
 
1978
2058
  let stream = this.localStream;
@@ -2183,26 +2263,41 @@ export class StreamClient {
2183
2263
  async publishMusic(input: MusicInput, options?: MusicOptions): Promise<void> {
2184
2264
  if (!this.mediaPermissions.audio) throw new RpcError('media-disabled', 'audio is switched off for this project');
2185
2265
  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();
2266
+ // Taken now, in call order: a music publish still under way is over, and
2267
+ // a stopMusic() from here on ends this one.
2268
+ const operation = ++this.musicOperation;
2269
+ await this.musicTask(async () => {
2270
+ if (generation !== this.publishGeneration || operation !== this.musicOperation) throw unpublishedWhile('music');
2271
+ if (this.music) await this.closeMusic();
2272
+ const music = new MusicSource(input, options);
2273
+ try {
2274
+ await this.produceMusic(music, generation, operation);
2275
+ // Checked again here: an unpublish() or stopMusic() after the
2276
+ // producer was kept has already closed it, and the music must not be
2277
+ // remembered for a reconnect to publish again.
2278
+ if (generation !== this.publishGeneration || operation !== this.musicOperation) throw unpublishedWhile('music');
2279
+ } catch (err) {
2280
+ music.close();
2281
+ throw err;
2282
+ }
2283
+ this.music = music;
2284
+ this.wasPublishing = true;
2285
+ this.emitLocalMedia();
2286
+ });
2287
+ }
2288
+
2289
+ private musicTask<T>(task: () => Promise<T>): Promise<T> {
2290
+ const run = this.musicQueue.then(task, task);
2291
+ this.musicQueue = run.catch(() => undefined);
2292
+ return run;
2201
2293
  }
2202
2294
 
2203
- private async produceMusic(music: MusicSource, generation: number): Promise<void> {
2295
+ private async produceMusic(music: MusicSource, generation: number, operation: number): Promise<void> {
2296
+ // unpublish(), stopMusic() or a newer publishMusic() can run at any wait
2297
+ // below, and wins.
2298
+ const current = () => generation === this.publishGeneration && operation === this.musicOperation;
2204
2299
  const transport = await this.ensureSendTransport();
2205
- if (generation !== this.publishGeneration) throw unpublishedWhile('music');
2300
+ if (!current()) throw unpublishedWhile('music');
2206
2301
  const producer = await transport.produce({
2207
2302
  track: music.track,
2208
2303
  stopTracks: false,
@@ -2211,7 +2306,14 @@ export class StreamClient {
2211
2306
  codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
2212
2307
  appData: { source: 'music' },
2213
2308
  });
2214
- await this.keepProducer(producer, generation, 'music');
2309
+ if (!current()) {
2310
+ producer.close();
2311
+ await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
2312
+ throw unpublishedWhile('music');
2313
+ }
2314
+ // Kept and named the music in one step, with no wait between: stopMusic()
2315
+ // finds the music producer by that name.
2316
+ this.producers.set(producer.id, producer);
2215
2317
  this.musicProducerId = producer.id;
2216
2318
  // Music switched off before a reconnect comes back paused here.
2217
2319
  await this.pauseOnServer(producer);
@@ -2242,8 +2344,18 @@ export class StreamClient {
2242
2344
  this.emitLocalMedia();
2243
2345
  }
2244
2346
 
2245
- /** Stop publishing music. The element or stream it came from is left as it was. */
2347
+ /**
2348
+ * Stop publishing music. The element or stream it came from is left as it
2349
+ * was. A publishMusic() still under way is stopped too: it rejects with
2350
+ * `unpublished` and publishes nothing.
2351
+ */
2246
2352
  async stopMusic(): Promise<void> {
2353
+ this.musicOperation += 1;
2354
+ await this.closeMusic();
2355
+ }
2356
+
2357
+ /** Close the music being published, here and at the server. */
2358
+ private async closeMusic(): Promise<void> {
2247
2359
  const id = this.musicProducerId;
2248
2360
  this.musicProducerId = null;
2249
2361
  this.music?.close();
@@ -2265,8 +2377,9 @@ export class StreamClient {
2265
2377
  }
2266
2378
 
2267
2379
  private async consume(remote: RemoteProducer): Promise<void> {
2380
+ if (this.closedByUs) return;
2268
2381
  if (!this.device) throw new RpcError('not-connected', 'not connected');
2269
- if (this.consumers.has(remote.producerId)) return;
2382
+ if (this.subscriptions.has(remote.producerId)) return;
2270
2383
  // Your own camera is already local; pulling it back off the SFU wastes a
2271
2384
  // consumer and shows you a delayed copy of yourself.
2272
2385
  if (remote.identity === this.joinResult?.identity) return;
@@ -2285,34 +2398,67 @@ export class StreamClient {
2285
2398
  }
2286
2399
  this.videoSlots.add(remote.producerId);
2287
2400
  }
2401
+ const operation = {};
2402
+ this.subscriptions.set(remote.producerId, operation);
2288
2403
  try {
2289
- await this.consumeNow(remote);
2404
+ await this.consumeNow(remote, operation);
2290
2405
  } catch (err) {
2291
- if (!this.consumers.has(remote.producerId)) this.videoSlots.delete(remote.producerId);
2406
+ // A closed producer or replaced session has already released its slot.
2407
+ // Its late failure must neither release a newer slot nor report a ghost error.
2408
+ if (this.subscriptions.get(remote.producerId) !== operation) return;
2409
+ this.subscriptions.delete(remote.producerId);
2410
+ this.consumers.get(remote.producerId)?.close();
2411
+ this.consumers.delete(remote.producerId);
2412
+ this.remoteVideoLayers.delete(remote.producerId);
2413
+ this.remoteSources.delete(remote.producerId);
2414
+ this.remoteInfo.delete(remote.producerId);
2415
+ this.releaseVideoSlot(remote.producerId);
2292
2416
  throw err;
2293
2417
  }
2294
2418
  }
2295
2419
 
2420
+ /**
2421
+ * Free a video slot, and let the oldest video waiting take it -- whatever
2422
+ * freed it: an ended video, or a subscription that failed. Not while
2423
+ * reconnecting: a failure caused by the socket dropping would otherwise
2424
+ * start every waiting video on a dead connection, each failing with
2425
+ * not-connected; the reconnect's join lists them all again anyway.
2426
+ */
2427
+ private releaseVideoSlot(producerId: string): void {
2428
+ if (this.videoSlots.delete(producerId) && !this.closedByUs && this.state !== 'reconnecting') this.consumeSkipped();
2429
+ }
2430
+
2296
2431
  /** The oldest videos left waiting at the fan-out limit take the slots that opened. */
2297
- private async consumeSkipped(): Promise<void> {
2432
+ private consumeSkipped(): void {
2298
2433
  for (const [producerId, remote] of this.skippedVideos) {
2299
2434
  const limit = this.mediaPermissions.maxVideoSubscriptions;
2300
2435
  if (limit !== null && this.videoSlots.size >= limit) return;
2301
2436
  this.skippedVideos.delete(producerId);
2302
- await this.consume(remote);
2437
+ // consume reserves synchronously, before its first await. Consecutive
2438
+ // releases cannot admit the same producer or exceed the limit.
2439
+ void this.consume(remote).catch((err) => this.emit('error', err as Error));
2303
2440
  }
2304
2441
  }
2305
2442
 
2306
- private async consumeNow(remote: RemoteProducer): Promise<void> {
2443
+ private async consumeNow(remote: RemoteProducer, operation: object): Promise<void> {
2307
2444
  if (!this.device) throw new RpcError('not-connected', 'not connected');
2445
+ const device = this.device;
2446
+ const current = (made?: Consumer) => {
2447
+ if (!this.closedByUs && this.subscriptions.get(remote.producerId) === operation) return;
2448
+ made?.close();
2449
+ throw new RpcError('not-connected', 'subscription ended during setup');
2450
+ };
2308
2451
  const transport = await this.ensureRecvTransport();
2452
+ current();
2309
2453
  const params = (await this.rpc('consume', {
2310
2454
  transportId: transport.id,
2311
2455
  producerId: remote.producerId,
2312
- rtpCapabilities: this.device.rtpCapabilities,
2456
+ rtpCapabilities: device.rtpCapabilities,
2313
2457
  })) as { id: string; producerId: string; kind: 'audio' | 'video'; rtpParameters: never };
2314
2458
 
2459
+ current();
2315
2460
  const consumer = await transport.consume(params);
2461
+ current(consumer);
2316
2462
  this.consumers.set(remote.producerId, consumer);
2317
2463
  this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? (params as { videoLayers?: VideoLayer[] }).videoLayers ?? []);
2318
2464
 
@@ -2328,10 +2474,12 @@ export class StreamClient {
2328
2474
  if (layers) {
2329
2475
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
2330
2476
  }
2477
+ current(consumer);
2331
2478
 
2332
2479
  // The server created it paused. Resume only now that our end exists,
2333
2480
  // otherwise the first frames arrive before there is anything to decode them.
2334
2481
  await this.rpc('resumeConsumer', { consumerId: consumer.id });
2482
+ current(consumer);
2335
2483
 
2336
2484
  const kind = consumer.kind as 'audio' | 'video';
2337
2485
  const source = remote.source ?? defaultSource(kind);