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