@stream-io/video-client 1.61.1 → 1.62.0

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/index.browser.es.js +525 -178
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +525 -178
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +525 -178
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +21 -1
  9. package/dist/src/coordinator/connection/types.d.ts +1 -1
  10. package/dist/src/devices/DeviceManager.d.ts +6 -0
  11. package/dist/src/devices/DeviceManagerState.d.ts +17 -0
  12. package/dist/src/devices/MicrophoneManager.d.ts +11 -0
  13. package/dist/src/devices/devices.d.ts +5 -0
  14. package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +0 -27
  15. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +7 -2
  16. package/dist/src/helpers/DynascaleManager.d.ts +15 -0
  17. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +18 -0
  18. package/dist/src/helpers/mediaInterruptions.d.ts +14 -0
  19. package/dist/src/reporting/ClientEventReporter.d.ts +1 -1
  20. package/dist/src/ringing/reconcileRingState.d.ts +8 -7
  21. package/dist/src/rtc/BasePeerConnection.d.ts +13 -1
  22. package/dist/src/rtc/types.d.ts +7 -0
  23. package/dist/src/stats/SfuStatsReporter.d.ts +1 -13
  24. package/dist/src/stats/rtc/StatsTracer.d.ts +6 -6
  25. package/dist/src/stats/rtc/types.d.ts +0 -11
  26. package/package.json +1 -1
  27. package/src/Call.ts +110 -9
  28. package/src/StreamVideoClient.ts +8 -0
  29. package/src/__tests__/StreamVideoClient.rewatch.test.ts +136 -0
  30. package/src/coordinator/connection/types.ts +1 -1
  31. package/src/devices/DeviceManager.ts +26 -1
  32. package/src/devices/DeviceManagerState.ts +24 -0
  33. package/src/devices/MicrophoneManager.ts +79 -17
  34. package/src/devices/SpeakerManager.ts +1 -1
  35. package/src/devices/__tests__/CameraManager.test.ts +50 -1
  36. package/src/devices/__tests__/DeviceManager.test.ts +29 -0
  37. package/src/devices/__tests__/MicrophoneManager.test.ts +235 -0
  38. package/src/devices/__tests__/SpeakerManager.test.ts +27 -0
  39. package/src/devices/__tests__/devices.test.ts +10 -14
  40. package/src/devices/__tests__/mediaStreamTestHelpers.ts +11 -6
  41. package/src/devices/__tests__/mocks.ts +1 -0
  42. package/src/devices/devices.ts +7 -1
  43. package/src/gen/video/sfu/signal_rpc/signal.ts +0 -53
  44. package/src/helpers/AudioBindingsWatchdog.ts +91 -35
  45. package/src/helpers/DynascaleManager.ts +86 -11
  46. package/src/helpers/MediaPlaybackWatchdog.ts +55 -6
  47. package/src/helpers/__tests__/DynascaleManager.test.ts +253 -13
  48. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +140 -0
  49. package/src/helpers/__tests__/mediaInterruptions.test.ts +131 -0
  50. package/src/helpers/mediaInterruptions.ts +37 -0
  51. package/src/reporting/ClientEventReporter.ts +1 -1
  52. package/src/ringing/RingStatePoller.ts +1 -1
  53. package/src/ringing/__tests__/RingStatePoller.test.ts +1 -1
  54. package/src/ringing/__tests__/reconcileRingState.test.ts +33 -0
  55. package/src/ringing/reconcileRingState.ts +18 -10
  56. package/src/rtc/BasePeerConnection.ts +47 -9
  57. package/src/rtc/__tests__/Call.reconnect.test.ts +474 -0
  58. package/src/rtc/types.ts +8 -0
  59. package/src/stats/SfuStatsReporter.ts +19 -93
  60. package/src/stats/__tests__/SfuStatsReporter.test.ts +2 -18
  61. package/src/stats/rtc/StatsTracer.ts +6 -6
  62. package/src/stats/rtc/types.ts +0 -13
