@voltkit-rtc/client 0.9.1 → 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/dist/cjs/index.js CHANGED
@@ -285,6 +285,14 @@ class StreamClient {
285
285
  screenProducerIds = new Set();
286
286
  remoteVideoLayers = new Map();
287
287
  receiveQualities = new Map();
288
+ /**
289
+ * BR-09 (the Flutter SDK's V092-02): per video, the choice the server last accepted, and a revision every change
290
+ * bumps. A choice is stored before its request (what is asked for is read from it), so a request that fails goes
291
+ * back to the last accepted choice -- not merely the one before it, which may itself be waiting or refused -- and
292
+ * only the latest change may go back, so a late failure never undoes a newer choice. Replies come in request order.
293
+ */
294
+ receiveAccepted = new Map();
295
+ receiveRevision = new Map();
288
296
  cameraFrameRate = 30;
289
297
  frameRateFallback = false;
290
298
  frameRateLowSamples = 0;
@@ -658,10 +666,9 @@ class StreamClient {
658
666
  // A video that ended while this client was away sends it no
659
667
  // producerClosed: forget the layers asked for it.
660
668
  const present = new Set(result.producers.map((p) => p.producerId));
661
- for (const producerId of this.preferredLayers.keys()) {
669
+ for (const producerId of new Set([...this.preferredLayers.keys(), ...this.receiveAccepted.keys()])) {
662
670
  if (!present.has(producerId)) {
663
- this.preferredLayers.delete(producerId);
664
- this.receiveQualities.delete(producerId);
671
+ this.forgetReceiveChoice(producerId);
665
672
  this.remoteVideoLayers.delete(producerId);
666
673
  }
667
674
  }
@@ -881,6 +888,8 @@ class StreamClient {
881
888
  this.teardownMedia();
882
889
  this.preferredLayers.clear();
883
890
  this.receiveQualities.clear();
891
+ this.receiveAccepted.clear();
892
+ this.receiveRevision.clear();
884
893
  this.remoteVideoLayers.clear();
885
894
  this.releaseScreen();
886
895
  this.music?.close();
@@ -1167,8 +1176,7 @@ class StreamClient {
1167
1176
  case 'producerClosed': {
1168
1177
  const { producerId, identity } = data;
1169
1178
  this.subscriptions.delete(producerId);
1170
- this.preferredLayers.delete(producerId);
1171
- this.receiveQualities.delete(producerId);
1179
+ this.forgetReceiveChoice(producerId);
1172
1180
  this.remoteVideoLayers.delete(producerId);
1173
1181
  this.skippedVideos.delete(producerId);
1174
1182
  this.adaptivePaused.delete(producerId);
@@ -1761,7 +1769,10 @@ class StreamClient {
1761
1769
  const shown = current?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
1762
1770
  const previousSource = shown ? this.cameraSource(shown) : null;
1763
1771
  const before = this.camOut;
1772
+ const beforeRaw = this.camRaw;
1773
+ const wasPaused = current?.paused ?? false;
1764
1774
  const sent = await this.processCamera(track);
1775
+ let swapped = false;
1765
1776
  try {
1766
1777
  if (!current) {
1767
1778
  // The republish reads the camera's state off the track it sends, and
@@ -1775,8 +1786,10 @@ class StreamClient {
1775
1786
  }
1776
1787
  }
1777
1788
  this.adoptVideoTrack(sent);
1778
- if (!current || current.track === sent)
1789
+ if (!current || current.track === sent) {
1790
+ swapped = true;
1779
1791
  return;
1792
+ }
1780
1793
  const previous = current.track;
1781
1794
  const paused = current.paused;
1782
1795
  try {
@@ -1787,11 +1800,13 @@ class StreamClient {
1787
1800
  if (this.cameraEncodings && (0, quality_js_1.sameLayout)(this.cameraEncodings, next) &&
1788
1801
  previousSize?.width === width && previousSize?.height === height) {
1789
1802
  await current.replaceTrack({ track: sent });
1803
+ swapped = true;
1790
1804
  this.watchTrackEnded(current);
1791
1805
  await this.applyLayerBitrates(current, next);
1792
1806
  return;
1793
1807
  }
1794
1808
  await this.reproduceCamera(current, sent);
1809
+ swapped = true;
1795
1810
  }
1796
1811
  finally {
1797
1812
  // Pausing switched the old track off (mediasoup-client pauses that
@@ -1802,13 +1817,57 @@ class StreamClient {
1802
1817
  previous.enabled = true;
1803
1818
  }
1804
1819
  }
1820
+ catch (err) {
1821
+ if (!swapped) {
1822
+ await this.undoCameraSwap({ producer: current ?? null, given: track, made: sent, shown, source: previousSource, raw: beforeRaw, out: before, paused: wasPaused });
1823
+ }
1824
+ throw err;
1825
+ }
1805
1826
  finally {
1806
- // The processor's previous picture is no longer sent or shown.
1807
- if (before && before !== this.camOut)
1827
+ // The processor's previous picture is no longer sent or shown -- once the new one is.
1828
+ if (swapped && before && before !== this.camOut)
1808
1829
  before.stop();
1809
1830
  }
1810
1831
  });
1811
1832
  }
1833
+ /**
1834
+ * A camera swap that failed (BR-12; the Flutter SDK's V090-02). What is sent is still the old picture -- or nothing,
1835
+ * when the producer was replaced and the new one failed. The client's stream goes back to the old picture, so the
1836
+ * app's preview and a reconnect never use a track that did not go on air. With a processor, the old camera is put
1837
+ * back through it if it dropped the old picture when given the new track. The picture made for the new track is
1838
+ * released; the new track itself is the caller's.
1839
+ */
1840
+ async undoCameraSwap(undo) {
1841
+ const { producer, given, made, shown, source, raw, out, paused } = undo;
1842
+ if (made !== given)
1843
+ made.stop();
1844
+ const sending = () => producer !== null && !producer.closed && this.cameraProducer() === producer;
1845
+ let picture = shown;
1846
+ if (shown && shown.readyState === 'ended' && source && source !== shown && source.readyState !== 'ended') {
1847
+ // The processor dropped the picture on air: make it again from the old camera, and send that.
1848
+ picture = await this.processCamera(source);
1849
+ if (sending() && producer && producer.track !== picture) {
1850
+ try {
1851
+ await producer.replaceTrack({ track: picture });
1852
+ this.watchTrackEnded(producer);
1853
+ }
1854
+ catch (err) {
1855
+ this.emit('error', new Error(`the camera could not be put back after a failed switch: ${err?.message ?? err}`));
1856
+ }
1857
+ }
1858
+ }
1859
+ else {
1860
+ // The picture on air is as it was, and so is what stands behind it.
1861
+ this.camRaw = raw;
1862
+ this.camOut = out;
1863
+ }
1864
+ if (!picture || picture.readyState === 'ended')
1865
+ return;
1866
+ // With nothing sending it, a republish reads the camera's state off the track: a paused camera stays paused.
1867
+ if (!sending())
1868
+ picture.enabled = !paused;
1869
+ this.adoptVideoTrack(picture);
1870
+ }
1812
1871
  /**
1813
1872
  * Give a camera producer whose track was swapped in place the bitrates of
1814
1873
  * its new picture. The layout matched, but a bigger picture at the same
@@ -1882,8 +1941,10 @@ class StreamClient {
1882
1941
  * 1280×720), and a layer above the biggest means the biggest. Leave
1883
1942
  * `temporalLayer` out to keep the full frame rate. It is the most this
1884
1943
  * client is sent: a connection that cannot carry it still gets less. The
1885
- * choice is kept across reconnects and forgotten when the track ends.
1886
- * Rejects for a producer whose video this client is not receiving.
1944
+ * choice is kept across reconnects and forgotten when the track ends; a
1945
+ * call that fails (refused, or the connection lost before the answer)
1946
+ * leaves the previous choice in place. Rejects for a producer whose video
1947
+ * this client is not receiving.
1887
1948
  */
1888
1949
  async setPreferredLayers(producerId, spatialLayer, temporalLayer) {
1889
1950
  if (!isLayer(spatialLayer) || (temporalLayer !== undefined && !isLayer(temporalLayer))) {
@@ -1894,12 +1955,19 @@ class StreamClient {
1894
1955
  throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
1895
1956
  }
1896
1957
  const layers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
1897
- this.receiveQualities.delete(producerId);
1898
- // Kept first: a reconnect that interrupts the request still applies it.
1899
- this.preferredLayers.set(producerId, layers);
1958
+ // Stored before the request, which reads it; a request that fails (refused, or the connection lost before the
1959
+ // answer) leaves the last accepted choice in place (BR-09).
1960
+ const change = this.changeReceive(producerId, { quality: undefined, layers });
1900
1961
  // Shown through attachVideo: the lower of this and the size it is shown at.
1901
1962
  const adaptive = this.adaptive.current(producerId);
1902
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
1963
+ try {
1964
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
1965
+ }
1966
+ catch (err) {
1967
+ change.failed();
1968
+ throw err;
1969
+ }
1970
+ change.accepted();
1903
1971
  }
1904
1972
  async collectVideoStats(baseline) {
1905
1973
  const producer = this.cameraProducer();
@@ -1918,7 +1986,14 @@ class StreamClient {
1918
1986
  (0, stats_js_1.pruneBaseline)(baseline, (key) => key.startsWith(prefix));
1919
1987
  return (0, stats_js_1.parseSendStats)(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
1920
1988
  }
1921
- /** Friendly receive ceiling using this producer's real layer sizes; auto restores adaptation. */
1989
+ /**
1990
+ * The quality this viewer gets of one remote video. '1080p', '720p' and '480p' pick the publisher's biggest layer
1991
+ * at or below that size (a 720p camera cannot give 1080p), and the viewer gets it while the video is shown, however
1992
+ * big it is on screen: someone who picks 1080p expects 1080p, not the smaller copy that fits the tile. A connection
1993
+ * that cannot carry it still gets less until it recovers, and a video shown through attachVideo is still paused
1994
+ * while nobody can see it. 'auto' (the default) goes back to the size it is shown at, or the server's choice.
1995
+ * Kept across reconnects; a call that fails leaves the previous choice in place.
1996
+ */
1922
1997
  async setReceiveQuality(producerId, quality) {
1923
1998
  if (!['auto', '1080p', '720p', '480p'].includes(quality))
1924
1999
  throw new RangeError('setReceiveQuality: unsupported quality');
@@ -1936,10 +2011,52 @@ class StreamClient {
1936
2011
  throw new errors_js_1.RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto');
1937
2012
  }
1938
2013
  }
1939
- this.receiveQualities.set(producerId, quality);
1940
- this.preferredLayers.set(producerId, layers);
2014
+ const change = this.changeReceive(producerId, { quality, layers });
1941
2015
  const adaptive = this.adaptive.current(producerId);
1942
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
2016
+ try {
2017
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
2018
+ }
2019
+ catch (err) {
2020
+ change.failed();
2021
+ throw err;
2022
+ }
2023
+ change.accepted();
2024
+ }
2025
+ /** Store `choice` for one video now, before its request; the caller reports how the request ended (BR-09). */
2026
+ changeReceive(producerId, choice) {
2027
+ if (!this.receiveAccepted.has(producerId)) {
2028
+ this.receiveAccepted.set(producerId, { quality: this.receiveQualities.get(producerId), layers: this.preferredLayers.get(producerId) });
2029
+ }
2030
+ const revision = (this.receiveRevision.get(producerId) ?? 0) + 1;
2031
+ this.receiveRevision.set(producerId, revision);
2032
+ this.setReceiveChoice(producerId, choice);
2033
+ return {
2034
+ // Forgotten meanwhile (the video ended, the session closed): a late answer changes nothing.
2035
+ accepted: () => { if (this.receiveRevision.has(producerId))
2036
+ this.receiveAccepted.set(producerId, choice); },
2037
+ failed: () => {
2038
+ const back = this.receiveAccepted.get(producerId);
2039
+ if (this.receiveRevision.get(producerId) === revision && back)
2040
+ this.setReceiveChoice(producerId, back);
2041
+ },
2042
+ };
2043
+ }
2044
+ setReceiveChoice(producerId, choice) {
2045
+ if (choice.quality === undefined)
2046
+ this.receiveQualities.delete(producerId);
2047
+ else
2048
+ this.receiveQualities.set(producerId, choice.quality);
2049
+ if (choice.layers === undefined)
2050
+ this.preferredLayers.delete(producerId);
2051
+ else
2052
+ this.preferredLayers.set(producerId, choice.layers);
2053
+ }
2054
+ /** The video ended: its choices go with it. */
2055
+ forgetReceiveChoice(producerId) {
2056
+ this.preferredLayers.delete(producerId);
2057
+ this.receiveQualities.delete(producerId);
2058
+ this.receiveAccepted.delete(producerId);
2059
+ this.receiveRevision.delete(producerId);
1943
2060
  }
1944
2061
  cameraProducer() {
1945
2062
  for (const p of this.producers.values()) {
@@ -2707,26 +2824,35 @@ class StreamClient {
2707
2824
  * Adaptive stream (like LiveKit's adaptiveStream and Tencent TRTC's small
2708
2825
  * stream with receiveWhenViewVisible): tell the SDK which element shows a
2709
2826
  * remote video. It then receives the smallest layer that fills that element
2710
- * sharply (its size on screen × devicePixelRatio, at most 2) and pauses the
2827
+ * without stretching it (its size on screen × devicePixelRatio, at most 2) and pauses the
2711
2828
  * video at the server while the element is not visible (scrolled away,
2712
2829
  * `display: none`, or the tab in the background), so a grid of small tiles
2713
2830
  * stops decoding full-size video. Attach the same video to several elements
2714
2831
  * and the largest visible one decides. Returns the function that detaches
2715
- * it; detach when the element goes away. A ceiling set with
2716
- * `setReceiveQuality` or `setPreferredLayers` still applies. Works with any
2717
- * server; one without `pauseConsumer` gets the smallest layer instead of a
2718
- * pause.
2832
+ * it; detach when the element goes away. A quality chosen with
2833
+ * `setReceiveQuality` replaces the size it is shown at (the video is still
2834
+ * paused while hidden), and a ceiling set with `setPreferredLayers` still
2835
+ * applies. Works with any server; one without `pauseConsumer` gets the
2836
+ * smallest layer instead of a pause.
2719
2837
  */
2720
2838
  attachVideo(producerId, element) {
2721
2839
  return this.adaptive.attach(producerId, element);
2722
2840
  }
2723
- /** The layers to ask for: the app's ceiling (setReceiveQuality / setPreferredLayers) and adaptive stream's, the lower. */
2841
+ /**
2842
+ * The layers to ask for. A quality chosen with setReceiveQuality is the layer, whatever the size on screen (owner,
2843
+ * 2026-10-20: viewers who picked 1080p got 540p, because it was only a ceiling). A ceiling set with
2844
+ * setPreferredLayers and adaptive stream's size: the lower of the two.
2845
+ */
2724
2846
  effectiveLayers(producerId, want) {
2847
+ // Hidden: paused at the server, or held at the smallest layer where it cannot pause. A chosen quality or a ceiling
2848
+ // applies once the video is shown again, never while nobody can see it (BR-08; the Flutter SDK's V092-01).
2849
+ if (want && !want.visible)
2850
+ return { spatialLayer: 0, temporalLayer: 0 };
2725
2851
  let manual = this.preferredLayers.get(producerId);
2726
2852
  const quality = this.receiveQualities.get(producerId);
2727
2853
  if (quality && quality !== 'auto') {
2728
2854
  try {
2729
- manual = { spatialLayer: (0, quality_js_1.receiveSpatialLayer)(this.remoteVideoLayers.get(producerId) ?? [], quality) };
2855
+ return { spatialLayer: (0, quality_js_1.receiveSpatialLayer)(this.remoteVideoLayers.get(producerId) ?? [], quality) };
2730
2856
  }
2731
2857
  catch {
2732
2858
  manual = undefined;
@@ -3115,7 +3241,9 @@ class StreamClient {
3115
3241
  * captured with the constraints publish was given (size, frame rate, echo cancellation) and its track replaces the
3116
3242
  * published one: a camera of the same size swaps with no pause for viewers, a microphone always does. The track it
3117
3243
  * replaces is stopped, releasing that device; the client's own stream follows (`localStreamChanged`), and a
3118
- * reconnect captures the chosen device again. A camera or microphone that is switched off stays off.
3244
+ * reconnect captures the chosen device again. A camera or microphone that is switched off stays off. A swap that
3245
+ * fails releases the new device and keeps the old one on air, put back through a noise suppressor or video effects
3246
+ * if one is set.
3119
3247
  *
3120
3248
  * For a camera published as a canvas or another processed track, capture the new camera yourself and pass your
3121
3249
  * processed track to `replaceVideoTrack`; this rejects rather than replace your picture with the raw camera.
@@ -3162,15 +3290,38 @@ class StreamClient {
3162
3290
  track.stop();
3163
3291
  throw new Error('switchDevice: the microphone was unpublished meanwhile');
3164
3292
  }
3165
- await this.adoptAudioTrack(producer, track);
3293
+ try {
3294
+ // Releases the new microphone itself if the swap fails.
3295
+ await this.adoptAudioTrack(producer, track);
3296
+ }
3297
+ catch (err) {
3298
+ if (producer.closed || this.micProducer() !== producer)
3299
+ throw new Error('switchDevice: the microphone was unpublished meanwhile');
3300
+ throw err;
3301
+ }
3166
3302
  this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
3167
3303
  });
3168
3304
  }
3169
- /** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
3305
+ /**
3306
+ * Put `track` (one the SDK captured) on the microphone producer and in the client's own stream; the replaced track is
3307
+ * stopped. A swap that fails releases `track` and the sound made from it, and with a processor puts the microphone
3308
+ * still on air back through it: given the new track, the processor dropped that sound (BR-10, BR-11).
3309
+ */
3170
3310
  async adoptAudioTrack(producer, track) {
3171
3311
  const previous = this.micRaw ?? producer.track;
3172
- const sent = await this.processMic(track);
3173
- await producer.replaceTrack({ track: sent });
3312
+ const previousRaw = this.micRaw;
3313
+ let sent = track;
3314
+ try {
3315
+ sent = await this.processMic(track);
3316
+ await producer.replaceTrack({ track: sent });
3317
+ }
3318
+ catch (err) {
3319
+ if (sent !== track)
3320
+ sent.stop();
3321
+ track.stop();
3322
+ await this.restoreMic(producer, previousRaw);
3323
+ throw err;
3324
+ }
3174
3325
  this.micRaw = sent !== track ? track : null;
3175
3326
  const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
3176
3327
  this.localStream = new MediaStream([track, ...video]);
@@ -3179,6 +3330,25 @@ class StreamClient {
3179
3330
  previous.stop();
3180
3331
  this.watchTrackEnded(producer);
3181
3332
  }
3333
+ /** After a failed microphone swap with a processor: send the microphone on air through it again (it dropped that sound). */
3334
+ async restoreMic(producer, raw) {
3335
+ const current = () => !producer.closed && this.micProducer() === producer;
3336
+ if (!this.audioProcessor || !raw || raw.readyState === 'ended' || !current())
3337
+ return;
3338
+ try {
3339
+ const again = await this.processMic(raw);
3340
+ if (!current()) {
3341
+ if (again !== raw)
3342
+ again.stop();
3343
+ return;
3344
+ }
3345
+ await producer.replaceTrack({ track: again });
3346
+ this.micRaw = again !== raw ? raw : null;
3347
+ }
3348
+ catch (err) {
3349
+ this.emit('error', new Error(`the microphone could not be put back after a failed switch: ${err?.message ?? err}`));
3350
+ }
3351
+ }
3182
3352
  /** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
3183
3353
  watchTrackEnded(producer) {
3184
3354
  // The device's track, not the processed one a processor sends: that is the one a device ends.