@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/esm/index.js CHANGED
@@ -252,6 +252,14 @@ export class StreamClient {
252
252
  screenProducerIds = new Set();
253
253
  remoteVideoLayers = new Map();
254
254
  receiveQualities = new Map();
255
+ /**
256
+ * BR-09 (the Flutter SDK's V092-02): per video, the choice the server last accepted, and a revision every change
257
+ * bumps. A choice is stored before its request (what is asked for is read from it), so a request that fails goes
258
+ * back to the last accepted choice -- not merely the one before it, which may itself be waiting or refused -- and
259
+ * only the latest change may go back, so a late failure never undoes a newer choice. Replies come in request order.
260
+ */
261
+ receiveAccepted = new Map();
262
+ receiveRevision = new Map();
255
263
  cameraFrameRate = 30;
256
264
  frameRateFallback = false;
257
265
  frameRateLowSamples = 0;
@@ -625,10 +633,9 @@ export class StreamClient {
625
633
  // A video that ended while this client was away sends it no
626
634
  // producerClosed: forget the layers asked for it.
627
635
  const present = new Set(result.producers.map((p) => p.producerId));
628
- for (const producerId of this.preferredLayers.keys()) {
636
+ for (const producerId of new Set([...this.preferredLayers.keys(), ...this.receiveAccepted.keys()])) {
629
637
  if (!present.has(producerId)) {
630
- this.preferredLayers.delete(producerId);
631
- this.receiveQualities.delete(producerId);
638
+ this.forgetReceiveChoice(producerId);
632
639
  this.remoteVideoLayers.delete(producerId);
633
640
  }
634
641
  }
@@ -848,6 +855,8 @@ export class StreamClient {
848
855
  this.teardownMedia();
849
856
  this.preferredLayers.clear();
850
857
  this.receiveQualities.clear();
858
+ this.receiveAccepted.clear();
859
+ this.receiveRevision.clear();
851
860
  this.remoteVideoLayers.clear();
852
861
  this.releaseScreen();
853
862
  this.music?.close();
@@ -1134,8 +1143,7 @@ export class StreamClient {
1134
1143
  case 'producerClosed': {
1135
1144
  const { producerId, identity } = data;
1136
1145
  this.subscriptions.delete(producerId);
1137
- this.preferredLayers.delete(producerId);
1138
- this.receiveQualities.delete(producerId);
1146
+ this.forgetReceiveChoice(producerId);
1139
1147
  this.remoteVideoLayers.delete(producerId);
1140
1148
  this.skippedVideos.delete(producerId);
1141
1149
  this.adaptivePaused.delete(producerId);
@@ -1728,7 +1736,10 @@ export class StreamClient {
1728
1736
  const shown = current?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
1729
1737
  const previousSource = shown ? this.cameraSource(shown) : null;
1730
1738
  const before = this.camOut;
1739
+ const beforeRaw = this.camRaw;
1740
+ const wasPaused = current?.paused ?? false;
1731
1741
  const sent = await this.processCamera(track);
1742
+ let swapped = false;
1732
1743
  try {
1733
1744
  if (!current) {
1734
1745
  // The republish reads the camera's state off the track it sends, and
@@ -1742,8 +1753,10 @@ export class StreamClient {
1742
1753
  }
1743
1754
  }
1744
1755
  this.adoptVideoTrack(sent);
1745
- if (!current || current.track === sent)
1756
+ if (!current || current.track === sent) {
1757
+ swapped = true;
1746
1758
  return;
1759
+ }
1747
1760
  const previous = current.track;
1748
1761
  const paused = current.paused;
1749
1762
  try {
@@ -1754,11 +1767,13 @@ export class StreamClient {
1754
1767
  if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
1755
1768
  previousSize?.width === width && previousSize?.height === height) {
1756
1769
  await current.replaceTrack({ track: sent });
1770
+ swapped = true;
1757
1771
  this.watchTrackEnded(current);
1758
1772
  await this.applyLayerBitrates(current, next);
1759
1773
  return;
1760
1774
  }
1761
1775
  await this.reproduceCamera(current, sent);
1776
+ swapped = true;
1762
1777
  }
1763
1778
  finally {
1764
1779
  // Pausing switched the old track off (mediasoup-client pauses that
@@ -1769,13 +1784,57 @@ export class StreamClient {
1769
1784
  previous.enabled = true;
1770
1785
  }
1771
1786
  }
1787
+ catch (err) {
1788
+ if (!swapped) {
1789
+ await this.undoCameraSwap({ producer: current ?? null, given: track, made: sent, shown, source: previousSource, raw: beforeRaw, out: before, paused: wasPaused });
1790
+ }
1791
+ throw err;
1792
+ }
1772
1793
  finally {
1773
- // The processor's previous picture is no longer sent or shown.
1774
- if (before && before !== this.camOut)
1794
+ // The processor's previous picture is no longer sent or shown -- once the new one is.
1795
+ if (swapped && before && before !== this.camOut)
1775
1796
  before.stop();
1776
1797
  }
1777
1798
  });
1778
1799
  }
1800
+ /**
1801
+ * A camera swap that failed (BR-12; the Flutter SDK's V090-02). What is sent is still the old picture -- or nothing,
1802
+ * when the producer was replaced and the new one failed. The client's stream goes back to the old picture, so the
1803
+ * app's preview and a reconnect never use a track that did not go on air. With a processor, the old camera is put
1804
+ * back through it if it dropped the old picture when given the new track. The picture made for the new track is
1805
+ * released; the new track itself is the caller's.
1806
+ */
1807
+ async undoCameraSwap(undo) {
1808
+ const { producer, given, made, shown, source, raw, out, paused } = undo;
1809
+ if (made !== given)
1810
+ made.stop();
1811
+ const sending = () => producer !== null && !producer.closed && this.cameraProducer() === producer;
1812
+ let picture = shown;
1813
+ if (shown && shown.readyState === 'ended' && source && source !== shown && source.readyState !== 'ended') {
1814
+ // The processor dropped the picture on air: make it again from the old camera, and send that.
1815
+ picture = await this.processCamera(source);
1816
+ if (sending() && producer && producer.track !== picture) {
1817
+ try {
1818
+ await producer.replaceTrack({ track: picture });
1819
+ this.watchTrackEnded(producer);
1820
+ }
1821
+ catch (err) {
1822
+ this.emit('error', new Error(`the camera could not be put back after a failed switch: ${err?.message ?? err}`));
1823
+ }
1824
+ }
1825
+ }
1826
+ else {
1827
+ // The picture on air is as it was, and so is what stands behind it.
1828
+ this.camRaw = raw;
1829
+ this.camOut = out;
1830
+ }
1831
+ if (!picture || picture.readyState === 'ended')
1832
+ return;
1833
+ // With nothing sending it, a republish reads the camera's state off the track: a paused camera stays paused.
1834
+ if (!sending())
1835
+ picture.enabled = !paused;
1836
+ this.adoptVideoTrack(picture);
1837
+ }
1779
1838
  /**
1780
1839
  * Give a camera producer whose track was swapped in place the bitrates of
1781
1840
  * its new picture. The layout matched, but a bigger picture at the same
@@ -1849,8 +1908,10 @@ export class StreamClient {
1849
1908
  * 1280×720), and a layer above the biggest means the biggest. Leave
1850
1909
  * `temporalLayer` out to keep the full frame rate. It is the most this
1851
1910
  * client is sent: a connection that cannot carry it still gets less. The
1852
- * choice is kept across reconnects and forgotten when the track ends.
1853
- * Rejects for a producer whose video this client is not receiving.
1911
+ * choice is kept across reconnects and forgotten when the track ends; a
1912
+ * call that fails (refused, or the connection lost before the answer)
1913
+ * leaves the previous choice in place. Rejects for a producer whose video
1914
+ * this client is not receiving.
1854
1915
  */
1855
1916
  async setPreferredLayers(producerId, spatialLayer, temporalLayer) {
1856
1917
  if (!isLayer(spatialLayer) || (temporalLayer !== undefined && !isLayer(temporalLayer))) {
@@ -1861,12 +1922,19 @@ export class StreamClient {
1861
1922
  throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
1862
1923
  }
1863
1924
  const layers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
1864
- this.receiveQualities.delete(producerId);
1865
- // Kept first: a reconnect that interrupts the request still applies it.
1866
- this.preferredLayers.set(producerId, layers);
1925
+ // Stored before the request, which reads it; a request that fails (refused, or the connection lost before the
1926
+ // answer) leaves the last accepted choice in place (BR-09).
1927
+ const change = this.changeReceive(producerId, { quality: undefined, layers });
1867
1928
  // Shown through attachVideo: the lower of this and the size it is shown at.
1868
1929
  const adaptive = this.adaptive.current(producerId);
1869
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
1930
+ try {
1931
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
1932
+ }
1933
+ catch (err) {
1934
+ change.failed();
1935
+ throw err;
1936
+ }
1937
+ change.accepted();
1870
1938
  }
1871
1939
  async collectVideoStats(baseline) {
1872
1940
  const producer = this.cameraProducer();
@@ -1885,7 +1953,14 @@ export class StreamClient {
1885
1953
  pruneBaseline(baseline, (key) => key.startsWith(prefix));
1886
1954
  return parseSendStats(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
1887
1955
  }
1888
- /** Friendly receive ceiling using this producer's real layer sizes; auto restores adaptation. */
1956
+ /**
1957
+ * The quality this viewer gets of one remote video. '1080p', '720p' and '480p' pick the publisher's biggest layer
1958
+ * at or below that size (a 720p camera cannot give 1080p), and the viewer gets it while the video is shown, however
1959
+ * big it is on screen: someone who picks 1080p expects 1080p, not the smaller copy that fits the tile. A connection
1960
+ * that cannot carry it still gets less until it recovers, and a video shown through attachVideo is still paused
1961
+ * while nobody can see it. 'auto' (the default) goes back to the size it is shown at, or the server's choice.
1962
+ * Kept across reconnects; a call that fails leaves the previous choice in place.
1963
+ */
1889
1964
  async setReceiveQuality(producerId, quality) {
1890
1965
  if (!['auto', '1080p', '720p', '480p'].includes(quality))
1891
1966
  throw new RangeError('setReceiveQuality: unsupported quality');
@@ -1903,10 +1978,52 @@ export class StreamClient {
1903
1978
  throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto');
1904
1979
  }
1905
1980
  }
1906
- this.receiveQualities.set(producerId, quality);
1907
- this.preferredLayers.set(producerId, layers);
1981
+ const change = this.changeReceive(producerId, { quality, layers });
1908
1982
  const adaptive = this.adaptive.current(producerId);
1909
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
1983
+ try {
1984
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
1985
+ }
1986
+ catch (err) {
1987
+ change.failed();
1988
+ throw err;
1989
+ }
1990
+ change.accepted();
1991
+ }
1992
+ /** Store `choice` for one video now, before its request; the caller reports how the request ended (BR-09). */
1993
+ changeReceive(producerId, choice) {
1994
+ if (!this.receiveAccepted.has(producerId)) {
1995
+ this.receiveAccepted.set(producerId, { quality: this.receiveQualities.get(producerId), layers: this.preferredLayers.get(producerId) });
1996
+ }
1997
+ const revision = (this.receiveRevision.get(producerId) ?? 0) + 1;
1998
+ this.receiveRevision.set(producerId, revision);
1999
+ this.setReceiveChoice(producerId, choice);
2000
+ return {
2001
+ // Forgotten meanwhile (the video ended, the session closed): a late answer changes nothing.
2002
+ accepted: () => { if (this.receiveRevision.has(producerId))
2003
+ this.receiveAccepted.set(producerId, choice); },
2004
+ failed: () => {
2005
+ const back = this.receiveAccepted.get(producerId);
2006
+ if (this.receiveRevision.get(producerId) === revision && back)
2007
+ this.setReceiveChoice(producerId, back);
2008
+ },
2009
+ };
2010
+ }
2011
+ setReceiveChoice(producerId, choice) {
2012
+ if (choice.quality === undefined)
2013
+ this.receiveQualities.delete(producerId);
2014
+ else
2015
+ this.receiveQualities.set(producerId, choice.quality);
2016
+ if (choice.layers === undefined)
2017
+ this.preferredLayers.delete(producerId);
2018
+ else
2019
+ this.preferredLayers.set(producerId, choice.layers);
2020
+ }
2021
+ /** The video ended: its choices go with it. */
2022
+ forgetReceiveChoice(producerId) {
2023
+ this.preferredLayers.delete(producerId);
2024
+ this.receiveQualities.delete(producerId);
2025
+ this.receiveAccepted.delete(producerId);
2026
+ this.receiveRevision.delete(producerId);
1910
2027
  }
1911
2028
  cameraProducer() {
1912
2029
  for (const p of this.producers.values()) {
@@ -2674,26 +2791,35 @@ export class StreamClient {
2674
2791
  * Adaptive stream (like LiveKit's adaptiveStream and Tencent TRTC's small
2675
2792
  * stream with receiveWhenViewVisible): tell the SDK which element shows a
2676
2793
  * remote video. It then receives the smallest layer that fills that element
2677
- * sharply (its size on screen × devicePixelRatio, at most 2) and pauses the
2794
+ * without stretching it (its size on screen × devicePixelRatio, at most 2) and pauses the
2678
2795
  * video at the server while the element is not visible (scrolled away,
2679
2796
  * `display: none`, or the tab in the background), so a grid of small tiles
2680
2797
  * stops decoding full-size video. Attach the same video to several elements
2681
2798
  * and the largest visible one decides. Returns the function that detaches
2682
- * it; detach when the element goes away. A ceiling set with
2683
- * `setReceiveQuality` or `setPreferredLayers` still applies. Works with any
2684
- * server; one without `pauseConsumer` gets the smallest layer instead of a
2685
- * pause.
2799
+ * it; detach when the element goes away. A quality chosen with
2800
+ * `setReceiveQuality` replaces the size it is shown at (the video is still
2801
+ * paused while hidden), and a ceiling set with `setPreferredLayers` still
2802
+ * applies. Works with any server; one without `pauseConsumer` gets the
2803
+ * smallest layer instead of a pause.
2686
2804
  */
2687
2805
  attachVideo(producerId, element) {
2688
2806
  return this.adaptive.attach(producerId, element);
2689
2807
  }
2690
- /** The layers to ask for: the app's ceiling (setReceiveQuality / setPreferredLayers) and adaptive stream's, the lower. */
2808
+ /**
2809
+ * The layers to ask for. A quality chosen with setReceiveQuality is the layer, whatever the size on screen (owner,
2810
+ * 2026-10-20: viewers who picked 1080p got 540p, because it was only a ceiling). A ceiling set with
2811
+ * setPreferredLayers and adaptive stream's size: the lower of the two.
2812
+ */
2691
2813
  effectiveLayers(producerId, want) {
2814
+ // Hidden: paused at the server, or held at the smallest layer where it cannot pause. A chosen quality or a ceiling
2815
+ // applies once the video is shown again, never while nobody can see it (BR-08; the Flutter SDK's V092-01).
2816
+ if (want && !want.visible)
2817
+ return { spatialLayer: 0, temporalLayer: 0 };
2692
2818
  let manual = this.preferredLayers.get(producerId);
2693
2819
  const quality = this.receiveQualities.get(producerId);
2694
2820
  if (quality && quality !== 'auto') {
2695
2821
  try {
2696
- manual = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) };
2822
+ return { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) };
2697
2823
  }
2698
2824
  catch {
2699
2825
  manual = undefined;
@@ -3082,7 +3208,9 @@ export class StreamClient {
3082
3208
  * captured with the constraints publish was given (size, frame rate, echo cancellation) and its track replaces the
3083
3209
  * published one: a camera of the same size swaps with no pause for viewers, a microphone always does. The track it
3084
3210
  * replaces is stopped, releasing that device; the client's own stream follows (`localStreamChanged`), and a
3085
- * reconnect captures the chosen device again. A camera or microphone that is switched off stays off.
3211
+ * reconnect captures the chosen device again. A camera or microphone that is switched off stays off. A swap that
3212
+ * fails releases the new device and keeps the old one on air, put back through a noise suppressor or video effects
3213
+ * if one is set.
3086
3214
  *
3087
3215
  * For a camera published as a canvas or another processed track, capture the new camera yourself and pass your
3088
3216
  * processed track to `replaceVideoTrack`; this rejects rather than replace your picture with the raw camera.
@@ -3129,15 +3257,38 @@ export class StreamClient {
3129
3257
  track.stop();
3130
3258
  throw new Error('switchDevice: the microphone was unpublished meanwhile');
3131
3259
  }
3132
- await this.adoptAudioTrack(producer, track);
3260
+ try {
3261
+ // Releases the new microphone itself if the swap fails.
3262
+ await this.adoptAudioTrack(producer, track);
3263
+ }
3264
+ catch (err) {
3265
+ if (producer.closed || this.micProducer() !== producer)
3266
+ throw new Error('switchDevice: the microphone was unpublished meanwhile');
3267
+ throw err;
3268
+ }
3133
3269
  this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
3134
3270
  });
3135
3271
  }
3136
- /** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
3272
+ /**
3273
+ * Put `track` (one the SDK captured) on the microphone producer and in the client's own stream; the replaced track is
3274
+ * stopped. A swap that fails releases `track` and the sound made from it, and with a processor puts the microphone
3275
+ * still on air back through it: given the new track, the processor dropped that sound (BR-10, BR-11).
3276
+ */
3137
3277
  async adoptAudioTrack(producer, track) {
3138
3278
  const previous = this.micRaw ?? producer.track;
3139
- const sent = await this.processMic(track);
3140
- await producer.replaceTrack({ track: sent });
3279
+ const previousRaw = this.micRaw;
3280
+ let sent = track;
3281
+ try {
3282
+ sent = await this.processMic(track);
3283
+ await producer.replaceTrack({ track: sent });
3284
+ }
3285
+ catch (err) {
3286
+ if (sent !== track)
3287
+ sent.stop();
3288
+ track.stop();
3289
+ await this.restoreMic(producer, previousRaw);
3290
+ throw err;
3291
+ }
3141
3292
  this.micRaw = sent !== track ? track : null;
3142
3293
  const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
3143
3294
  this.localStream = new MediaStream([track, ...video]);
@@ -3146,6 +3297,25 @@ export class StreamClient {
3146
3297
  previous.stop();
3147
3298
  this.watchTrackEnded(producer);
3148
3299
  }
3300
+ /** After a failed microphone swap with a processor: send the microphone on air through it again (it dropped that sound). */
3301
+ async restoreMic(producer, raw) {
3302
+ const current = () => !producer.closed && this.micProducer() === producer;
3303
+ if (!this.audioProcessor || !raw || raw.readyState === 'ended' || !current())
3304
+ return;
3305
+ try {
3306
+ const again = await this.processMic(raw);
3307
+ if (!current()) {
3308
+ if (again !== raw)
3309
+ again.stop();
3310
+ return;
3311
+ }
3312
+ await producer.replaceTrack({ track: again });
3313
+ this.micRaw = again !== raw ? raw : null;
3314
+ }
3315
+ catch (err) {
3316
+ this.emit('error', new Error(`the microphone could not be put back after a failed switch: ${err?.message ?? err}`));
3317
+ }
3318
+ }
3149
3319
  /** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
3150
3320
  watchTrackEnded(producer) {
3151
3321
  // The device's track, not the processed one a processor sends: that is the one a device ends.