package/dist/index.es.js CHANGED
@@ -2666,18 +2666,6 @@ class SendStatsRequest$Type extends MessageType {
2666
2666
  constructor() {
2667
2667
  super('stream.video.sfu.signal.SendStatsRequest', [
2668
2668
  { no: 1, name: 'session_id', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
2669
- {
2670
- no: 2,
2671
- name: 'subscriber_stats',
2672
- kind: 'scalar',
2673
- T: 9 /*ScalarType.STRING*/,
2674
- },
2675
- {
2676
- no: 3,
2677
- name: 'publisher_stats',
2678
- kind: 'scalar',
2679
- T: 9 /*ScalarType.STRING*/,
2680
- },
2681
2669
  {
2682
2670
  no: 4,
2683
2671
  name: 'webrtc_version',
@@ -2691,8 +2679,6 @@ class SendStatsRequest$Type extends MessageType {
2691
2679
  kind: 'scalar',
2692
2680
  T: 9 /*ScalarType.STRING*/,
2693
2681
  },
2694
- { no: 7, name: 'audio_devices', kind: 'message', T: () => InputDevices },
2695
- { no: 8, name: 'video_devices', kind: 'message', T: () => InputDevices },
2696
2682
  {
2697
2683
  no: 9,
2698
2684
  name: 'android',
@@ -2709,18 +2695,6 @@ class SendStatsRequest$Type extends MessageType {
2709
2695
  },
2710
2696
  { no: 11, name: 'telemetry', kind: 'message', T: () => Telemetry },
2711
2697
  { no: 12, name: 'rtmp', kind: 'message', T: () => RTMPIngress },
2712
- {
2713
- no: 13,
2714
- name: 'subscriber_rtc_stats',
2715
- kind: 'scalar',
2716
- T: 9 /*ScalarType.STRING*/,
2717
- },
2718
- {
2719
- no: 14,
2720
- name: 'publisher_rtc_stats',
2721
- kind: 'scalar',
2722
- T: 9 /*ScalarType.STRING*/,
2723
- },
2724
2698
  { no: 15, name: 'rtc_stats', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
2725
2699
  {
2726
2700
  no: 16,
@@ -5082,7 +5056,7 @@ const toIceCandidate = (iceTrickle) => {
5082
5056
  }
5083
5057
  };
5084
5058
 
5085
- const version = "1.61.1";
5059
+ const version = "1.62.0";
5086
5060
  const [major, minor, patch] = version.split('.');
5087
5061
  let sdkInfo = {
5088
5062
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -7810,30 +7784,21 @@ class SfuStatsReporter {
7810
7784
  sfuClient;
7811
7785
  subscriber;
7812
7786
  publisher;
7813
- microphone;
7814
- camera;
7815
- state;
7816
7787
  tracer;
7817
7788
  unifiedSessionId;
7818
7789
  intervalId;
7819
7790
  timeoutId;
7820
7791
  reportCount = 0;
7821
- unsubscribeDevicePermissionsSubscription;
7822
- unsubscribeListDevicesSubscription;
7823
7792
  sdkName;
7824
7793
  sdkVersion;
7825
7794
  webRTCVersion;
7826
- inputDevices = new Map();
7827
7795
  statsConcurrencyTag = Symbol('sfuStatsReporter');
7828
7796
  isStopped = false;
7829
- constructor(sfuClient, { options, clientDetails, subscriber, publisher, microphone, camera, state, tracer, unifiedSessionId, }) {
7797
+ constructor(sfuClient, { options, clientDetails, subscriber, publisher, tracer, unifiedSessionId, }) {
7830
7798
  this.sfuClient = sfuClient;
7831
7799
  this.options = options;
7832
7800
  this.subscriber = subscriber;
7833
7801
  this.publisher = publisher;
7834
- this.microphone = microphone;
7835
- this.camera = camera;
7836
- this.state = state;
7837
7802
  this.tracer = tracer;
7838
7803
  this.unifiedSessionId = unifiedSessionId;
7839
7804
  const { sdk, browser } = clientDetails;
@@ -7847,35 +7812,6 @@ class SfuStatsReporter {
7847
7812
  `${browser?.name || ''}-${browser?.version || ''}` ||
7848
7813
  'N/A';
7849
7814
  }
7850
- observeDevice = (device, kind) => {
7851
- const { browserPermissionState$ } = device.state;
7852
- this.unsubscribeDevicePermissionsSubscription?.();
7853
- this.unsubscribeDevicePermissionsSubscription = createSubscription(combineLatest([browserPermissionState$, this.state.ownCapabilities$]), ([browserPermissionState, ownCapabilities]) => {
7854
- // cleanup the previous listDevices() subscription in case
7855
- // permissions or capabilities have changed.
7856
- // we will subscribe again if everything is in order.
7857
- this.unsubscribeListDevicesSubscription?.();
7858
- const hasCapability = kind === 'mic'
7859
- ? ownCapabilities.includes(OwnCapability.SEND_AUDIO)
7860
- : ownCapabilities.includes(OwnCapability.SEND_VIDEO);
7861
- if (browserPermissionState !== 'granted' || !hasCapability) {
7862
- this.inputDevices.set(kind, {
7863
- currentDevice: '',
7864
- availableDevices: [],
7865
- isPermitted: false,
7866
- });
7867
- return;
7868
- }
7869
- this.unsubscribeListDevicesSubscription = createSubscription(combineLatest([device.listDevices(), device.state.selectedDevice$]), ([devices, deviceId]) => {
7870
- const selected = devices.find((d) => d.deviceId === deviceId);
7871
- this.inputDevices.set(kind, {
7872
- currentDevice: selected?.label || deviceId || '',
7873
- availableDevices: devices.map((d) => d.label),
7874
- isPermitted: true,
7875
- });
7876
- });
7877
- });
7878
- };
7879
7815
  sendConnectionTime = (connectionTimeSeconds) => {
7880
7816
  this.sendTelemetryData({
7881
7817
  data: {
@@ -7910,7 +7846,7 @@ class SfuStatsReporter {
7910
7846
  this.subscriber.stats.takeSample(),
7911
7847
  this.publisher?.stats.takeSample(),
7912
7848
  ]);
7913
- send = (subscriberStats, publisherStats, telemetry) => {
7849
+ send = (subscriberPerfStats, publisherPerfStats, telemetry) => {
7914
7850
  // serialize sends so overlapping ones can't race on the trace buffers or
7915
7851
  // deliver an older delta after a newer one already succeeded. Not gated by
7916
7852
  // `isStopped`: an explicit final flush must still deliver after stop().
@@ -7928,8 +7864,8 @@ class SfuStatsReporter {
7928
7864
  this.subscriber.stats.clearPendingDeltas();
7929
7865
  }
7930
7866
  let pubPending = [];
7931
- if (pubTracer && publisherStats) {
7932
- pubPending = this.publisher?.stats.getPendingDeltas() ?? [];
7867
+ if (pubTracer && this.publisher) {
7868
+ pubPending = this.publisher.stats.getPendingDeltas();
7933
7869
  }
7934
7870
  else {
7935
7871
  this.publisher?.stats.clearPendingDeltas();
@@ -7951,17 +7887,9 @@ class SfuStatsReporter {
7951
7887
  sdk: this.sdkName,
7952
7888
  sdkVersion: this.sdkVersion,
7953
7889
  webrtcVersion: this.webRTCVersion,
7954
- subscriberStats: JSON.stringify(flatten(subscriberStats.stats)),
7955
- publisherStats: publisherStats
7956
- ? JSON.stringify(flatten(publisherStats.stats))
7957
- : '[]',
7958
- subscriberRtcStats: '',
7959
- publisherRtcStats: '',
7960
7890
  rtcStats: JSON.stringify(traces),
7961
- encodeStats: publisherStats?.performanceStats ?? [],
7962
- decodeStats: subscriberStats.performanceStats,
7963
- audioDevices: this.inputDevices.get('mic'),
7964
- videoDevices: this.inputDevices.get('camera'),
7891
+ encodeStats: publisherPerfStats ?? [],
7892
+ decodeStats: subscriberPerfStats,
7965
7893
  unifiedSessionId: this.unifiedSessionId,
7966
7894
  deviceState: getDeviceState(),
7967
7895
  telemetry,
@@ -7975,8 +7903,8 @@ class SfuStatsReporter {
7975
7903
  // delivery confirmed: advance the delivery baseline for each chain.
7976
7904
  if (subTracer)
7977
7905
  this.subscriber.stats.commitDeltas(subPending);
7978
- if (pubTracer && publisherStats) {
7979
- this.publisher?.stats.commitDeltas(pubPending);
7906
+ if (pubTracer && this.publisher) {
7907
+ this.publisher.stats.commitDeltas(pubPending);
7980
7908
  }
7981
7909
  }
7982
7910
  catch (err) {
@@ -7998,8 +7926,8 @@ class SfuStatsReporter {
7998
7926
  run = async (telemetry) => {
7999
7927
  if (this.isStopped)
8000
7928
  return;
8001
- const [subscriberStats, publisherStats] = await this.sample();
8002
- await this.send(subscriberStats, publisherStats, telemetry);
7929
+ const [subscriberPerfStats, publisherPerfStats] = await this.sample();
7930
+ await this.send(subscriberPerfStats, publisherPerfStats, telemetry);
8003
7931
  };
8004
7932
  scheduleNextReport = () => {
8005
7933
  const intervals = [1500, 3000, 3000, 5000];
@@ -8020,8 +7948,6 @@ class SfuStatsReporter {
8020
7948
  start = () => {
8021
7949
  if (this.options.reporting_interval_ms <= 0)
8022
7950
  return;
8023
- this.observeDevice(this.microphone, 'mic');
8024
- this.observeDevice(this.camera, 'camera');
8025
7951
  this.isStopped = false;
8026
7952
  this.reportCount = 0;
8027
7953
  clearInterval(this.intervalId);
@@ -8030,11 +7956,6 @@ class SfuStatsReporter {
8030
7956
  };
8031
7957
  stop = () => {
8032
7958
  this.isStopped = true;
8033
- this.unsubscribeDevicePermissionsSubscription?.();
8034
- this.unsubscribeDevicePermissionsSubscription = undefined;
8035
- this.unsubscribeListDevicesSubscription?.();
8036
- this.unsubscribeListDevicesSubscription = undefined;
8037
- this.inputDevices.clear();
8038
7959
  clearInterval(this.intervalId);
8039
7960
  this.intervalId = undefined;
8040
7961
  clearTimeout(this.timeoutId);
@@ -8055,8 +7976,8 @@ class SfuStatsReporter {
8055
7976
  return;
8056
7977
  try {
8057
7978
  const [sample] = await timeboxed([this.sample()], 2000);
8058
- const [subscriberStats, publisherStats] = sample;
8059
- this.send(subscriberStats, publisherStats).catch((err) => {
7979
+ const [subscriberPerfStats, publisherPerfStats] = sample;
7980
+ this.send(subscriberPerfStats, publisherPerfStats).catch((err) => {
8060
7981
  this.logger.warn('Failed to flush report stats', err);
8061
7982
  });
8062
7983
  }
@@ -8183,9 +8104,9 @@ class StatsTracer {
8183
8104
  this.maxPendingDeltas = maxPendingDeltas;
8184
8105
  }
8185
8106
  /**
8186
- * Samples the RTCPeerConnection: returns the current stats report and the
8187
- * derived performance stats, and appends the delta-compressed sample to the
8188
- * un-acked delivery chain (retrieved via `getPendingDeltas`).
8107
+ * Samples the RTCPeerConnection: returns the derived performance stats and
8108
+ * appends the delta-compressed sample to the un-acked delivery chain
8109
+ * (retrieved via `getPendingDeltas`).
8189
8110
  *
8190
8111
  * @internal
8191
8112
  */
@@ -8210,7 +8131,7 @@ class StatsTracer {
8210
8131
  this.pendingDeltas.push({ delta, ts: now });
8211
8132
  this.frameTimeHistory = this.frameTimeHistory.slice(-2);
8212
8133
  this.fpsHistory = this.fpsHistory.slice(-2);
8213
- return { performanceStats, stats };
8134
+ return performanceStats;
8214
8135
  });
8215
8136
  };
8216
8137
  /**
@@ -8530,10 +8451,12 @@ class BasePeerConnection {
8530
8451
  sfuClient;
8531
8452
  onReconnectionNeeded;
8532
8453
  onIceConnected;
8454
+ onNoLocalCandidates;
8533
8455
  onPeerConnectionStateChange;
8534
8456
  onRemoteTrackUnmute;
8535
8457
  iceRestartDelay;
8536
8458
  iceHasEverConnected = false;
8459
+ localCandidatesGathered = 0;
8537
8460
  iceRestartTimeout;
8538
8461
  preConnectStuckTimeout;
8539
8462
  isIceRestarting = false;
@@ -8547,7 +8470,7 @@ class BasePeerConnection {
8547
8470
  /**
8548
8471
  * Constructs a new `BasePeerConnection` instance.
8549
8472
  */
8550
- constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
8473
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onNoLocalCandidates, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
8551
8474
  this.peerType = peerType;
8552
8475
  this.sfuClient = sfuClient;
8553
8476
  this.state = state;
@@ -8558,6 +8481,7 @@ class BasePeerConnection {
8558
8481
  this.tag = tag;
8559
8482
  this.onReconnectionNeeded = onReconnectionNeeded;
8560
8483
  this.onIceConnected = onIceConnected;
8484
+ this.onNoLocalCandidates = onNoLocalCandidates;
8561
8485
  this.onPeerConnectionStateChange = onPeerConnectionStateChange;
8562
8486
  this.onRemoteTrackUnmute = onRemoteTrackUnmute;
8563
8487
  this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
@@ -8598,6 +8522,7 @@ class BasePeerConnection {
8598
8522
  this.preConnectStuckTimeout = undefined;
8599
8523
  this.onReconnectionNeeded = undefined;
8600
8524
  this.onIceConnected = undefined;
8525
+ this.onNoLocalCandidates = undefined;
8601
8526
  this.onPeerConnectionStateChange = undefined;
8602
8527
  this.onRemoteTrackUnmute = undefined;
8603
8528
  this.isDisposed = true;
@@ -8701,6 +8626,17 @@ class BasePeerConnection {
8701
8626
  getTrackType = (trackId) => {
8702
8627
  return this.trackIdToTrackType.get(trackId);
8703
8628
  };
8629
+ /**
8630
+ * Returns `true` once ICE has reached `connected`/`completed` at least
8631
+ * once during the lifetime of this peer connection.
8632
+ */
8633
+ hasIceEverConnected = () => this.iceHasEverConnected;
8634
+ /**
8635
+ * Returns `true` when the latest ICE gathering round completed without
8636
+ * producing a single local candidate.
8637
+ */
8638
+ hasNoLocalCandidates = () => this.pc.iceGatheringState === 'complete' &&
8639
+ this.localCandidatesGathered === 0;
8704
8640
  /**
8705
8641
  * Checks if the `RTCPeerConnection` is healthy.
8706
8642
  * It checks the ICE connection state and the peer connection state.
@@ -8723,6 +8659,8 @@ class BasePeerConnection {
8723
8659
  this.logger.debug('null ice candidate');
8724
8660
  return;
8725
8661
  }
8662
+ if (candidate.candidate)
8663
+ this.localCandidatesGathered++;
8726
8664
  const iceCandidate = toJSON(candidate);
8727
8665
  this.sfuClient
8728
8666
  .iceTrickle({ peerType: this.peerType, iceCandidate })
@@ -8787,6 +8725,17 @@ class BasePeerConnection {
8787
8725
  }
8788
8726
  };
8789
8727
  handleConnectionStateUpdate = (state) => {
8728
+ // Fire `onIceConnected` exactly once per peer-connection lifetime, the
8729
+ // first time ICE reaches `connected`/`completed` end-to-end. Recorded
8730
+ // ahead of the guards below so a connection established while offline,
8731
+ // reconnecting or restarting ICE is not missed. Used by `Call` to reset
8732
+ // the unsupported-network failure counter only after WebRTC has actually
8733
+ // recovered, not merely on SFU join.
8734
+ if ((state === 'connected' || state === 'completed') &&
8735
+ !this.iceHasEverConnected) {
8736
+ this.iceHasEverConnected = true;
8737
+ this.onIceConnected?.(this.peerType);
8738
+ }
8790
8739
  const { callingState } = this.state;
8791
8740
  if (callingState === CallingState.OFFLINE)
8792
8741
  return;
@@ -8850,14 +8799,6 @@ class BasePeerConnection {
8850
8799
  break;
8851
8800
  case 'connected':
8852
8801
  case 'completed':
8853
- // Fire `onIceConnected` exactly once per peer-connection lifetime —
8854
- // the first time ICE reaches `connected`/`completed` end-to-end.
8855
- // Used by `Call` to reset the unsupported-network failure counter
8856
- // only after WebRTC has actually recovered, not merely on SFU join.
8857
- if (!this.iceHasEverConnected) {
8858
- this.iceHasEverConnected = true;
8859
- this.onIceConnected?.(this.peerType);
8860
- }
8861
8802
  // clear any scheduled restartICE since the connection is healthy
8862
8803
  if (this.iceRestartTimeout) {
8863
8804
  this.logger.info('connected connection, canceling restartICE');
@@ -8883,7 +8824,17 @@ class BasePeerConnection {
8883
8824
  * Handles the ICE gathering state change event.
8884
8825
  */
8885
8826
  onIceGatherChange = () => {
8886
- this.logger.debug(`ICE Gathering State`, this.pc.iceGatheringState);
8827
+ const state = this.pc.iceGatheringState;
8828
+ this.logger.debug(`ICE Gathering State`, state);
8829
+ if (state === 'gathering') {
8830
+ this.localCandidatesGathered = 0;
8831
+ }
8832
+ else if (state === 'complete' &&
8833
+ this.localCandidatesGathered === 0 &&
8834
+ !this.iceHasEverConnected) {
8835
+ this.logger.info('ICE gathering completed without local candidates');
8836
+ this.onNoLocalCandidates?.(this.peerType);
8837
+ }
8887
8838
  };
8888
8839
  /**
8889
8840
  * Handles the signaling state change event.
@@ -10759,15 +10710,16 @@ class StreamSfuClient {
10759
10710
  /**
10760
10711
  * Decides what a ringing call should do next, based on the current call state.
10761
10712
  *
10762
- * The `call.accepted`, `call.rejected` and `call.missed` handlers and the ring
10763
- * state poller both run this. They differ only in how the state got there: the
10764
- * handlers rely on `CallState.updateFromEvent`, which runs before them, and the
10765
- * poller applies the polled ring state itself.
10713
+ * The `call.accepted` and `call.rejected` handlers, the ring state poller and
10714
+ * the rewatch after a WS reconnect all run this. They differ only in how the
10715
+ * state got there: the handlers rely on `CallState.updateFromEvent`, which runs
10716
+ * before them, while the poller applies the polled ring state itself and the
10717
+ * rewatch applies the queried call state.
10766
10718
  *
10767
10719
  * @param call the call to reconcile.
10768
- * @param joinSource which of the two triggered this run, reported on the
10769
- * caller's join: `ring-ws` for the event handlers, `ring-poll-api` for the
10770
- * poller.
10720
+ * @param joinSource what triggered this run, reported on the caller's join:
10721
+ * `ring-ws` for the event handlers, `ring-poll-api` for the poller and the
10722
+ * rewatch (both come from an API response).
10771
10723
  * @returns whether the ring reached a terminal state. A failed join is not
10772
10724
  * terminal: the caller should keep trying while the ring is open.
10773
10725
  */
@@ -10822,10 +10774,16 @@ const reconcileAsCaller = async (call, joinSource) => {
10822
10774
  return false;
10823
10775
  };
10824
10776
  // the current user's own accept or reject, on this or another device, is
10825
- // handled by `resolveOwnRingOutcome`, and `call.ended` by `watchCallEnded`.
10777
+ // handled by `resolveOwnRingOutcome`, and a live `call.ended` by
10778
+ // `watchCallEnded`. An end missed while offline is handled here.
10826
10779
  const reconcileAsCallee = async (call) => {
10827
- const createdById = call.state.createdBy?.id;
10828
- const rejectedBy = call.state.session?.rejected_by ?? {};
10780
+ const { session, endedAt, createdBy } = call.state;
10781
+ if (endedAt || session?.ended_at) {
10782
+ call.logger.info('ring: the call has ended, leaving');
10783
+ return leave(call, { reason: 'ended', message: 'ring: call ended' });
10784
+ }
10785
+ const createdById = createdBy?.id;
10786
+ const rejectedBy = session?.rejected_by ?? {};
10829
10787
  if (createdById && rejectedBy[createdById]) {
10830
10788
  call.logger.info('ring: the caller cancelled, leaving');
10831
10789
  return leave(call, {
@@ -10874,7 +10832,7 @@ class RingStatePoller {
10874
10832
  unsubscribe = [];
10875
10833
  constructor(call, options = {}) {
10876
10834
  this.call = call;
10877
- this.startAfterMs = options.startAfterMs ?? 15_000;
10835
+ this.startAfterMs = options.startAfterMs ?? 9_000;
10878
10836
  this.intervalMs = options.intervalMs ?? 5_000;
10879
10837
  }
10880
10838
  /**
@@ -11539,12 +11497,33 @@ const registerRingingCallEventHandlers = (call) => {
11539
11497
  };
11540
11498
 
11541
11499
  const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
11500
+ const toElementState = (element) => {
11501
+ const stream = element.srcObject;
11502
+ const [track] = stream?.getAudioTracks?.() ?? [];
11503
+ return {
11504
+ paused: element.paused,
11505
+ muted: element.muted,
11506
+ volume: Math.round(element.volume * 100) / 100,
11507
+ readyState: element.readyState,
11508
+ sinkId: element.sinkId,
11509
+ track: track
11510
+ ? {
11511
+ enabled: track.enabled,
11512
+ muted: track.muted,
11513
+ readyState: track.readyState,
11514
+ }
11515
+ : null,
11516
+ };
11517
+ };
11542
11518
  /**
11543
11519
  * Tracks audio element bindings and periodically warns about
11544
11520
  * remote participants whose audio streams have no bound element.
11521
+ * On the same tick, it traces the playback state of the bound elements,
11522
+ * limited to the bindings whose state changed since the previous trace.
11545
11523
  */
11546
11524
  class AudioBindingsWatchdog {
11547
11525
  bindings = new Map();
11526
+ tracedStates = new Map();
11548
11527
  enabled = true;
11549
11528
  watchdogInterval;
11550
11529
  unsubscribeCallingState;
@@ -11555,8 +11534,6 @@ class AudioBindingsWatchdog {
11555
11534
  this.tracer = tracer;
11556
11535
  this.state = state;
11557
11536
  this.unsubscribeCallingState = createSubscription(state.callingState$, (callingState) => {
11558
- if (!this.enabled)
11559
- return;
11560
11537
  if (callingState !== CallingState.JOINED) {
11561
11538
  this.stop();
11562
11539
  }
@@ -11585,15 +11562,15 @@ class AudioBindingsWatchdog {
11585
11562
  this.bindings.delete(toBindingKey(sessionId, trackType));
11586
11563
  };
11587
11564
  /**
11588
- * Enables or disables the watchdog.
11589
- * When disabled, the periodic check stops but bindings are still tracked.
11565
+ * Enables or disables the dangling binding warnings.
11566
+ * Bindings are still tracked and their playback state is still traced.
11590
11567
  */
11591
11568
  setEnabled = (enabled) => {
11592
11569
  this.enabled = enabled;
11593
11570
  if (enabled) {
11594
11571
  this.start();
11595
11572
  }
11596
- else {
11573
+ else if (this.state.callingState !== CallingState.JOINED) {
11597
11574
  this.stop();
11598
11575
  }
11599
11576
  };
@@ -11603,33 +11580,61 @@ class AudioBindingsWatchdog {
11603
11580
  dispose = () => {
11604
11581
  this.stop();
11605
11582
  this.bindings.clear();
11583
+ this.tracedStates.clear();
11606
11584
  this.unsubscribeCallingState();
11607
11585
  };
11608
11586
  start = () => {
11609
11587
  clearInterval(this.watchdogInterval);
11610
11588
  this.watchdogInterval = setInterval(() => {
11611
- const danglingUserIds = [];
11612
- for (const p of this.state.participants) {
11613
- if (p.isLocalParticipant)
11614
- continue;
11615
- const { audioStream, screenShareAudioStream, sessionId, userId, publishedTracks, } = p;
11616
- if (audioStream &&
11617
- publishedTracks.includes(TrackType.AUDIO) &&
11618
- !this.bindings.has(toBindingKey(sessionId))) {
11619
- danglingUserIds.push(userId);
11620
- }
11621
- if (screenShareAudioStream &&
11622
- publishedTracks.includes(TrackType.SCREEN_SHARE_AUDIO) &&
11623
- !this.bindings.has(toBindingKey(sessionId, 'screenShareAudioTrack'))) {
11624
- danglingUserIds.push(userId);
11625
- }
11589
+ if (this.enabled)
11590
+ this.warnAboutDanglingBindings();
11591
+ this.traceBindingStates();
11592
+ }, 3000);
11593
+ };
11594
+ warnAboutDanglingBindings = () => {
11595
+ const danglingUserIds = [];
11596
+ for (const p of this.state.participants) {
11597
+ if (p.isLocalParticipant)
11598
+ continue;
11599
+ const { audioStream, screenShareAudioStream, sessionId, userId, publishedTracks, } = p;
11600
+ if (audioStream &&
11601
+ publishedTracks.includes(TrackType.AUDIO) &&
11602
+ !this.bindings.has(toBindingKey(sessionId))) {
11603
+ danglingUserIds.push(userId);
11626
11604
  }
11627
- if (danglingUserIds.length > 0) {
11628
- const key = 'audioBinding.danglingWarning';
11629
- this.tracer.traceOnce(key, key, danglingUserIds);
11630
- this.logger.warn(`Dangling audio bindings detected. Did you forget to bind the audio element? user_ids: ${danglingUserIds}.`);
11605
+ if (screenShareAudioStream &&
11606
+ publishedTracks.includes(TrackType.SCREEN_SHARE_AUDIO) &&
11607
+ !this.bindings.has(toBindingKey(sessionId, 'screenShareAudioTrack'))) {
11608
+ danglingUserIds.push(userId);
11631
11609
  }
11632
- }, 3000);
11610
+ }
11611
+ if (danglingUserIds.length > 0) {
11612
+ const key = 'audioBinding.danglingWarning';
11613
+ this.tracer.traceOnce(key, key, danglingUserIds);
11614
+ this.logger.warn(`Dangling audio bindings detected. Did you forget to bind the audio element? user_ids: ${danglingUserIds}.`);
11615
+ }
11616
+ };
11617
+ traceBindingStates = () => {
11618
+ const changes = {};
11619
+ let hasChanges = false;
11620
+ for (const [key, element] of this.bindings) {
11621
+ const state = toElementState(element);
11622
+ const serialized = JSON.stringify(state);
11623
+ if (this.tracedStates.get(key) === serialized)
11624
+ continue;
11625
+ this.tracedStates.set(key, serialized);
11626
+ changes[key] = state;
11627
+ hasChanges = true;
11628
+ }
11629
+ for (const key of this.tracedStates.keys()) {
11630
+ if (this.bindings.has(key))
11631
+ continue;
11632
+ this.tracedStates.delete(key);
11633
+ changes[key] = null;
11634
+ hasChanges = true;
11635
+ }
11636
+ if (hasChanges)
11637
+ this.tracer.trace('audioBinding.state', changes);
11633
11638
  };
11634
11639
  stop = () => {
11635
11640
  clearInterval(this.watchdogInterval);
@@ -11908,7 +11913,9 @@ class TrackSubscriptionManager {
11908
11913
  class MediaPlaybackWatchdog {
11909
11914
  logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
11910
11915
  kind;
11916
+ traceInfo;
11911
11917
  isBlocked;
11918
+ onGiveUp;
11912
11919
  element;
11913
11920
  tracer;
11914
11921
  controller = new AbortController();
@@ -11920,8 +11927,14 @@ class MediaPlaybackWatchdog {
11920
11927
  constructor(opts) {
11921
11928
  this.element = opts.element;
11922
11929
  this.kind = opts.kind;
11930
+ this.traceInfo = {
11931
+ kind: opts.kind,
11932
+ sessionId: opts.sessionId,
11933
+ trackType: opts.trackType,
11934
+ };
11923
11935
  this.tracer = opts.tracer;
11924
11936
  this.isBlocked = opts.isBlocked ?? (() => false);
11937
+ this.onGiveUp = opts.onGiveUp ?? (() => { });
11925
11938
  this.attach();
11926
11939
  }
11927
11940
  attach = () => {
@@ -11939,6 +11952,23 @@ class MediaPlaybackWatchdog {
11939
11952
  this.controller.abort();
11940
11953
  this.clearTimers();
11941
11954
  };
11955
+ /**
11956
+ * Starts a fresh recovery cycle, e.g. after a media interruption ended.
11957
+ * Unlike a regular pause-triggered recovery, it also tries elements that
11958
+ * were skipped as blocked or not ready, since those conditions may have
11959
+ * cleared together with the interruption.
11960
+ */
11961
+ rearm = () => {
11962
+ if (this.disposed)
11963
+ return;
11964
+ this.clearTimers();
11965
+ this.attempt = 0;
11966
+ this.stopped = false;
11967
+ const { paused, srcObject, ended } = this.element;
11968
+ if (!paused || !srcObject || ended)
11969
+ return;
11970
+ this.pendingTimer = setTimeout(this.attemptPlay, 0);
11971
+ };
11942
11972
  clearTimers = () => {
11943
11973
  if (this.pendingTimer)
11944
11974
  clearTimeout(this.pendingTimer);
@@ -11960,17 +11990,34 @@ class MediaPlaybackWatchdog {
11960
11990
  if (this.element.paused)
11961
11991
  return;
11962
11992
  this.tracer.trace('mediaPlayback.recover.success', {
11963
- kind: this.kind,
11993
+ ...this.traceInfo,
11964
11994
  attempts: this.attempt,
11965
11995
  });
11966
11996
  this.attempt = 0;
11967
11997
  this.stopped = false;
11968
11998
  };
11999
+ /**
12000
+ * Media elements backed by a MediaStream fire `suspend` routinely (after
12001
+ * the initial load, on source swaps, on layer switches), so a `suspend`
12002
+ * that needs no recovery is ignored without a trace. An explicit `pause`
12003
+ * is rare and always traced, along with the reason recovery was skipped.
12004
+ */
11969
12005
  onPauseOrSuspend = (event) => {
11970
- if (this.disposed || this.stopped)
12006
+ if (this.disposed || this.stopped || this.pendingTimer)
12007
+ return;
12008
+ const skipReason = this.computeSkipReason();
12009
+ if (skipReason) {
12010
+ if (event.type === 'pause') {
12011
+ this.tracer.trace('mediaPlayback.paused', {
12012
+ ...this.traceInfo,
12013
+ reason: event.type,
12014
+ skipped: skipReason,
12015
+ });
12016
+ }
11971
12017
  return;
12018
+ }
11972
12019
  this.tracer.trace('mediaPlayback.paused', {
11973
- kind: this.kind,
12020
+ ...this.traceInfo,
11974
12021
  reason: event.type,
11975
12022
  });
11976
12023
  if (this.settleTimer)
@@ -11984,7 +12031,7 @@ class MediaPlaybackWatchdog {
11984
12031
  const skipReason = this.computeSkipReason();
11985
12032
  if (skipReason) {
11986
12033
  this.tracer.trace('mediaPlayback.recover.skipped', {
11987
- kind: this.kind,
12034
+ ...this.traceInfo,
11988
12035
  reason: skipReason,
11989
12036
  });
11990
12037
  return;
@@ -11993,9 +12040,10 @@ class MediaPlaybackWatchdog {
11993
12040
  this.stopped = true;
11994
12041
  this.clearTimers();
11995
12042
  this.tracer.trace('mediaPlayback.recover.giveUp', {
11996
- kind: this.kind,
12043
+ ...this.traceInfo,
11997
12044
  attempts: this.attempt,
11998
12045
  });
12046
+ this.onGiveUp();
11999
12047
  return;
12000
12048
  }
12001
12049
  const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
@@ -12023,7 +12071,7 @@ class MediaPlaybackWatchdog {
12023
12071
  return;
12024
12072
  this.attempt += 1;
12025
12073
  this.tracer.trace('mediaPlayback.recover.attempt', {
12026
- kind: this.kind,
12074
+ ...this.traceInfo,
12027
12075
  attempt: this.attempt,
12028
12076
  });
12029
12077
  try {
@@ -12038,6 +12086,41 @@ class MediaPlaybackWatchdog {
12038
12086
  };
12039
12087
  }
12040
12088
 
12089
+ /**
12090
+ * Checks whether media playback and capture are likely interrupted: the page
12091
+ * is hidden, or WebKit reports the audio session as interrupted (a phone
12092
+ * call, Siri, or another app taking over audio).
12093
+ */
12094
+ const isMediaInterrupted = () => {
12095
+ if (typeof document === 'undefined')
12096
+ return false;
12097
+ return (document.visibilityState === 'hidden' ||
12098
+ navigator.audioSession?.state === 'interrupted');
12099
+ };
12100
+ /**
12101
+ * Invokes `onResumed` every time the page leaves a media interruption,
12102
+ * as defined by {@link isMediaInterrupted}.
12103
+ *
12104
+ * @param onResumed the callback to invoke.
12105
+ * @returns a function that stops watching.
12106
+ */
12107
+ const watchMediaInterruptions = (onResumed) => {
12108
+ if (typeof document === 'undefined')
12109
+ return () => { };
12110
+ let interrupted = isMediaInterrupted();
12111
+ const check = () => {
12112
+ const wasInterrupted = interrupted;
12113
+ interrupted = isMediaInterrupted();
12114
+ if (wasInterrupted && !interrupted)
12115
+ onResumed();
12116
+ };
12117
+ const controller = new AbortController();
12118
+ const { signal } = controller;
12119
+ document.addEventListener('visibilitychange', check, { signal });
12120
+ navigator.audioSession?.addEventListener('statechange', check, { signal });
12121
+ return () => controller.abort();
12122
+ };
12123
+
12041
12124
  /**
12042
12125
  * A manager class that handles dynascale related tasks like:
12043
12126
  *
@@ -12051,6 +12134,9 @@ class DynascaleManager {
12051
12134
  tracer;
12052
12135
  useWebAudio = false;
12053
12136
  audioContext;
12137
+ playbackWatchdogs = new Set();
12138
+ stopWatchingInterruptions;
12139
+ stopTracingAudioSession;
12054
12140
  trackSubscriptionManager;
12055
12141
  blockedAudioTracker;
12056
12142
  /**
@@ -12067,6 +12153,10 @@ class DynascaleManager {
12067
12153
  * Closes the audio context if it was created.
12068
12154
  */
12069
12155
  dispose = async () => {
12156
+ this.stopWatchingInterruptions?.();
12157
+ this.stopWatchingInterruptions = undefined;
12158
+ this.stopTracingAudioSession?.();
12159
+ this.stopTracingAudioSession = undefined;
12070
12160
  const context = this.audioContext;
12071
12161
  if (context && context.state !== 'closed') {
12072
12162
  document.removeEventListener('click', this.resumeAudioContext);
@@ -12206,10 +12296,12 @@ class DynascaleManager {
12206
12296
  // without prior user interaction:
12207
12297
  // https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
12208
12298
  videoElement.muted = true;
12209
- const playbackWatchdog = new MediaPlaybackWatchdog({
12299
+ const playbackWatchdog = this.createPlaybackWatchdog({
12210
12300
  element: videoElement,
12211
12301
  kind: 'video',
12212
12302
  tracer: this.tracer,
12303
+ sessionId,
12304
+ trackType,
12213
12305
  });
12214
12306
  const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
12215
12307
  const streamSubscription = participant$
@@ -12237,7 +12329,7 @@ class DynascaleManager {
12237
12329
  publishedTracksSubscription?.unsubscribe();
12238
12330
  streamSubscription.unsubscribe();
12239
12331
  resizeObserver?.disconnect();
12240
- playbackWatchdog.dispose();
12332
+ this.disposePlaybackWatchdog(playbackWatchdog);
12241
12333
  };
12242
12334
  };
12243
12335
  /**
@@ -12255,9 +12347,10 @@ class DynascaleManager {
12255
12347
  const participant = this.callState.findParticipantBySessionId(sessionId);
12256
12348
  if (!participant || participant.isLocalParticipant)
12257
12349
  return;
12350
+ this.traceAudioSessionState();
12258
12351
  const participant$ = this.callState.participants$.pipe(map((ps) => ps.find((p) => p.sessionId === sessionId)), takeWhile((p) => !!p), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
12259
12352
  const updateSinkId = (deviceId, audioContext) => {
12260
- if (!deviceId)
12353
+ if (!deviceId || !this.speaker.state.isDeviceSelectionSupported)
12261
12354
  return;
12262
12355
  if ('setSinkId' in audioElement) {
12263
12356
  audioElement.setSinkId(deviceId).catch((e) => {
@@ -12274,7 +12367,9 @@ class DynascaleManager {
12274
12367
  let sourceNode = undefined;
12275
12368
  let gainNode = undefined;
12276
12369
  let audioWatchdog = undefined;
12370
+ let blockedByGiveUp = false;
12277
12371
  const clearBlockedAudio = () => {
12372
+ blockedByGiveUp = false;
12278
12373
  if (!this.blockedAudioTracker.isBlocked(audioElement))
12279
12374
  return;
12280
12375
  this.blockedAudioTracker.markBlocked(audioElement, false);
@@ -12282,15 +12377,18 @@ class DynascaleManager {
12282
12377
  audioElement.addEventListener('playing', clearBlockedAudio);
12283
12378
  const isAudioTrack = trackType === 'audioTrack';
12284
12379
  const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
12380
+ let sourceChangeTimer;
12285
12381
  const updateMediaStreamSubscription = participant$
12286
12382
  .pipe(distinctUntilKeyChanged(trackKey))
12287
12383
  .subscribe((p) => {
12288
12384
  const source = isAudioTrack ? p.audioStream : p.screenShareAudioStream;
12289
12385
  if (audioElement.srcObject === source)
12290
12386
  return;
12291
- setTimeout(() => {
12387
+ clearTimeout(sourceChangeTimer);
12388
+ sourceChangeTimer = setTimeout(() => {
12389
+ sourceChangeTimer = undefined;
12292
12390
  audioElement.srcObject = source ?? null;
12293
- audioWatchdog?.dispose();
12391
+ this.disposePlaybackWatchdog(audioWatchdog);
12294
12392
  audioWatchdog = undefined;
12295
12393
  if (!source) {
12296
12394
  clearBlockedAudio();
@@ -12321,15 +12419,25 @@ class DynascaleManager {
12321
12419
  this.tracer.trace('audioPlaybackError', e.message);
12322
12420
  if (e.name === 'NotAllowedError') {
12323
12421
  this.tracer.trace('audioPlaybackBlocked', null);
12422
+ blockedByGiveUp = false;
12324
12423
  this.blockedAudioTracker.markBlocked(audioElement, true, sessionId);
12325
12424
  }
12326
12425
  this.logger.warn(`Failed to play audio stream`, e);
12327
12426
  });
12328
- audioWatchdog = new MediaPlaybackWatchdog({
12427
+ audioWatchdog = this.createPlaybackWatchdog({
12329
12428
  element: audioElement,
12330
12429
  kind: 'audio',
12331
12430
  tracer: this.tracer,
12332
- isBlocked: () => this.blockedAudioTracker.isBlocked(audioElement),
12431
+ sessionId,
12432
+ trackType,
12433
+ // only an autoplay-policy block needs a user gesture; a give-up
12434
+ // block is for the UI and must not stop a re-armed cycle
12435
+ isBlocked: () => !blockedByGiveUp &&
12436
+ this.blockedAudioTracker.isBlocked(audioElement),
12437
+ onGiveUp: () => {
12438
+ blockedByGiveUp = true;
12439
+ this.blockedAudioTracker.markBlocked(audioElement, true, sessionId);
12440
+ },
12333
12441
  });
12334
12442
  }
12335
12443
  const { selectedDevice } = this.speaker.state;
@@ -12337,7 +12445,7 @@ class DynascaleManager {
12337
12445
  updateSinkId(selectedDevice, audioContext);
12338
12446
  });
12339
12447
  });
12340
- const sinkIdSubscription = !('setSinkId' in audioElement)
12448
+ const sinkIdSubscription = !this.speaker.state.isDeviceSelectionSupported
12341
12449
  ? null
12342
12450
  : this.speaker.state.selectedDevice$.subscribe((deviceId) => {
12343
12451
  const audioContext = this.getOrCreateAudioContext();
@@ -12359,13 +12467,51 @@ class DynascaleManager {
12359
12467
  sinkIdSubscription?.unsubscribe();
12360
12468
  volumeSubscription.unsubscribe();
12361
12469
  updateMediaStreamSubscription.unsubscribe();
12470
+ clearTimeout(sourceChangeTimer);
12362
12471
  audioElement.srcObject = null;
12363
12472
  sourceNode?.disconnect();
12364
12473
  gainNode?.disconnect();
12365
- audioWatchdog?.dispose();
12474
+ this.disposePlaybackWatchdog(audioWatchdog);
12366
12475
  audioWatchdog = undefined;
12367
12476
  };
12368
12477
  };
12478
+ /**
12479
+ * Creates a playback watchdog that is re-armed whenever a media
12480
+ * interruption (backgrounding, phone call, Siri) ends.
12481
+ */
12482
+ createPlaybackWatchdog = (opts) => {
12483
+ const watchdog = new MediaPlaybackWatchdog(opts);
12484
+ this.playbackWatchdogs.add(watchdog);
12485
+ this.stopWatchingInterruptions ??= watchMediaInterruptions(this.rearmPlayback);
12486
+ return watchdog;
12487
+ };
12488
+ disposePlaybackWatchdog = (watchdog) => {
12489
+ if (!watchdog)
12490
+ return;
12491
+ watchdog.dispose();
12492
+ this.playbackWatchdogs.delete(watchdog);
12493
+ };
12494
+ rearmPlayback = () => {
12495
+ this.tracer.trace('mediaPlayback.rearm', this.playbackWatchdogs.size);
12496
+ this.playbackWatchdogs.forEach((watchdog) => watchdog.rearm());
12497
+ };
12498
+ /**
12499
+ * Traces the WebKit audio session state (initial value and changes) in
12500
+ * both playback modes, as it explains most iOS playback interruptions.
12501
+ */
12502
+ traceAudioSessionState = () => {
12503
+ const audioSession = navigator.audioSession;
12504
+ if (!audioSession || this.stopTracingAudioSession)
12505
+ return;
12506
+ const trace = () => {
12507
+ this.tracer.trace('audioSession.state', audioSession.state);
12508
+ };
12509
+ trace();
12510
+ audioSession.addEventListener('statechange', trace);
12511
+ this.stopTracingAudioSession = () => {
12512
+ audioSession.removeEventListener('statechange', trace);
12513
+ };
12514
+ };
12369
12515
  getOrCreateAudioContext = () => {
12370
12516
  if (!this.useWebAudio)
12371
12517
  return;
@@ -12388,7 +12534,6 @@ class DynascaleManager {
12388
12534
  audioSession.type = 'play-and-record';
12389
12535
  let isSessionInterrupted = false;
12390
12536
  audioSession.addEventListener('statechange', () => {
12391
- this.tracer.trace('audioSession.state', audioSession.state);
12392
12537
  if (audioSession.state === 'interrupted') {
12393
12538
  isSessionInterrupted = true;
12394
12539
  }
@@ -12963,10 +13108,17 @@ const getDevices = (permission, kind, tracer) => {
12963
13108
  /**
12964
13109
  * Tells if the browser supports audio output change on 'audio' elements,
12965
13110
  * see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId.
13111
+ *
13112
+ * WebKit (Safari, iOS browsers, WKWebView) is reported as unsupported:
13113
+ * setSinkId requires a user gesture, a per-device output can run at a
13114
+ * different sample rate than the remote audio mixer (pitched/slowed audio),
13115
+ * and audio goes silent when the selected device disconnects.
12966
13116
  */
12967
13117
  const checkIfAudioOutputChangeSupported = () => {
12968
13118
  if (typeof document === 'undefined')
12969
13119
  return false;
13120
+ if (isWebKit())
13121
+ return false;
12970
13122
  const element = document.createElement('audio');
12971
13123
  return 'setSinkId' in element;
12972
13124
  };
@@ -13472,6 +13624,7 @@ class DeviceManager {
13472
13624
  ? forceStopOrOptions
13473
13625
  : (forceStopOrOptions?.forceStop ?? false);
13474
13626
  this.state.prevStatus = this.state.optimisticStatus;
13627
+ this.state.setError(undefined);
13475
13628
  if (!forceStop && this.state.optimisticStatus === 'disabled') {
13476
13629
  return;
13477
13630
  }
@@ -13481,6 +13634,7 @@ class DeviceManager {
13481
13634
  const stopTracks = forceStop || this.state.disableMode === 'stop-tracks';
13482
13635
  await this.muteStream(stopTracks);
13483
13636
  this.state.setStatus('disabled');
13637
+ this.state.setError(undefined);
13484
13638
  }
13485
13639
  finally {
13486
13640
  if (!signal.aborted) {
@@ -13776,7 +13930,11 @@ class DeviceManager {
13776
13930
  };
13777
13931
  // the rootStream represents the stream coming from the actual device
13778
13932
  // e.g. camera or microphone stream
13933
+ this.state.setError(undefined);
13779
13934
  rootStreamPromise = this.getSelectedStream(constraints);
13935
+ rootStreamPromise.catch((error) => {
13936
+ this.state.setError(error);
13937
+ });
13780
13938
  // we publish the last MediaStream of the chain
13781
13939
  stream = await this.filters.reduce((parent, entry) => parent
13782
13940
  .then((inputStream) => {
@@ -13947,9 +14105,22 @@ class DeviceManager {
13947
14105
  }
13948
14106
  const devices = getCurrentValue(this.listDevices()) || [];
13949
14107
  const currentDevice = this.findDevice(devices, selectedDevice) ??
13950
- createSyntheticDevice(selectedDevice, deviceKind);
14108
+ createSyntheticDevice(selectedDevice, deviceKind, this.getActiveTrackLabel(selectedDevice));
13951
14109
  writePreferences(currentDevice, deviceKey, muted, storageKey);
13952
14110
  }
14111
+ /**
14112
+ * Returns the label of the active track if it was captured from the given
14113
+ * device. Used as a fallback when the device list hasn't been enumerated yet,
14114
+ * so the persisted preference still carries the device label.
14115
+ */
14116
+ getActiveTrackLabel = (deviceId) => {
14117
+ const stream = this.state.rootMediaStream;
14118
+ const tracks = this.trackType === TrackType.AUDIO
14119
+ ? stream?.getAudioTracks()
14120
+ : stream?.getVideoTracks();
14121
+ const track = tracks?.find((t) => t.getSettings().deviceId === deviceId);
14122
+ return track?.label;
14123
+ };
13953
14124
  async applyPersistedPreferences(enabledInCallType, forceDisabled = false) {
13954
14125
  const deviceKey = this.trackType === TrackType.AUDIO ? 'microphone' : 'camera';
13955
14126
  const preferences = readPreferences(this.devicePersistence.storageKey);
@@ -14001,6 +14172,7 @@ class DeviceManagerState {
14001
14172
  rootMediaStreamSubject = new BehaviorSubject(undefined);
14002
14173
  selectedDeviceSubject = new BehaviorSubject(undefined);
14003
14174
  defaultConstraintsSubject = new BehaviorSubject(undefined);
14175
+ errorSubject = new BehaviorSubject(undefined);
14004
14176
  /**
14005
14177
  * @internal
14006
14178
  */
@@ -14035,6 +14207,13 @@ class DeviceManagerState {
14035
14207
  * The default constraints for the device.
14036
14208
  */
14037
14209
  defaultConstraints$ = this.defaultConstraintsSubject.asObservable();
14210
+ /**
14211
+ * An Observable that emits the error of the last failed attempt to start
14212
+ * the device (e.g. the device is in use by another app), or `undefined`.
14213
+ * For screen share, this includes the user dismissing the picker.
14214
+ * Cleared when the device is started again or disabled.
14215
+ */
14216
+ error$ = this.errorSubject.asObservable().pipe(distinctUntilChanged());
14038
14217
  /**
14039
14218
  * An observable that will emit `true` if browser/system permission
14040
14219
  * is granted (or at least hasn't been denied), `false` otherwise.
@@ -14102,6 +14281,19 @@ class DeviceManagerState {
14102
14281
  get rootMediaStream() {
14103
14282
  return getCurrentValue(this.rootMediaStream$);
14104
14283
  }
14284
+ /**
14285
+ * The error of the last failed attempt to start the device, or `undefined`.
14286
+ */
14287
+ get error() {
14288
+ return getCurrentValue(this.error$);
14289
+ }
14290
+ /**
14291
+ * @internal
14292
+ * @param error the error to set.
14293
+ */
14294
+ setError(error) {
14295
+ setCurrentValue(this.errorSubject, error);
14296
+ }
14105
14297
  /**
14106
14298
  * @internal
14107
14299
  * @param status
@@ -14683,9 +14875,11 @@ class MicrophoneManager extends AudioDeviceManager {
14683
14875
  this.call.state.callingState$,
14684
14876
  this.call.state.ownCapabilities$,
14685
14877
  this.state.selectedDevice$,
14686
- this.state.status$,
14687
14878
  this.state.browserPermissionState$,
14688
- ]), async ([callingState, ownCapabilities, deviceId, status, permissionState,]) => {
14879
+ // only a trigger: queued handlers can run after the status changed
14880
+ // again, so the live status is read below
14881
+ this.state.status$,
14882
+ ]), async ([callingState, ownCapabilities, deviceId, permissionState]) => {
14689
14883
  try {
14690
14884
  if (callingState === CallingState.LEFT) {
14691
14885
  // The muted-recording-prepared mode is reset in `callManager.stop()`
@@ -14700,7 +14894,7 @@ class MicrophoneManager extends AudioDeviceManager {
14700
14894
  return;
14701
14895
  if (ownCapabilities.includes(OwnCapability.SEND_AUDIO)) {
14702
14896
  const hasPermission = await this.hasPermission(permissionState);
14703
- if (hasPermission && status !== 'enabled') {
14897
+ if (hasPermission && this.state.status !== 'enabled') {
14704
14898
  this.setMutedRecordingPrepared(true);
14705
14899
  await this.startSpeakingWhileMutedDetection(deviceId);
14706
14900
  }
@@ -15000,6 +15194,52 @@ class MicrophoneManager extends AudioDeviceManager {
15000
15194
  await this.call.ensureMediaFactory();
15001
15195
  return getAudioStream(constraints, this.call.tracer);
15002
15196
  }
15197
+ /**
15198
+ * On mute, hands a clone of the live mic track to the speaking while muted
15199
+ * detection, so the device stays captured after the mic tracks are stopped.
15200
+ * Releasing and re-acquiring the mic flips Bluetooth headsets between audio
15201
+ * profiles.
15202
+ */
15203
+ async muteStream(stopTracks = true) {
15204
+ const [track] = this.state.rootMediaStream?.getAudioTracks() ?? [];
15205
+ const clone = track?.readyState === 'live' &&
15206
+ this.state.optimisticStatus === 'disabled' &&
15207
+ this.canHandOffMic()
15208
+ ? track.clone()
15209
+ : undefined;
15210
+ try {
15211
+ await super.muteStream(stopTracks);
15212
+ }
15213
+ catch (err) {
15214
+ clone?.stop();
15215
+ throw err;
15216
+ }
15217
+ if (!clone)
15218
+ return;
15219
+ // the mic, the call, or the selected device may have changed while
15220
+ // unpublishing
15221
+ const deviceId = resolveDeviceId(clone.getSettings().deviceId, 'audioinput');
15222
+ if (clone.readyState !== 'live' ||
15223
+ !this.canHandOffMic() ||
15224
+ deviceId !== this.state.selectedDevice) {
15225
+ clone.stop();
15226
+ return;
15227
+ }
15228
+ this.startSpeakingWhileMutedDetection(deviceId, clone).catch((err) => {
15229
+ clone.stop();
15230
+ this.logger.warn('Could not hand off mic to speaking while muted', err);
15231
+ });
15232
+ }
15233
+ /**
15234
+ * Whether a mute can hand the mic over to the speaking while muted detection.
15235
+ */
15236
+ canHandOffMic = () => {
15237
+ const { callingState, ownCapabilities } = this.call.state;
15238
+ return (!isReactNative() &&
15239
+ this.speakingWhileMutedNotificationEnabled &&
15240
+ callingState === CallingState.JOINED &&
15241
+ ownCapabilities.includes(OwnCapability.SEND_AUDIO));
15242
+ };
15003
15243
  doSetAudioBitrateProfile(profile) {
15004
15244
  this.setDefaultConstraints({
15005
15245
  ...this.state.defaultConstraints,
@@ -15019,10 +15259,13 @@ class MicrophoneManager extends AudioDeviceManager {
15019
15259
  }
15020
15260
  }
15021
15261
  }
15022
- async startSpeakingWhileMutedDetection(deviceId) {
15262
+ async startSpeakingWhileMutedDetection(deviceId, track) {
15023
15263
  await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
15024
- if (this.soundDetectorCleanup && this.soundDetectorDeviceId === deviceId)
15264
+ if (this.soundDetectorCleanup &&
15265
+ this.soundDetectorDeviceId === deviceId) {
15266
+ track?.stop();
15025
15267
  return;
15268
+ }
15026
15269
  await this.teardownSpeakingWhileMutedDetection();
15027
15270
  if (isReactNative()) {
15028
15271
  const speechActivity = globalThis.streamRNVideoSDK?.nativeEvents?.speechActivity;
@@ -15038,11 +15281,14 @@ class MicrophoneManager extends AudioDeviceManager {
15038
15281
  };
15039
15282
  }
15040
15283
  else {
15041
- // Need to start a new stream that's not connected to publisher
15042
- const stream = await this.getStream({
15043
- ...this.state.defaultConstraints,
15044
- deviceId: { exact: deviceId },
15045
- });
15284
+ // Use the handed-off mic track, or start a new stream that's not
15285
+ // connected to publisher
15286
+ const stream = track
15287
+ ? new MediaStream([track])
15288
+ : await this.getStream({
15289
+ ...this.state.defaultConstraints,
15290
+ deviceId: { exact: deviceId },
15291
+ });
15046
15292
  this.soundDetectorCleanup = createSoundDetector(stream, (event) => {
15047
15293
  this.state.setSpeakingWhileMuted(event.isSoundDetected);
15048
15294
  });
@@ -15322,7 +15568,7 @@ class SpeakerManager {
15322
15568
  }
15323
15569
  async applyWeb() {
15324
15570
  const { enabled, storageKey } = this.devicePersistence;
15325
- if (!enabled)
15571
+ if (!enabled || !this.state.isDeviceSelectionSupported)
15326
15572
  return;
15327
15573
  const preferences = readPreferences(storageKey);
15328
15574
  const preferenceList = toPreferenceList(preferences.speaker);
@@ -15598,6 +15844,14 @@ class Call {
15598
15844
  networkAvailableTask;
15599
15845
  // (10 attempts per rolling 120 s window).
15600
15846
  rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
15847
+ // Backstop for slow REJOIN loops that stay under the short window
15848
+ // (20 attempts per rolling 30 min window). Dropped once the integrator
15849
+ // takes over the budget through `setRejoinAttemptLimit`.
15850
+ rejoinLongWindowRateLimiter = new SlidingWindowRateLimiter(20, 30 * 60 * 1000);
15851
+ // In-flight `doJoin` runs, awaited before acting on peer connections that
15852
+ // gathered no local ICE candidates.
15853
+ joinTasks = new Set();
15854
+ noLocalCandidatesCheckScheduled = false;
15601
15855
  // "Network doesn't support WebRTC" detector: counts peer-connection
15602
15856
  // failures where ICE never reached `connected`/`completed`.
15603
15857
  maxIceFailuresWithoutConnect = 2;
@@ -15991,6 +16245,7 @@ class Call {
15991
16245
  // `joinFlow` never runs, leaving stale values that would make the next
15992
16246
  // fresh `join()` send a stale `ReconnectDetails` to the SFU.
15993
16247
  this.rejoinRateLimiter.reset();
16248
+ this.rejoinLongWindowRateLimiter?.reset();
15994
16249
  this.iceFailuresWithoutConnect = 0;
15995
16250
  this.consecutiveNegotiationFailures = 0;
15996
16251
  this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
@@ -16386,6 +16641,16 @@ class Call {
16386
16641
  * @returns a promise which resolves once the call join-flow has finished.
16387
16642
  */
16388
16643
  doJoin = async (data) => {
16644
+ const task = this.performJoin(data);
16645
+ this.joinTasks.add(task);
16646
+ try {
16647
+ await task;
16648
+ }
16649
+ finally {
16650
+ this.joinTasks.delete(task);
16651
+ }
16652
+ };
16653
+ performJoin = async (data) => {
16389
16654
  const connectStartTime = Date.now();
16390
16655
  const callingState = this.state.callingState;
16391
16656
  const joinLeaveGeneration = this.leaveGeneration;
@@ -16743,6 +17008,9 @@ class Call {
16743
17008
  // "ICE never connected" failure budget can be cleared.
16744
17009
  this.iceFailuresWithoutConnect = 0;
16745
17010
  },
17011
+ onNoLocalCandidates: () => {
17012
+ this.checkNoLocalCandidates();
17013
+ },
16746
17014
  onPeerConnectionStateChange: (event) => {
16747
17015
  this.clientEventReporter.onPeerConnectionStateChange(this.cid, event);
16748
17016
  },
@@ -16785,15 +17053,65 @@ class Call {
16785
17053
  options: statsOptions,
16786
17054
  subscriber: this.subscriber,
16787
17055
  publisher: this.publisher,
16788
- microphone: this.microphone,
16789
- camera: this.camera,
16790
- state: this.state,
16791
17056
  tracer: this.tracer,
16792
17057
  unifiedSessionId,
16793
17058
  });
16794
17059
  this.sfuStatsReporter.start();
16795
17060
  }
16796
17061
  };
17062
+ /**
17063
+ * Escalates to REJOIN with `ICE_NEVER_CONNECTED` when the current peer
17064
+ * connections never connected and one of them completed ICE gathering
17065
+ * without a single local candidate, which means WebRTC networking is
17066
+ * blocked on this device.
17067
+ *
17068
+ * `reconnect` ignores requests while a join or another reconnect is in
17069
+ * flight, so the check is postponed until those settle and then re-run
17070
+ * against the peer connections current at that time.
17071
+ */
17072
+ checkNoLocalCandidates = () => {
17073
+ if (this.noLocalCandidatesCheckScheduled)
17074
+ return;
17075
+ if (this.joinTasks.size > 0 || hasPending(this.reconnectConcurrencyTag)) {
17076
+ this.noLocalCandidatesCheckScheduled = true;
17077
+ const leaveGeneration = this.leaveGeneration;
17078
+ this.waitForJoinsAndReconnects()
17079
+ .then(() => {
17080
+ this.noLocalCandidatesCheckScheduled = false;
17081
+ if (this.leaveGeneration !== leaveGeneration)
17082
+ return;
17083
+ this.checkNoLocalCandidates();
17084
+ })
17085
+ .catch((err) => {
17086
+ this.noLocalCandidatesCheckScheduled = false;
17087
+ this.logger.warn('[Reconnect] No-candidates check failed', err);
17088
+ });
17089
+ return;
17090
+ }
17091
+ if (this.state.callingState !== CallingState.JOINED)
17092
+ return;
17093
+ // without network, gathering legitimately yields no candidates
17094
+ if (typeof navigator !== 'undefined' && navigator.onLine === false)
17095
+ return;
17096
+ const peerConnections = [this.publisher, this.subscriber];
17097
+ if (peerConnections.some((pc) => pc?.hasIceEverConnected()))
17098
+ return;
17099
+ if (!peerConnections.some((pc) => pc?.hasNoLocalCandidates()))
17100
+ return;
17101
+ this.reconnect(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED).catch((err) => {
17102
+ this.logger.warn('[Reconnect] Error reconnecting', err);
17103
+ });
17104
+ };
17105
+ /**
17106
+ * Resolves once no `doJoin` run and no reconnect is in flight.
17107
+ */
17108
+ waitForJoinsAndReconnects = async () => {
17109
+ while (this.joinTasks.size > 0 ||
17110
+ hasPending(this.reconnectConcurrencyTag)) {
17111
+ await Promise.allSettled(this.joinTasks);
17112
+ await settled(this.reconnectConcurrencyTag);
17113
+ }
17114
+ };
16797
17115
  /**
16798
17116
  * Retrieves credentials for joining the call.
16799
17117
  *
@@ -16992,6 +17310,14 @@ class Call {
16992
17310
  return;
16993
17311
  }
16994
17312
  }
17313
+ // MIGRATE is server-driven (e.g. SFU drains), so it doesn't count
17314
+ // toward the long-window backstop.
17315
+ if (this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN &&
17316
+ this.rejoinLongWindowRateLimiter &&
17317
+ !this.rejoinLongWindowRateLimiter.tryRegister()) {
17318
+ await giveUpAndLeave('rejoin_attempt_limit_exceeded');
17319
+ return;
17320
+ }
16995
17321
  // we don't increment reconnect attempts for the FAST strategy.
16996
17322
  if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
16997
17323
  this.reconnectAttempts++;
@@ -17210,7 +17536,13 @@ class Call {
17210
17536
  this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
17211
17537
  });
17212
17538
  // handles the "error" event, through which the SFU can request a reconnect
17213
- const unregisterOnError = this.on('error', (e) => {
17539
+ // Only the active SFU client may drive a reconnect: a superseded one can
17540
+ // still report errors about its own (replaced) session, and none may
17541
+ // while `leave()` tears the call down.
17542
+ const activeSfuTag = () => this.sfuClient?.tag ?? '*';
17543
+ const unregisterOnError = this.dispatcher.on('error', activeSfuTag, (e) => {
17544
+ if (!this.sfuClient)
17545
+ return;
17214
17546
  const { reconnectStrategy: strategy, error } = e;
17215
17547
  if (!SfuJoinError.isJoinErrorCode(e)) {
17216
17548
  const code = error?.code ? ErrorCode[error.code] : undefined;
@@ -17233,7 +17565,16 @@ class Call {
17233
17565
  });
17234
17566
  }
17235
17567
  else {
17236
- this.reconnect(strategy, error?.message || ReconnectReason.SFU_ERROR).catch((err) => {
17568
+ // A media transport failure on a session whose peer connections
17569
+ // never connected is an "ICE never connected" failure, and must
17570
+ // count toward the unsupported-network budget.
17571
+ const neverConnected = error?.code === ErrorCode.PARTICIPANT_MEDIA_TRANSPORT_FAILURE &&
17572
+ !this.publisher?.hasIceEverConnected() &&
17573
+ !this.subscriber?.hasIceEverConnected();
17574
+ const reason = neverConnected
17575
+ ? ReconnectReason.ICE_NEVER_CONNECTED
17576
+ : error?.message || ReconnectReason.SFU_ERROR;
17577
+ this.reconnect(strategy, reason).catch((err) => {
17237
17578
  this.logger.warn('[Reconnect] Error reconnecting', err);
17238
17579
  });
17239
17580
  }
@@ -18297,9 +18638,11 @@ class Call {
18297
18638
  * `rejoin_attempt_limit_exceeded` leave message.
18298
18639
  *
18299
18640
  * Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
18300
- * Both arguments are clamped to a minimum of 1.
18641
+ * Both arguments are clamped to a minimum of 1. Unless this method is
18642
+ * called, an additional cap of 20 REJOIN attempts per 30 minutes applies.
18301
18643
  */
18302
18644
  setRejoinAttemptLimit = (maxAttempts, windowSeconds) => {
18645
+ this.rejoinLongWindowRateLimiter = undefined;
18303
18646
  this.rejoinRateLimiter.setLimits(Math.max(1, maxAttempts), Math.max(1, windowSeconds) * 1000);
18304
18647
  };
18305
18648
  /**
@@ -19637,11 +19980,11 @@ class StreamClient {
19637
19980
  return await this.wsConnection.connect(this.defaultWSTimeout);
19638
19981
  };
19639
19982
  getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
19640
- "1.61.1";
19983
+ "1.62.0";
19641
19984
  getUserAgent = () => {
19642
19985
  if (!this.cachedUserAgent) {
19643
19986
  const { clientAppIdentifier = {} } = this.options;
19644
- const { sdkName = 'js', sdkVersion = "1.61.1", ...extras } = clientAppIdentifier;
19987
+ const { sdkName = 'js', sdkVersion = "1.62.0", ...extras } = clientAppIdentifier;
19645
19988
  this.cachedUserAgent = [
19646
19989
  `stream-video-${sdkName}-v${sdkVersion}`,
19647
19990
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -20581,6 +20924,10 @@ class StreamVideoClient {
20581
20924
  const call = this.writeableStateStore.findCall(c.call.type, c.call.id);
20582
20925
  if (call) {
20583
20926
  call.updateFromCallStateResponse(c);
20927
+ // ring events missed while the WS was down aren't replayed
20928
+ reconcileRingState(call, 'ring-poll-api').catch((err) => {
20929
+ call.logger.error('Failed to reconcile the ring state after rewatch', err);
20930
+ });
20584
20931
  }
20585
20932
  }
20586
20933
  return;