@voltkit-rtc/client 0.9.2 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltkit-rtc/client",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Browser SDK for VoltKit RTC: realtime audio/video rooms, chat and live broadcast publishing.",
5
5
  "keywords": [
6
6
  "voltkit",
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  /** This package's version; kept equal to package.json by a test. */
10
- export const SDK_VERSION = '0.9.2';
10
+ export const SDK_VERSION = '0.9.3';
11
11
 
12
12
  /** How many recent events the log keeps. */
13
13
  export const DIAGNOSTICS_LOG_SIZE = 200;
package/src/index.ts CHANGED
@@ -564,6 +564,12 @@ interface PreferredLayers {
564
564
 
565
565
  const isLayer = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0;
566
566
 
567
+ /** A viewer's choice for one video: a quality (setReceiveQuality, which also sets the layers) or a ceiling (setPreferredLayers). */
568
+ interface ReceiveChoice {
569
+ quality: ReceiveQuality | undefined;
570
+ layers: PreferredLayers | undefined;
571
+ }
572
+
567
573
  /**
568
574
  * Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
569
575
  * otherwise starts low and takes many seconds to climb to the top layer.
@@ -732,6 +738,14 @@ export class StreamClient {
732
738
  private readonly screenProducerIds = new Set<string>();
733
739
  private readonly remoteVideoLayers = new Map<string, VideoLayer[]>();
734
740
  private readonly receiveQualities = new Map<string, ReceiveQuality>();
741
+ /**
742
+ * BR-09 (the Flutter SDK's V092-02): per video, the choice the server last accepted, and a revision every change
743
+ * bumps. A choice is stored before its request (what is asked for is read from it), so a request that fails goes
744
+ * back to the last accepted choice -- not merely the one before it, which may itself be waiting or refused -- and
745
+ * only the latest change may go back, so a late failure never undoes a newer choice. Replies come in request order.
746
+ */
747
+ private readonly receiveAccepted = new Map<string, ReceiveChoice>();
748
+ private readonly receiveRevision = new Map<string, number>();
735
749
  private cameraFrameRate: 30 | 60 = 30;
736
750
  private frameRateFallback = false;
737
751
  private frameRateLowSamples = 0;
@@ -1104,10 +1118,9 @@ export class StreamClient {
1104
1118
  // A video that ended while this client was away sends it no
1105
1119
  // producerClosed: forget the layers asked for it.
1106
1120
  const present = new Set(result.producers.map((p) => p.producerId));
1107
- for (const producerId of this.preferredLayers.keys()) {
1121
+ for (const producerId of new Set([...this.preferredLayers.keys(), ...this.receiveAccepted.keys()])) {
1108
1122
  if (!present.has(producerId)) {
1109
- this.preferredLayers.delete(producerId);
1110
- this.receiveQualities.delete(producerId);
1123
+ this.forgetReceiveChoice(producerId);
1111
1124
  this.remoteVideoLayers.delete(producerId);
1112
1125
  }
1113
1126
  }
@@ -1322,6 +1335,8 @@ export class StreamClient {
1322
1335
  this.teardownMedia();
1323
1336
  this.preferredLayers.clear();
1324
1337
  this.receiveQualities.clear();
1338
+ this.receiveAccepted.clear();
1339
+ this.receiveRevision.clear();
1325
1340
  this.remoteVideoLayers.clear();
1326
1341
  this.releaseScreen();
1327
1342
  this.music?.close();
@@ -1603,8 +1618,7 @@ export class StreamClient {
1603
1618
  case 'producerClosed': {
1604
1619
  const { producerId, identity } = data as { producerId: string; identity: string };
1605
1620
  this.subscriptions.delete(producerId);
1606
- this.preferredLayers.delete(producerId);
1607
- this.receiveQualities.delete(producerId);
1621
+ this.forgetReceiveChoice(producerId);
1608
1622
  this.remoteVideoLayers.delete(producerId);
1609
1623
  this.skippedVideos.delete(producerId);
1610
1624
  this.adaptivePaused.delete(producerId);
@@ -2188,7 +2202,10 @@ export class StreamClient {
2188
2202
  const shown = current?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
2189
2203
  const previousSource = shown ? this.cameraSource(shown) : null;
2190
2204
  const before = this.camOut;
2205
+ const beforeRaw = this.camRaw;
2206
+ const wasPaused = current?.paused ?? false;
2191
2207
  const sent = await this.processCamera(track);
2208
+ let swapped = false;
2192
2209
  try {
2193
2210
  if (!current) {
2194
2211
  // The republish reads the camera's state off the track it sends, and
@@ -2202,7 +2219,10 @@ export class StreamClient {
2202
2219
  }
2203
2220
  }
2204
2221
  this.adoptVideoTrack(sent);
2205
- if (!current || current.track === sent) return;
2222
+ if (!current || current.track === sent) {
2223
+ swapped = true;
2224
+ return;
2225
+ }
2206
2226
  const previous = current.track;
2207
2227
  const paused = current.paused;
2208
2228
  try {
@@ -2213,11 +2233,13 @@ export class StreamClient {
2213
2233
  if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
2214
2234
  previousSize?.width === width && previousSize?.height === height) {
2215
2235
  await current.replaceTrack({ track: sent });
2236
+ swapped = true;
2216
2237
  this.watchTrackEnded(current);
2217
2238
  await this.applyLayerBitrates(current, next);
2218
2239
  return;
2219
2240
  }
2220
2241
  await this.reproduceCamera(current, sent);
2242
+ swapped = true;
2221
2243
  } finally {
2222
2244
  // Pausing switched the old track off (mediasoup-client pauses that
2223
2245
  // way), and nothing switches it back on once it is replaced. It is the
@@ -2225,13 +2247,55 @@ export class StreamClient {
2225
2247
  // off, that canvas stays black after the camera is resumed.
2226
2248
  if (paused && previous && previous !== this.cameraProducer()?.track) previous.enabled = true;
2227
2249
  }
2250
+ } catch (err) {
2251
+ if (!swapped) {
2252
+ await this.undoCameraSwap({ producer: current ?? null, given: track, made: sent, shown, source: previousSource, raw: beforeRaw, out: before, paused: wasPaused });
2253
+ }
2254
+ throw err;
2228
2255
  } finally {
2229
- // The processor's previous picture is no longer sent or shown.
2230
- if (before && before !== this.camOut) before.stop();
2256
+ // The processor's previous picture is no longer sent or shown -- once the new one is.
2257
+ if (swapped && before && before !== this.camOut) before.stop();
2231
2258
  }
2232
2259
  });
2233
2260
  }
2234
2261
 
2262
+ /**
2263
+ * A camera swap that failed (BR-12; the Flutter SDK's V090-02). What is sent is still the old picture -- or nothing,
2264
+ * when the producer was replaced and the new one failed. The client's stream goes back to the old picture, so the
2265
+ * app's preview and a reconnect never use a track that did not go on air. With a processor, the old camera is put
2266
+ * back through it if it dropped the old picture when given the new track. The picture made for the new track is
2267
+ * released; the new track itself is the caller's.
2268
+ */
2269
+ private async undoCameraSwap(undo: {
2270
+ producer: Producer | null; given: MediaStreamTrack; made: MediaStreamTrack; shown: MediaStreamTrack | null;
2271
+ source: MediaStreamTrack | null; raw: MediaStreamTrack | null; out: MediaStreamTrack | null; paused: boolean;
2272
+ }): Promise<void> {
2273
+ const { producer, given, made, shown, source, raw, out, paused } = undo;
2274
+ if (made !== given) made.stop();
2275
+ const sending = () => producer !== null && !producer.closed && this.cameraProducer() === producer;
2276
+ let picture = shown;
2277
+ if (shown && shown.readyState === 'ended' && source && source !== shown && source.readyState !== 'ended') {
2278
+ // The processor dropped the picture on air: make it again from the old camera, and send that.
2279
+ picture = await this.processCamera(source);
2280
+ if (sending() && producer && producer.track !== picture) {
2281
+ try {
2282
+ await producer.replaceTrack({ track: picture });
2283
+ this.watchTrackEnded(producer);
2284
+ } catch (err) {
2285
+ this.emit('error', new Error(`the camera could not be put back after a failed switch: ${(err as Error)?.message ?? err}`));
2286
+ }
2287
+ }
2288
+ } else {
2289
+ // The picture on air is as it was, and so is what stands behind it.
2290
+ this.camRaw = raw;
2291
+ this.camOut = out;
2292
+ }
2293
+ if (!picture || picture.readyState === 'ended') return;
2294
+ // With nothing sending it, a republish reads the camera's state off the track: a paused camera stays paused.
2295
+ if (!sending()) picture.enabled = !paused;
2296
+ this.adoptVideoTrack(picture);
2297
+ }
2298
+
2235
2299
  /**
2236
2300
  * Give a camera producer whose track was swapped in place the bitrates of
2237
2301
  * its new picture. The layout matched, but a bigger picture at the same
@@ -2302,8 +2366,10 @@ export class StreamClient {
2302
2366
  * 1280×720), and a layer above the biggest means the biggest. Leave
2303
2367
  * `temporalLayer` out to keep the full frame rate. It is the most this
2304
2368
  * client is sent: a connection that cannot carry it still gets less. The
2305
- * choice is kept across reconnects and forgotten when the track ends.
2306
- * Rejects for a producer whose video this client is not receiving.
2369
+ * choice is kept across reconnects and forgotten when the track ends; a
2370
+ * call that fails (refused, or the connection lost before the answer)
2371
+ * leaves the previous choice in place. Rejects for a producer whose video
2372
+ * this client is not receiving.
2307
2373
  */
2308
2374
  async setPreferredLayers(producerId: string, spatialLayer: number, temporalLayer?: number): Promise<void> {
2309
2375
  if (!isLayer(spatialLayer) || (temporalLayer !== undefined && !isLayer(temporalLayer))) {
@@ -2314,12 +2380,18 @@ export class StreamClient {
2314
2380
  throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
2315
2381
  }
2316
2382
  const layers: PreferredLayers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
2317
- this.receiveQualities.delete(producerId);
2318
- // Kept first: a reconnect that interrupts the request still applies it.
2319
- this.preferredLayers.set(producerId, layers);
2383
+ // Stored before the request, which reads it; a request that fails (refused, or the connection lost before the
2384
+ // answer) leaves the last accepted choice in place (BR-09).
2385
+ const change = this.changeReceive(producerId, { quality: undefined, layers });
2320
2386
  // Shown through attachVideo: the lower of this and the size it is shown at.
2321
2387
  const adaptive = this.adaptive.current(producerId);
2322
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
2388
+ try {
2389
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
2390
+ } catch (err) {
2391
+ change.failed();
2392
+ throw err;
2393
+ }
2394
+ change.accepted();
2323
2395
  }
2324
2396
 
2325
2397
  private async collectVideoStats(baseline: StatsBaseline): Promise<VideoSendStats | null> {
@@ -2343,7 +2415,7 @@ export class StreamClient {
2343
2415
  * big it is on screen: someone who picks 1080p expects 1080p, not the smaller copy that fits the tile. A connection
2344
2416
  * that cannot carry it still gets less until it recovers, and a video shown through attachVideo is still paused
2345
2417
  * while nobody can see it. 'auto' (the default) goes back to the size it is shown at, or the server's choice.
2346
- * Kept across reconnects.
2418
+ * Kept across reconnects; a call that fails leaves the previous choice in place.
2347
2419
  */
2348
2420
  async setReceiveQuality(producerId: string, quality: ReceiveQuality): Promise<void> {
2349
2421
  if (!['auto', '1080p', '720p', '480p'].includes(quality)) throw new RangeError('setReceiveQuality: unsupported quality');
@@ -2355,10 +2427,48 @@ export class StreamClient {
2355
2427
  try { layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) }; }
2356
2428
  catch { throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto'); }
2357
2429
  }
2358
- this.receiveQualities.set(producerId, quality);
2359
- this.preferredLayers.set(producerId, layers);
2430
+ const change = this.changeReceive(producerId, { quality, layers });
2360
2431
  const adaptive = this.adaptive.current(producerId);
2361
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
2432
+ try {
2433
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
2434
+ } catch (err) {
2435
+ change.failed();
2436
+ throw err;
2437
+ }
2438
+ change.accepted();
2439
+ }
2440
+
2441
+ /** Store `choice` for one video now, before its request; the caller reports how the request ended (BR-09). */
2442
+ private changeReceive(producerId: string, choice: ReceiveChoice): { accepted(): void; failed(): void } {
2443
+ if (!this.receiveAccepted.has(producerId)) {
2444
+ this.receiveAccepted.set(producerId, { quality: this.receiveQualities.get(producerId), layers: this.preferredLayers.get(producerId) });
2445
+ }
2446
+ const revision = (this.receiveRevision.get(producerId) ?? 0) + 1;
2447
+ this.receiveRevision.set(producerId, revision);
2448
+ this.setReceiveChoice(producerId, choice);
2449
+ return {
2450
+ // Forgotten meanwhile (the video ended, the session closed): a late answer changes nothing.
2451
+ accepted: () => { if (this.receiveRevision.has(producerId)) this.receiveAccepted.set(producerId, choice); },
2452
+ failed: () => {
2453
+ const back = this.receiveAccepted.get(producerId);
2454
+ if (this.receiveRevision.get(producerId) === revision && back) this.setReceiveChoice(producerId, back);
2455
+ },
2456
+ };
2457
+ }
2458
+
2459
+ private setReceiveChoice(producerId: string, choice: ReceiveChoice): void {
2460
+ if (choice.quality === undefined) this.receiveQualities.delete(producerId);
2461
+ else this.receiveQualities.set(producerId, choice.quality);
2462
+ if (choice.layers === undefined) this.preferredLayers.delete(producerId);
2463
+ else this.preferredLayers.set(producerId, choice.layers);
2464
+ }
2465
+
2466
+ /** The video ended: its choices go with it. */
2467
+ private forgetReceiveChoice(producerId: string): void {
2468
+ this.preferredLayers.delete(producerId);
2469
+ this.receiveQualities.delete(producerId);
2470
+ this.receiveAccepted.delete(producerId);
2471
+ this.receiveRevision.delete(producerId);
2362
2472
  }
2363
2473
 
2364
2474
  private cameraProducer(): Producer | undefined {
@@ -3117,6 +3227,9 @@ export class StreamClient {
3117
3227
  * setPreferredLayers and adaptive stream's size: the lower of the two.
3118
3228
  */
3119
3229
  private effectiveLayers(producerId: string, want: AdaptiveWant | null): PreferredLayers {
3230
+ // Hidden: paused at the server, or held at the smallest layer where it cannot pause. A chosen quality or a ceiling
3231
+ // applies once the video is shown again, never while nobody can see it (BR-08; the Flutter SDK's V092-01).
3232
+ if (want && !want.visible) return { spatialLayer: 0, temporalLayer: 0 };
3120
3233
  let manual: PreferredLayers | undefined = this.preferredLayers.get(producerId);
3121
3234
  const quality = this.receiveQualities.get(producerId);
3122
3235
  if (quality && quality !== 'auto') {
@@ -3488,7 +3601,9 @@ export class StreamClient {
3488
3601
  * captured with the constraints publish was given (size, frame rate, echo cancellation) and its track replaces the
3489
3602
  * published one: a camera of the same size swaps with no pause for viewers, a microphone always does. The track it
3490
3603
  * replaces is stopped, releasing that device; the client's own stream follows (`localStreamChanged`), and a
3491
- * reconnect captures the chosen device again. A camera or microphone that is switched off stays off.
3604
+ * reconnect captures the chosen device again. A camera or microphone that is switched off stays off. A swap that
3605
+ * fails releases the new device and keeps the old one on air, put back through a noise suppressor or video effects
3606
+ * if one is set.
3492
3607
  *
3493
3608
  * For a camera published as a canvas or another processed track, capture the new camera yourself and pass your
3494
3609
  * processed track to `replaceVideoTrack`; this rejects rather than replace your picture with the raw camera.
@@ -3528,16 +3643,35 @@ export class StreamClient {
3528
3643
  track.stop();
3529
3644
  throw new Error('switchDevice: the microphone was unpublished meanwhile');
3530
3645
  }
3531
- await this.adoptAudioTrack(producer, track);
3646
+ try {
3647
+ // Releases the new microphone itself if the swap fails.
3648
+ await this.adoptAudioTrack(producer, track);
3649
+ } catch (err) {
3650
+ if (producer.closed || this.micProducer() !== producer) throw new Error('switchDevice: the microphone was unpublished meanwhile');
3651
+ throw err;
3652
+ }
3532
3653
  this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
3533
3654
  });
3534
3655
  }
3535
3656
 
3536
- /** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
3657
+ /**
3658
+ * Put `track` (one the SDK captured) on the microphone producer and in the client's own stream; the replaced track is
3659
+ * stopped. A swap that fails releases `track` and the sound made from it, and with a processor puts the microphone
3660
+ * still on air back through it: given the new track, the processor dropped that sound (BR-10, BR-11).
3661
+ */
3537
3662
  private async adoptAudioTrack(producer: Producer, track: MediaStreamTrack): Promise<void> {
3538
3663
  const previous = this.micRaw ?? producer.track;
3539
- const sent = await this.processMic(track);
3540
- await producer.replaceTrack({ track: sent });
3664
+ const previousRaw = this.micRaw;
3665
+ let sent = track;
3666
+ try {
3667
+ sent = await this.processMic(track);
3668
+ await producer.replaceTrack({ track: sent });
3669
+ } catch (err) {
3670
+ if (sent !== track) sent.stop();
3671
+ track.stop();
3672
+ await this.restoreMic(producer, previousRaw);
3673
+ throw err;
3674
+ }
3541
3675
  this.micRaw = sent !== track ? track : null;
3542
3676
  const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
3543
3677
  this.localStream = new MediaStream([track, ...video]);
@@ -3546,6 +3680,23 @@ export class StreamClient {
3546
3680
  this.watchTrackEnded(producer);
3547
3681
  }
3548
3682
 
3683
+ /** After a failed microphone swap with a processor: send the microphone on air through it again (it dropped that sound). */
3684
+ private async restoreMic(producer: Producer, raw: MediaStreamTrack | null): Promise<void> {
3685
+ const current = () => !producer.closed && this.micProducer() === producer;
3686
+ if (!this.audioProcessor || !raw || raw.readyState === 'ended' || !current()) return;
3687
+ try {
3688
+ const again = await this.processMic(raw);
3689
+ if (!current()) {
3690
+ if (again !== raw) again.stop();
3691
+ return;
3692
+ }
3693
+ await producer.replaceTrack({ track: again });
3694
+ this.micRaw = again !== raw ? raw : null;
3695
+ } catch (err) {
3696
+ this.emit('error', new Error(`the microphone could not be put back after a failed switch: ${(err as Error)?.message ?? err}`));
3697
+ }
3698
+ }
3699
+
3549
3700
  /** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
3550
3701
  private watchTrackEnded(producer: Producer): void {
3551
3702
  // The device's track, not the processed one a processor sends: that is the one a device ends.