@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.cjs.js CHANGED
@@ -2685,18 +2685,6 @@ class SendStatsRequest$Type extends runtime.MessageType {
2685
2685
  constructor() {
2686
2686
  super('stream.video.sfu.signal.SendStatsRequest', [
2687
2687
  { no: 1, name: 'session_id', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
2688
- {
2689
- no: 2,
2690
- name: 'subscriber_stats',
2691
- kind: 'scalar',
2692
- T: 9 /*ScalarType.STRING*/,
2693
- },
2694
- {
2695
- no: 3,
2696
- name: 'publisher_stats',
2697
- kind: 'scalar',
2698
- T: 9 /*ScalarType.STRING*/,
2699
- },
2700
2688
  {
2701
2689
  no: 4,
2702
2690
  name: 'webrtc_version',
@@ -2710,8 +2698,6 @@ class SendStatsRequest$Type extends runtime.MessageType {
2710
2698
  kind: 'scalar',
2711
2699
  T: 9 /*ScalarType.STRING*/,
2712
2700
  },
2713
- { no: 7, name: 'audio_devices', kind: 'message', T: () => InputDevices },
2714
- { no: 8, name: 'video_devices', kind: 'message', T: () => InputDevices },
2715
2701
  {
2716
2702
  no: 9,
2717
2703
  name: 'android',
@@ -2728,18 +2714,6 @@ class SendStatsRequest$Type extends runtime.MessageType {
2728
2714
  },
2729
2715
  { no: 11, name: 'telemetry', kind: 'message', T: () => Telemetry },
2730
2716
  { no: 12, name: 'rtmp', kind: 'message', T: () => RTMPIngress },
2731
- {
2732
- no: 13,
2733
- name: 'subscriber_rtc_stats',
2734
- kind: 'scalar',
2735
- T: 9 /*ScalarType.STRING*/,
2736
- },
2737
- {
2738
- no: 14,
2739
- name: 'publisher_rtc_stats',
2740
- kind: 'scalar',
2741
- T: 9 /*ScalarType.STRING*/,
2742
- },
2743
2717
  { no: 15, name: 'rtc_stats', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
2744
2718
  {
2745
2719
  no: 16,
@@ -5101,7 +5075,7 @@ const toIceCandidate = (iceTrickle) => {
5101
5075
  }
5102
5076
  };
5103
5077
 
5104
- const version = "1.61.1";
5078
+ const version = "1.62.0";
5105
5079
  const [major, minor, patch] = version.split('.');
5106
5080
  let sdkInfo = {
5107
5081
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -7829,30 +7803,21 @@ class SfuStatsReporter {
7829
7803
  sfuClient;
7830
7804
  subscriber;
7831
7805
  publisher;
7832
- microphone;
7833
- camera;
7834
- state;
7835
7806
  tracer;
7836
7807
  unifiedSessionId;
7837
7808
  intervalId;
7838
7809
  timeoutId;
7839
7810
  reportCount = 0;
7840
- unsubscribeDevicePermissionsSubscription;
7841
- unsubscribeListDevicesSubscription;
7842
7811
  sdkName;
7843
7812
  sdkVersion;
7844
7813
  webRTCVersion;
7845
- inputDevices = new Map();
7846
7814
  statsConcurrencyTag = Symbol('sfuStatsReporter');
7847
7815
  isStopped = false;
7848
- constructor(sfuClient, { options, clientDetails, subscriber, publisher, microphone, camera, state, tracer, unifiedSessionId, }) {
7816
+ constructor(sfuClient, { options, clientDetails, subscriber, publisher, tracer, unifiedSessionId, }) {
7849
7817
  this.sfuClient = sfuClient;
7850
7818
  this.options = options;
7851
7819
  this.subscriber = subscriber;
7852
7820
  this.publisher = publisher;
7853
- this.microphone = microphone;
7854
- this.camera = camera;
7855
- this.state = state;
7856
7821
  this.tracer = tracer;
7857
7822
  this.unifiedSessionId = unifiedSessionId;
7858
7823
  const { sdk, browser } = clientDetails;
@@ -7866,35 +7831,6 @@ class SfuStatsReporter {
7866
7831
  `${browser?.name || ''}-${browser?.version || ''}` ||
7867
7832
  'N/A';
7868
7833
  }
7869
- observeDevice = (device, kind) => {
7870
- const { browserPermissionState$ } = device.state;
7871
- this.unsubscribeDevicePermissionsSubscription?.();
7872
- this.unsubscribeDevicePermissionsSubscription = createSubscription(rxjs.combineLatest([browserPermissionState$, this.state.ownCapabilities$]), ([browserPermissionState, ownCapabilities]) => {
7873
- // cleanup the previous listDevices() subscription in case
7874
- // permissions or capabilities have changed.
7875
- // we will subscribe again if everything is in order.
7876
- this.unsubscribeListDevicesSubscription?.();
7877
- const hasCapability = kind === 'mic'
7878
- ? ownCapabilities.includes(OwnCapability.SEND_AUDIO)
7879
- : ownCapabilities.includes(OwnCapability.SEND_VIDEO);
7880
- if (browserPermissionState !== 'granted' || !hasCapability) {
7881
- this.inputDevices.set(kind, {
7882
- currentDevice: '',
7883
- availableDevices: [],
7884
- isPermitted: false,
7885
- });
7886
- return;
7887
- }
7888
- this.unsubscribeListDevicesSubscription = createSubscription(rxjs.combineLatest([device.listDevices(), device.state.selectedDevice$]), ([devices, deviceId]) => {
7889
- const selected = devices.find((d) => d.deviceId === deviceId);
7890
- this.inputDevices.set(kind, {
7891
- currentDevice: selected?.label || deviceId || '',
7892
- availableDevices: devices.map((d) => d.label),
7893
- isPermitted: true,
7894
- });
7895
- });
7896
- });
7897
- };
7898
7834
  sendConnectionTime = (connectionTimeSeconds) => {
7899
7835
  this.sendTelemetryData({
7900
7836
  data: {
@@ -7929,7 +7865,7 @@ class SfuStatsReporter {
7929
7865
  this.subscriber.stats.takeSample(),
7930
7866
  this.publisher?.stats.takeSample(),
7931
7867
  ]);
7932
- send = (subscriberStats, publisherStats, telemetry) => {
7868
+ send = (subscriberPerfStats, publisherPerfStats, telemetry) => {
7933
7869
  // serialize sends so overlapping ones can't race on the trace buffers or
7934
7870
  // deliver an older delta after a newer one already succeeded. Not gated by
7935
7871
  // `isStopped`: an explicit final flush must still deliver after stop().
@@ -7947,8 +7883,8 @@ class SfuStatsReporter {
7947
7883
  this.subscriber.stats.clearPendingDeltas();
7948
7884
  }
7949
7885
  let pubPending = [];
7950
- if (pubTracer && publisherStats) {
7951
- pubPending = this.publisher?.stats.getPendingDeltas() ?? [];
7886
+ if (pubTracer && this.publisher) {
7887
+ pubPending = this.publisher.stats.getPendingDeltas();
7952
7888
  }
7953
7889
  else {
7954
7890
  this.publisher?.stats.clearPendingDeltas();
@@ -7970,17 +7906,9 @@ class SfuStatsReporter {
7970
7906
  sdk: this.sdkName,
7971
7907
  sdkVersion: this.sdkVersion,
7972
7908
  webrtcVersion: this.webRTCVersion,
7973
- subscriberStats: JSON.stringify(flatten(subscriberStats.stats)),
7974
- publisherStats: publisherStats
7975
- ? JSON.stringify(flatten(publisherStats.stats))
7976
- : '[]',
7977
- subscriberRtcStats: '',
7978
- publisherRtcStats: '',
7979
7909
  rtcStats: JSON.stringify(traces),
7980
- encodeStats: publisherStats?.performanceStats ?? [],
7981
- decodeStats: subscriberStats.performanceStats,
7982
- audioDevices: this.inputDevices.get('mic'),
7983
- videoDevices: this.inputDevices.get('camera'),
7910
+ encodeStats: publisherPerfStats ?? [],
7911
+ decodeStats: subscriberPerfStats,
7984
7912
  unifiedSessionId: this.unifiedSessionId,
7985
7913
  deviceState: getDeviceState(),
7986
7914
  telemetry,
@@ -7994,8 +7922,8 @@ class SfuStatsReporter {
7994
7922
  // delivery confirmed: advance the delivery baseline for each chain.
7995
7923
  if (subTracer)
7996
7924
  this.subscriber.stats.commitDeltas(subPending);
7997
- if (pubTracer && publisherStats) {
7998
- this.publisher?.stats.commitDeltas(pubPending);
7925
+ if (pubTracer && this.publisher) {
7926
+ this.publisher.stats.commitDeltas(pubPending);
7999
7927
  }
8000
7928
  }
8001
7929
  catch (err) {
@@ -8017,8 +7945,8 @@ class SfuStatsReporter {
8017
7945
  run = async (telemetry) => {
8018
7946
  if (this.isStopped)
8019
7947
  return;
8020
- const [subscriberStats, publisherStats] = await this.sample();
8021
- await this.send(subscriberStats, publisherStats, telemetry);
7948
+ const [subscriberPerfStats, publisherPerfStats] = await this.sample();
7949
+ await this.send(subscriberPerfStats, publisherPerfStats, telemetry);
8022
7950
  };
8023
7951
  scheduleNextReport = () => {
8024
7952
  const intervals = [1500, 3000, 3000, 5000];
@@ -8039,8 +7967,6 @@ class SfuStatsReporter {
8039
7967
  start = () => {
8040
7968
  if (this.options.reporting_interval_ms <= 0)
8041
7969
  return;
8042
- this.observeDevice(this.microphone, 'mic');
8043
- this.observeDevice(this.camera, 'camera');
8044
7970
  this.isStopped = false;
8045
7971
  this.reportCount = 0;
8046
7972
  clearInterval(this.intervalId);
@@ -8049,11 +7975,6 @@ class SfuStatsReporter {
8049
7975
  };
8050
7976
  stop = () => {
8051
7977
  this.isStopped = true;
8052
- this.unsubscribeDevicePermissionsSubscription?.();
8053
- this.unsubscribeDevicePermissionsSubscription = undefined;
8054
- this.unsubscribeListDevicesSubscription?.();
8055
- this.unsubscribeListDevicesSubscription = undefined;
8056
- this.inputDevices.clear();
8057
7978
  clearInterval(this.intervalId);
8058
7979
  this.intervalId = undefined;
8059
7980
  clearTimeout(this.timeoutId);
@@ -8074,8 +7995,8 @@ class SfuStatsReporter {
8074
7995
  return;
8075
7996
  try {
8076
7997
  const [sample] = await timeboxed([this.sample()], 2000);
8077
- const [subscriberStats, publisherStats] = sample;
8078
- this.send(subscriberStats, publisherStats).catch((err) => {
7998
+ const [subscriberPerfStats, publisherPerfStats] = sample;
7999
+ this.send(subscriberPerfStats, publisherPerfStats).catch((err) => {
8079
8000
  this.logger.warn('Failed to flush report stats', err);
8080
8001
  });
8081
8002
  }
@@ -8202,9 +8123,9 @@ class StatsTracer {
8202
8123
  this.maxPendingDeltas = maxPendingDeltas;
8203
8124
  }
8204
8125
  /**
8205
- * Samples the RTCPeerConnection: returns the current stats report and the
8206
- * derived performance stats, and appends the delta-compressed sample to the
8207
- * un-acked delivery chain (retrieved via `getPendingDeltas`).
8126
+ * Samples the RTCPeerConnection: returns the derived performance stats and
8127
+ * appends the delta-compressed sample to the un-acked delivery chain
8128
+ * (retrieved via `getPendingDeltas`).
8208
8129
  *
8209
8130
  * @internal
8210
8131
  */
@@ -8229,7 +8150,7 @@ class StatsTracer {
8229
8150
  this.pendingDeltas.push({ delta, ts: now });
8230
8151
  this.frameTimeHistory = this.frameTimeHistory.slice(-2);
8231
8152
  this.fpsHistory = this.fpsHistory.slice(-2);
8232
- return { performanceStats, stats };
8153
+ return performanceStats;
8233
8154
  });
8234
8155
  };
8235
8156
  /**
@@ -8549,10 +8470,12 @@ class BasePeerConnection {
8549
8470
  sfuClient;
8550
8471
  onReconnectionNeeded;
8551
8472
  onIceConnected;
8473
+ onNoLocalCandidates;
8552
8474
  onPeerConnectionStateChange;
8553
8475
  onRemoteTrackUnmute;
8554
8476
  iceRestartDelay;
8555
8477
  iceHasEverConnected = false;
8478
+ localCandidatesGathered = 0;
8556
8479
  iceRestartTimeout;
8557
8480
  preConnectStuckTimeout;
8558
8481
  isIceRestarting = false;
@@ -8566,7 +8489,7 @@ class BasePeerConnection {
8566
8489
  /**
8567
8490
  * Constructs a new `BasePeerConnection` instance.
8568
8491
  */
8569
- constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
8492
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onNoLocalCandidates, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
8570
8493
  this.peerType = peerType;
8571
8494
  this.sfuClient = sfuClient;
8572
8495
  this.state = state;
@@ -8577,6 +8500,7 @@ class BasePeerConnection {
8577
8500
  this.tag = tag;
8578
8501
  this.onReconnectionNeeded = onReconnectionNeeded;
8579
8502
  this.onIceConnected = onIceConnected;
8503
+ this.onNoLocalCandidates = onNoLocalCandidates;
8580
8504
  this.onPeerConnectionStateChange = onPeerConnectionStateChange;
8581
8505
  this.onRemoteTrackUnmute = onRemoteTrackUnmute;
8582
8506
  this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
@@ -8617,6 +8541,7 @@ class BasePeerConnection {
8617
8541
  this.preConnectStuckTimeout = undefined;
8618
8542
  this.onReconnectionNeeded = undefined;
8619
8543
  this.onIceConnected = undefined;
8544
+ this.onNoLocalCandidates = undefined;
8620
8545
  this.onPeerConnectionStateChange = undefined;
8621
8546
  this.onRemoteTrackUnmute = undefined;
8622
8547
  this.isDisposed = true;
@@ -8720,6 +8645,17 @@ class BasePeerConnection {
8720
8645
  getTrackType = (trackId) => {
8721
8646
  return this.trackIdToTrackType.get(trackId);
8722
8647
  };
8648
+ /**
8649
+ * Returns `true` once ICE has reached `connected`/`completed` at least
8650
+ * once during the lifetime of this peer connection.
8651
+ */
8652
+ hasIceEverConnected = () => this.iceHasEverConnected;
8653
+ /**
8654
+ * Returns `true` when the latest ICE gathering round completed without
8655
+ * producing a single local candidate.
8656
+ */
8657
+ hasNoLocalCandidates = () => this.pc.iceGatheringState === 'complete' &&
8658
+ this.localCandidatesGathered === 0;
8723
8659
  /**
8724
8660
  * Checks if the `RTCPeerConnection` is healthy.
8725
8661
  * It checks the ICE connection state and the peer connection state.
@@ -8742,6 +8678,8 @@ class BasePeerConnection {
8742
8678
  this.logger.debug('null ice candidate');
8743
8679
  return;
8744
8680
  }
8681
+ if (candidate.candidate)
8682
+ this.localCandidatesGathered++;
8745
8683
  const iceCandidate = toJSON(candidate);
8746
8684
  this.sfuClient
8747
8685
  .iceTrickle({ peerType: this.peerType, iceCandidate })
@@ -8806,6 +8744,17 @@ class BasePeerConnection {
8806
8744
  }
8807
8745
  };
8808
8746
  handleConnectionStateUpdate = (state) => {
8747
+ // Fire `onIceConnected` exactly once per peer-connection lifetime, the
8748
+ // first time ICE reaches `connected`/`completed` end-to-end. Recorded
8749
+ // ahead of the guards below so a connection established while offline,
8750
+ // reconnecting or restarting ICE is not missed. Used by `Call` to reset
8751
+ // the unsupported-network failure counter only after WebRTC has actually
8752
+ // recovered, not merely on SFU join.
8753
+ if ((state === 'connected' || state === 'completed') &&
8754
+ !this.iceHasEverConnected) {
8755
+ this.iceHasEverConnected = true;
8756
+ this.onIceConnected?.(this.peerType);
8757
+ }
8809
8758
  const { callingState } = this.state;
8810
8759
  if (callingState === exports.CallingState.OFFLINE)
8811
8760
  return;
@@ -8869,14 +8818,6 @@ class BasePeerConnection {
8869
8818
  break;
8870
8819
  case 'connected':
8871
8820
  case 'completed':
8872
- // Fire `onIceConnected` exactly once per peer-connection lifetime —
8873
- // the first time ICE reaches `connected`/`completed` end-to-end.
8874
- // Used by `Call` to reset the unsupported-network failure counter
8875
- // only after WebRTC has actually recovered, not merely on SFU join.
8876
- if (!this.iceHasEverConnected) {
8877
- this.iceHasEverConnected = true;
8878
- this.onIceConnected?.(this.peerType);
8879
- }
8880
8821
  // clear any scheduled restartICE since the connection is healthy
8881
8822
  if (this.iceRestartTimeout) {
8882
8823
  this.logger.info('connected connection, canceling restartICE');
@@ -8902,7 +8843,17 @@ class BasePeerConnection {
8902
8843
  * Handles the ICE gathering state change event.
8903
8844
  */
8904
8845
  onIceGatherChange = () => {
8905
- this.logger.debug(`ICE Gathering State`, this.pc.iceGatheringState);
8846
+ const state = this.pc.iceGatheringState;
8847
+ this.logger.debug(`ICE Gathering State`, state);
8848
+ if (state === 'gathering') {
8849
+ this.localCandidatesGathered = 0;
8850
+ }
8851
+ else if (state === 'complete' &&
8852
+ this.localCandidatesGathered === 0 &&
8853
+ !this.iceHasEverConnected) {
8854
+ this.logger.info('ICE gathering completed without local candidates');
8855
+ this.onNoLocalCandidates?.(this.peerType);
8856
+ }
8906
8857
  };
8907
8858
  /**
8908
8859
  * Handles the signaling state change event.
@@ -10778,15 +10729,16 @@ class StreamSfuClient {
10778
10729
  /**
10779
10730
  * Decides what a ringing call should do next, based on the current call state.
10780
10731
  *
10781
- * The `call.accepted`, `call.rejected` and `call.missed` handlers and the ring
10782
- * state poller both run this. They differ only in how the state got there: the
10783
- * handlers rely on `CallState.updateFromEvent`, which runs before them, and the
10784
- * poller applies the polled ring state itself.
10732
+ * The `call.accepted` and `call.rejected` handlers, the ring state poller and
10733
+ * the rewatch after a WS reconnect all run this. They differ only in how the
10734
+ * state got there: the handlers rely on `CallState.updateFromEvent`, which runs
10735
+ * before them, while the poller applies the polled ring state itself and the
10736
+ * rewatch applies the queried call state.
10785
10737
  *
10786
10738
  * @param call the call to reconcile.
10787
- * @param joinSource which of the two triggered this run, reported on the
10788
- * caller's join: `ring-ws` for the event handlers, `ring-poll-api` for the
10789
- * poller.
10739
+ * @param joinSource what triggered this run, reported on the caller's join:
10740
+ * `ring-ws` for the event handlers, `ring-poll-api` for the poller and the
10741
+ * rewatch (both come from an API response).
10790
10742
  * @returns whether the ring reached a terminal state. A failed join is not
10791
10743
  * terminal: the caller should keep trying while the ring is open.
10792
10744
  */
@@ -10841,10 +10793,16 @@ const reconcileAsCaller = async (call, joinSource) => {
10841
10793
  return false;
10842
10794
  };
10843
10795
  // the current user's own accept or reject, on this or another device, is
10844
- // handled by `resolveOwnRingOutcome`, and `call.ended` by `watchCallEnded`.
10796
+ // handled by `resolveOwnRingOutcome`, and a live `call.ended` by
10797
+ // `watchCallEnded`. An end missed while offline is handled here.
10845
10798
  const reconcileAsCallee = async (call) => {
10846
- const createdById = call.state.createdBy?.id;
10847
- const rejectedBy = call.state.session?.rejected_by ?? {};
10799
+ const { session, endedAt, createdBy } = call.state;
10800
+ if (endedAt || session?.ended_at) {
10801
+ call.logger.info('ring: the call has ended, leaving');
10802
+ return leave(call, { reason: 'ended', message: 'ring: call ended' });
10803
+ }
10804
+ const createdById = createdBy?.id;
10805
+ const rejectedBy = session?.rejected_by ?? {};
10848
10806
  if (createdById && rejectedBy[createdById]) {
10849
10807
  call.logger.info('ring: the caller cancelled, leaving');
10850
10808
  return leave(call, {
@@ -10893,7 +10851,7 @@ class RingStatePoller {
10893
10851
  unsubscribe = [];
10894
10852
  constructor(call, options = {}) {
10895
10853
  this.call = call;
10896
- this.startAfterMs = options.startAfterMs ?? 15_000;
10854
+ this.startAfterMs = options.startAfterMs ?? 9_000;
10897
10855
  this.intervalMs = options.intervalMs ?? 5_000;
10898
10856
  }
10899
10857
  /**
@@ -11558,12 +11516,33 @@ const registerRingingCallEventHandlers = (call) => {
11558
11516
  };
11559
11517
 
11560
11518
  const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
11519
+ const toElementState = (element) => {
11520
+ const stream = element.srcObject;
11521
+ const [track] = stream?.getAudioTracks?.() ?? [];
11522
+ return {
11523
+ paused: element.paused,
11524
+ muted: element.muted,
11525
+ volume: Math.round(element.volume * 100) / 100,
11526
+ readyState: element.readyState,
11527
+ sinkId: element.sinkId,
11528
+ track: track
11529
+ ? {
11530
+ enabled: track.enabled,
11531
+ muted: track.muted,
11532
+ readyState: track.readyState,
11533
+ }
11534
+ : null,
11535
+ };
11536
+ };
11561
11537
  /**
11562
11538
  * Tracks audio element bindings and periodically warns about
11563
11539
  * remote participants whose audio streams have no bound element.
11540
+ * On the same tick, it traces the playback state of the bound elements,
11541
+ * limited to the bindings whose state changed since the previous trace.
11564
11542
  */
11565
11543
  class AudioBindingsWatchdog {
11566
11544
  bindings = new Map();
11545
+ tracedStates = new Map();
11567
11546
  enabled = true;
11568
11547
  watchdogInterval;
11569
11548
  unsubscribeCallingState;
@@ -11574,8 +11553,6 @@ class AudioBindingsWatchdog {
11574
11553
  this.tracer = tracer;
11575
11554
  this.state = state;
11576
11555
  this.unsubscribeCallingState = createSubscription(state.callingState$, (callingState) => {
11577
- if (!this.enabled)
11578
- return;
11579
11556
  if (callingState !== exports.CallingState.JOINED) {
11580
11557
  this.stop();
11581
11558
  }
@@ -11604,15 +11581,15 @@ class AudioBindingsWatchdog {
11604
11581
  this.bindings.delete(toBindingKey(sessionId, trackType));
11605
11582
  };
11606
11583
  /**
11607
- * Enables or disables the watchdog.
11608
- * When disabled, the periodic check stops but bindings are still tracked.
11584
+ * Enables or disables the dangling binding warnings.
11585
+ * Bindings are still tracked and their playback state is still traced.
11609
11586
  */
11610
11587
  setEnabled = (enabled) => {
11611
11588
  this.enabled = enabled;
11612
11589
  if (enabled) {
11613
11590
  this.start();
11614
11591
  }
11615
- else {
11592
+ else if (this.state.callingState !== exports.CallingState.JOINED) {
11616
11593
  this.stop();
11617
11594
  }
11618
11595
  };
@@ -11622,33 +11599,61 @@ class AudioBindingsWatchdog {
11622
11599
  dispose = () => {
11623
11600
  this.stop();
11624
11601
  this.bindings.clear();
11602
+ this.tracedStates.clear();
11625
11603
  this.unsubscribeCallingState();
11626
11604
  };
11627
11605
  start = () => {
11628
11606
  clearInterval(this.watchdogInterval);
11629
11607
  this.watchdogInterval = setInterval(() => {
11630
- const danglingUserIds = [];
11631
- for (const p of this.state.participants) {
11632
- if (p.isLocalParticipant)
11633
- continue;
11634
- const { audioStream, screenShareAudioStream, sessionId, userId, publishedTracks, } = p;
11635
- if (audioStream &&
11636
- publishedTracks.includes(TrackType.AUDIO) &&
11637
- !this.bindings.has(toBindingKey(sessionId))) {
11638
- danglingUserIds.push(userId);
11639
- }
11640
- if (screenShareAudioStream &&
11641
- publishedTracks.includes(TrackType.SCREEN_SHARE_AUDIO) &&
11642
- !this.bindings.has(toBindingKey(sessionId, 'screenShareAudioTrack'))) {
11643
- danglingUserIds.push(userId);
11644
- }
11608
+ if (this.enabled)
11609
+ this.warnAboutDanglingBindings();
11610
+ this.traceBindingStates();
11611
+ }, 3000);
11612
+ };
11613
+ warnAboutDanglingBindings = () => {
11614
+ const danglingUserIds = [];
11615
+ for (const p of this.state.participants) {
11616
+ if (p.isLocalParticipant)
11617
+ continue;
11618
+ const { audioStream, screenShareAudioStream, sessionId, userId, publishedTracks, } = p;
11619
+ if (audioStream &&
11620
+ publishedTracks.includes(TrackType.AUDIO) &&
11621
+ !this.bindings.has(toBindingKey(sessionId))) {
11622
+ danglingUserIds.push(userId);
11645
11623
  }
11646
- if (danglingUserIds.length > 0) {
11647
- const key = 'audioBinding.danglingWarning';
11648
- this.tracer.traceOnce(key, key, danglingUserIds);
11649
- this.logger.warn(`Dangling audio bindings detected. Did you forget to bind the audio element? user_ids: ${danglingUserIds}.`);
11624
+ if (screenShareAudioStream &&
11625
+ publishedTracks.includes(TrackType.SCREEN_SHARE_AUDIO) &&
11626
+ !this.bindings.has(toBindingKey(sessionId, 'screenShareAudioTrack'))) {
11627
+ danglingUserIds.push(userId);
11650
11628
  }
11651
- }, 3000);
11629
+ }
11630
+ if (danglingUserIds.length > 0) {
11631
+ const key = 'audioBinding.danglingWarning';
11632
+ this.tracer.traceOnce(key, key, danglingUserIds);
11633
+ this.logger.warn(`Dangling audio bindings detected. Did you forget to bind the audio element? user_ids: ${danglingUserIds}.`);
11634
+ }
11635
+ };
11636
+ traceBindingStates = () => {
11637
+ const changes = {};
11638
+ let hasChanges = false;
11639
+ for (const [key, element] of this.bindings) {
11640
+ const state = toElementState(element);
11641
+ const serialized = JSON.stringify(state);
11642
+ if (this.tracedStates.get(key) === serialized)
11643
+ continue;
11644
+ this.tracedStates.set(key, serialized);
11645
+ changes[key] = state;
11646
+ hasChanges = true;
11647
+ }
11648
+ for (const key of this.tracedStates.keys()) {
11649
+ if (this.bindings.has(key))
11650
+ continue;
11651
+ this.tracedStates.delete(key);
11652
+ changes[key] = null;
11653
+ hasChanges = true;
11654
+ }
11655
+ if (hasChanges)
11656
+ this.tracer.trace('audioBinding.state', changes);
11652
11657
  };
11653
11658
  stop = () => {
11654
11659
  clearInterval(this.watchdogInterval);
@@ -11927,7 +11932,9 @@ class TrackSubscriptionManager {
11927
11932
  class MediaPlaybackWatchdog {
11928
11933
  logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
11929
11934
  kind;
11935
+ traceInfo;
11930
11936
  isBlocked;
11937
+ onGiveUp;
11931
11938
  element;
11932
11939
  tracer;
11933
11940
  controller = new AbortController();
@@ -11939,8 +11946,14 @@ class MediaPlaybackWatchdog {
11939
11946
  constructor(opts) {
11940
11947
  this.element = opts.element;
11941
11948
  this.kind = opts.kind;
11949
+ this.traceInfo = {
11950
+ kind: opts.kind,
11951
+ sessionId: opts.sessionId,
11952
+ trackType: opts.trackType,
11953
+ };
11942
11954
  this.tracer = opts.tracer;
11943
11955
  this.isBlocked = opts.isBlocked ?? (() => false);
11956
+ this.onGiveUp = opts.onGiveUp ?? (() => { });
11944
11957
  this.attach();
11945
11958
  }
11946
11959
  attach = () => {
@@ -11958,6 +11971,23 @@ class MediaPlaybackWatchdog {
11958
11971
  this.controller.abort();
11959
11972
  this.clearTimers();
11960
11973
  };
11974
+ /**
11975
+ * Starts a fresh recovery cycle, e.g. after a media interruption ended.
11976
+ * Unlike a regular pause-triggered recovery, it also tries elements that
11977
+ * were skipped as blocked or not ready, since those conditions may have
11978
+ * cleared together with the interruption.
11979
+ */
11980
+ rearm = () => {
11981
+ if (this.disposed)
11982
+ return;
11983
+ this.clearTimers();
11984
+ this.attempt = 0;
11985
+ this.stopped = false;
11986
+ const { paused, srcObject, ended } = this.element;
11987
+ if (!paused || !srcObject || ended)
11988
+ return;
11989
+ this.pendingTimer = setTimeout(this.attemptPlay, 0);
11990
+ };
11961
11991
  clearTimers = () => {
11962
11992
  if (this.pendingTimer)
11963
11993
  clearTimeout(this.pendingTimer);
@@ -11979,17 +12009,34 @@ class MediaPlaybackWatchdog {
11979
12009
  if (this.element.paused)
11980
12010
  return;
11981
12011
  this.tracer.trace('mediaPlayback.recover.success', {
11982
- kind: this.kind,
12012
+ ...this.traceInfo,
11983
12013
  attempts: this.attempt,
11984
12014
  });
11985
12015
  this.attempt = 0;
11986
12016
  this.stopped = false;
11987
12017
  };
12018
+ /**
12019
+ * Media elements backed by a MediaStream fire `suspend` routinely (after
12020
+ * the initial load, on source swaps, on layer switches), so a `suspend`
12021
+ * that needs no recovery is ignored without a trace. An explicit `pause`
12022
+ * is rare and always traced, along with the reason recovery was skipped.
12023
+ */
11988
12024
  onPauseOrSuspend = (event) => {
11989
- if (this.disposed || this.stopped)
12025
+ if (this.disposed || this.stopped || this.pendingTimer)
12026
+ return;
12027
+ const skipReason = this.computeSkipReason();
12028
+ if (skipReason) {
12029
+ if (event.type === 'pause') {
12030
+ this.tracer.trace('mediaPlayback.paused', {
12031
+ ...this.traceInfo,
12032
+ reason: event.type,
12033
+ skipped: skipReason,
12034
+ });
12035
+ }
11990
12036
  return;
12037
+ }
11991
12038
  this.tracer.trace('mediaPlayback.paused', {
11992
- kind: this.kind,
12039
+ ...this.traceInfo,
11993
12040
  reason: event.type,
11994
12041
  });
11995
12042
  if (this.settleTimer)
@@ -12003,7 +12050,7 @@ class MediaPlaybackWatchdog {
12003
12050
  const skipReason = this.computeSkipReason();
12004
12051
  if (skipReason) {
12005
12052
  this.tracer.trace('mediaPlayback.recover.skipped', {
12006
- kind: this.kind,
12053
+ ...this.traceInfo,
12007
12054
  reason: skipReason,
12008
12055
  });
12009
12056
  return;
@@ -12012,9 +12059,10 @@ class MediaPlaybackWatchdog {
12012
12059
  this.stopped = true;
12013
12060
  this.clearTimers();
12014
12061
  this.tracer.trace('mediaPlayback.recover.giveUp', {
12015
- kind: this.kind,
12062
+ ...this.traceInfo,
12016
12063
  attempts: this.attempt,
12017
12064
  });
12065
+ this.onGiveUp();
12018
12066
  return;
12019
12067
  }
12020
12068
  const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
@@ -12042,7 +12090,7 @@ class MediaPlaybackWatchdog {
12042
12090
  return;
12043
12091
  this.attempt += 1;
12044
12092
  this.tracer.trace('mediaPlayback.recover.attempt', {
12045
- kind: this.kind,
12093
+ ...this.traceInfo,
12046
12094
  attempt: this.attempt,
12047
12095
  });
12048
12096
  try {
@@ -12057,6 +12105,41 @@ class MediaPlaybackWatchdog {
12057
12105
  };
12058
12106
  }
12059
12107
 
12108
+ /**
12109
+ * Checks whether media playback and capture are likely interrupted: the page
12110
+ * is hidden, or WebKit reports the audio session as interrupted (a phone
12111
+ * call, Siri, or another app taking over audio).
12112
+ */
12113
+ const isMediaInterrupted = () => {
12114
+ if (typeof document === 'undefined')
12115
+ return false;
12116
+ return (document.visibilityState === 'hidden' ||
12117
+ navigator.audioSession?.state === 'interrupted');
12118
+ };
12119
+ /**
12120
+ * Invokes `onResumed` every time the page leaves a media interruption,
12121
+ * as defined by {@link isMediaInterrupted}.
12122
+ *
12123
+ * @param onResumed the callback to invoke.
12124
+ * @returns a function that stops watching.
12125
+ */
12126
+ const watchMediaInterruptions = (onResumed) => {
12127
+ if (typeof document === 'undefined')
12128
+ return () => { };
12129
+ let interrupted = isMediaInterrupted();
12130
+ const check = () => {
12131
+ const wasInterrupted = interrupted;
12132
+ interrupted = isMediaInterrupted();
12133
+ if (wasInterrupted && !interrupted)
12134
+ onResumed();
12135
+ };
12136
+ const controller = new AbortController();
12137
+ const { signal } = controller;
12138
+ document.addEventListener('visibilitychange', check, { signal });
12139
+ navigator.audioSession?.addEventListener('statechange', check, { signal });
12140
+ return () => controller.abort();
12141
+ };
12142
+
12060
12143
  /**
12061
12144
  * A manager class that handles dynascale related tasks like:
12062
12145
  *
@@ -12070,6 +12153,9 @@ class DynascaleManager {
12070
12153
  tracer;
12071
12154
  useWebAudio = false;
12072
12155
  audioContext;
12156
+ playbackWatchdogs = new Set();
12157
+ stopWatchingInterruptions;
12158
+ stopTracingAudioSession;
12073
12159
  trackSubscriptionManager;
12074
12160
  blockedAudioTracker;
12075
12161
  /**
@@ -12086,6 +12172,10 @@ class DynascaleManager {
12086
12172
  * Closes the audio context if it was created.
12087
12173
  */
12088
12174
  dispose = async () => {
12175
+ this.stopWatchingInterruptions?.();
12176
+ this.stopWatchingInterruptions = undefined;
12177
+ this.stopTracingAudioSession?.();
12178
+ this.stopTracingAudioSession = undefined;
12089
12179
  const context = this.audioContext;
12090
12180
  if (context && context.state !== 'closed') {
12091
12181
  document.removeEventListener('click', this.resumeAudioContext);
@@ -12225,10 +12315,12 @@ class DynascaleManager {
12225
12315
  // without prior user interaction:
12226
12316
  // https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
12227
12317
  videoElement.muted = true;
12228
- const playbackWatchdog = new MediaPlaybackWatchdog({
12318
+ const playbackWatchdog = this.createPlaybackWatchdog({
12229
12319
  element: videoElement,
12230
12320
  kind: 'video',
12231
12321
  tracer: this.tracer,
12322
+ sessionId,
12323
+ trackType,
12232
12324
  });
12233
12325
  const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
12234
12326
  const streamSubscription = participant$
@@ -12256,7 +12348,7 @@ class DynascaleManager {
12256
12348
  publishedTracksSubscription?.unsubscribe();
12257
12349
  streamSubscription.unsubscribe();
12258
12350
  resizeObserver?.disconnect();
12259
- playbackWatchdog.dispose();
12351
+ this.disposePlaybackWatchdog(playbackWatchdog);
12260
12352
  };
12261
12353
  };
12262
12354
  /**
@@ -12274,9 +12366,10 @@ class DynascaleManager {
12274
12366
  const participant = this.callState.findParticipantBySessionId(sessionId);
12275
12367
  if (!participant || participant.isLocalParticipant)
12276
12368
  return;
12369
+ this.traceAudioSessionState();
12277
12370
  const participant$ = this.callState.participants$.pipe(rxjs.map((ps) => ps.find((p) => p.sessionId === sessionId)), rxjs.takeWhile((p) => !!p), rxjs.distinctUntilChanged(), rxjs.shareReplay({ bufferSize: 1, refCount: true }));
12278
12371
  const updateSinkId = (deviceId, audioContext) => {
12279
- if (!deviceId)
12372
+ if (!deviceId || !this.speaker.state.isDeviceSelectionSupported)
12280
12373
  return;
12281
12374
  if ('setSinkId' in audioElement) {
12282
12375
  audioElement.setSinkId(deviceId).catch((e) => {
@@ -12293,7 +12386,9 @@ class DynascaleManager {
12293
12386
  let sourceNode = undefined;
12294
12387
  let gainNode = undefined;
12295
12388
  let audioWatchdog = undefined;
12389
+ let blockedByGiveUp = false;
12296
12390
  const clearBlockedAudio = () => {
12391
+ blockedByGiveUp = false;
12297
12392
  if (!this.blockedAudioTracker.isBlocked(audioElement))
12298
12393
  return;
12299
12394
  this.blockedAudioTracker.markBlocked(audioElement, false);
@@ -12301,15 +12396,18 @@ class DynascaleManager {
12301
12396
  audioElement.addEventListener('playing', clearBlockedAudio);
12302
12397
  const isAudioTrack = trackType === 'audioTrack';
12303
12398
  const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
12399
+ let sourceChangeTimer;
12304
12400
  const updateMediaStreamSubscription = participant$
12305
12401
  .pipe(rxjs.distinctUntilKeyChanged(trackKey))
12306
12402
  .subscribe((p) => {
12307
12403
  const source = isAudioTrack ? p.audioStream : p.screenShareAudioStream;
12308
12404
  if (audioElement.srcObject === source)
12309
12405
  return;
12310
- setTimeout(() => {
12406
+ clearTimeout(sourceChangeTimer);
12407
+ sourceChangeTimer = setTimeout(() => {
12408
+ sourceChangeTimer = undefined;
12311
12409
  audioElement.srcObject = source ?? null;
12312
- audioWatchdog?.dispose();
12410
+ this.disposePlaybackWatchdog(audioWatchdog);
12313
12411
  audioWatchdog = undefined;
12314
12412
  if (!source) {
12315
12413
  clearBlockedAudio();
@@ -12340,15 +12438,25 @@ class DynascaleManager {
12340
12438
  this.tracer.trace('audioPlaybackError', e.message);
12341
12439
  if (e.name === 'NotAllowedError') {
12342
12440
  this.tracer.trace('audioPlaybackBlocked', null);
12441
+ blockedByGiveUp = false;
12343
12442
  this.blockedAudioTracker.markBlocked(audioElement, true, sessionId);
12344
12443
  }
12345
12444
  this.logger.warn(`Failed to play audio stream`, e);
12346
12445
  });
12347
- audioWatchdog = new MediaPlaybackWatchdog({
12446
+ audioWatchdog = this.createPlaybackWatchdog({
12348
12447
  element: audioElement,
12349
12448
  kind: 'audio',
12350
12449
  tracer: this.tracer,
12351
- isBlocked: () => this.blockedAudioTracker.isBlocked(audioElement),
12450
+ sessionId,
12451
+ trackType,
12452
+ // only an autoplay-policy block needs a user gesture; a give-up
12453
+ // block is for the UI and must not stop a re-armed cycle
12454
+ isBlocked: () => !blockedByGiveUp &&
12455
+ this.blockedAudioTracker.isBlocked(audioElement),
12456
+ onGiveUp: () => {
12457
+ blockedByGiveUp = true;
12458
+ this.blockedAudioTracker.markBlocked(audioElement, true, sessionId);
12459
+ },
12352
12460
  });
12353
12461
  }
12354
12462
  const { selectedDevice } = this.speaker.state;
@@ -12356,7 +12464,7 @@ class DynascaleManager {
12356
12464
  updateSinkId(selectedDevice, audioContext);
12357
12465
  });
12358
12466
  });
12359
- const sinkIdSubscription = !('setSinkId' in audioElement)
12467
+ const sinkIdSubscription = !this.speaker.state.isDeviceSelectionSupported
12360
12468
  ? null
12361
12469
  : this.speaker.state.selectedDevice$.subscribe((deviceId) => {
12362
12470
  const audioContext = this.getOrCreateAudioContext();
@@ -12378,13 +12486,51 @@ class DynascaleManager {
12378
12486
  sinkIdSubscription?.unsubscribe();
12379
12487
  volumeSubscription.unsubscribe();
12380
12488
  updateMediaStreamSubscription.unsubscribe();
12489
+ clearTimeout(sourceChangeTimer);
12381
12490
  audioElement.srcObject = null;
12382
12491
  sourceNode?.disconnect();
12383
12492
  gainNode?.disconnect();
12384
- audioWatchdog?.dispose();
12493
+ this.disposePlaybackWatchdog(audioWatchdog);
12385
12494
  audioWatchdog = undefined;
12386
12495
  };
12387
12496
  };
12497
+ /**
12498
+ * Creates a playback watchdog that is re-armed whenever a media
12499
+ * interruption (backgrounding, phone call, Siri) ends.
12500
+ */
12501
+ createPlaybackWatchdog = (opts) => {
12502
+ const watchdog = new MediaPlaybackWatchdog(opts);
12503
+ this.playbackWatchdogs.add(watchdog);
12504
+ this.stopWatchingInterruptions ??= watchMediaInterruptions(this.rearmPlayback);
12505
+ return watchdog;
12506
+ };
12507
+ disposePlaybackWatchdog = (watchdog) => {
12508
+ if (!watchdog)
12509
+ return;
12510
+ watchdog.dispose();
12511
+ this.playbackWatchdogs.delete(watchdog);
12512
+ };
12513
+ rearmPlayback = () => {
12514
+ this.tracer.trace('mediaPlayback.rearm', this.playbackWatchdogs.size);
12515
+ this.playbackWatchdogs.forEach((watchdog) => watchdog.rearm());
12516
+ };
12517
+ /**
12518
+ * Traces the WebKit audio session state (initial value and changes) in
12519
+ * both playback modes, as it explains most iOS playback interruptions.
12520
+ */
12521
+ traceAudioSessionState = () => {
12522
+ const audioSession = navigator.audioSession;
12523
+ if (!audioSession || this.stopTracingAudioSession)
12524
+ return;
12525
+ const trace = () => {
12526
+ this.tracer.trace('audioSession.state', audioSession.state);
12527
+ };
12528
+ trace();
12529
+ audioSession.addEventListener('statechange', trace);
12530
+ this.stopTracingAudioSession = () => {
12531
+ audioSession.removeEventListener('statechange', trace);
12532
+ };
12533
+ };
12388
12534
  getOrCreateAudioContext = () => {
12389
12535
  if (!this.useWebAudio)
12390
12536
  return;
@@ -12407,7 +12553,6 @@ class DynascaleManager {
12407
12553
  audioSession.type = 'play-and-record';
12408
12554
  let isSessionInterrupted = false;
12409
12555
  audioSession.addEventListener('statechange', () => {
12410
- this.tracer.trace('audioSession.state', audioSession.state);
12411
12556
  if (audioSession.state === 'interrupted') {
12412
12557
  isSessionInterrupted = true;
12413
12558
  }
@@ -12982,10 +13127,17 @@ const getDevices = (permission, kind, tracer) => {
12982
13127
  /**
12983
13128
  * Tells if the browser supports audio output change on 'audio' elements,
12984
13129
  * see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId.
13130
+ *
13131
+ * WebKit (Safari, iOS browsers, WKWebView) is reported as unsupported:
13132
+ * setSinkId requires a user gesture, a per-device output can run at a
13133
+ * different sample rate than the remote audio mixer (pitched/slowed audio),
13134
+ * and audio goes silent when the selected device disconnects.
12985
13135
  */
12986
13136
  const checkIfAudioOutputChangeSupported = () => {
12987
13137
  if (typeof document === 'undefined')
12988
13138
  return false;
13139
+ if (isWebKit())
13140
+ return false;
12989
13141
  const element = document.createElement('audio');
12990
13142
  return 'setSinkId' in element;
12991
13143
  };
@@ -13491,6 +13643,7 @@ class DeviceManager {
13491
13643
  ? forceStopOrOptions
13492
13644
  : (forceStopOrOptions?.forceStop ?? false);
13493
13645
  this.state.prevStatus = this.state.optimisticStatus;
13646
+ this.state.setError(undefined);
13494
13647
  if (!forceStop && this.state.optimisticStatus === 'disabled') {
13495
13648
  return;
13496
13649
  }
@@ -13500,6 +13653,7 @@ class DeviceManager {
13500
13653
  const stopTracks = forceStop || this.state.disableMode === 'stop-tracks';
13501
13654
  await this.muteStream(stopTracks);
13502
13655
  this.state.setStatus('disabled');
13656
+ this.state.setError(undefined);
13503
13657
  }
13504
13658
  finally {
13505
13659
  if (!signal.aborted) {
@@ -13795,7 +13949,11 @@ class DeviceManager {
13795
13949
  };
13796
13950
  // the rootStream represents the stream coming from the actual device
13797
13951
  // e.g. camera or microphone stream
13952
+ this.state.setError(undefined);
13798
13953
  rootStreamPromise = this.getSelectedStream(constraints);
13954
+ rootStreamPromise.catch((error) => {
13955
+ this.state.setError(error);
13956
+ });
13799
13957
  // we publish the last MediaStream of the chain
13800
13958
  stream = await this.filters.reduce((parent, entry) => parent
13801
13959
  .then((inputStream) => {
@@ -13966,9 +14124,22 @@ class DeviceManager {
13966
14124
  }
13967
14125
  const devices = getCurrentValue(this.listDevices()) || [];
13968
14126
  const currentDevice = this.findDevice(devices, selectedDevice) ??
13969
- createSyntheticDevice(selectedDevice, deviceKind);
14127
+ createSyntheticDevice(selectedDevice, deviceKind, this.getActiveTrackLabel(selectedDevice));
13970
14128
  writePreferences(currentDevice, deviceKey, muted, storageKey);
13971
14129
  }
14130
+ /**
14131
+ * Returns the label of the active track if it was captured from the given
14132
+ * device. Used as a fallback when the device list hasn't been enumerated yet,
14133
+ * so the persisted preference still carries the device label.
14134
+ */
14135
+ getActiveTrackLabel = (deviceId) => {
14136
+ const stream = this.state.rootMediaStream;
14137
+ const tracks = this.trackType === TrackType.AUDIO
14138
+ ? stream?.getAudioTracks()
14139
+ : stream?.getVideoTracks();
14140
+ const track = tracks?.find((t) => t.getSettings().deviceId === deviceId);
14141
+ return track?.label;
14142
+ };
13972
14143
  async applyPersistedPreferences(enabledInCallType, forceDisabled = false) {
13973
14144
  const deviceKey = this.trackType === TrackType.AUDIO ? 'microphone' : 'camera';
13974
14145
  const preferences = readPreferences(this.devicePersistence.storageKey);
@@ -14020,6 +14191,7 @@ class DeviceManagerState {
14020
14191
  rootMediaStreamSubject = new rxjs.BehaviorSubject(undefined);
14021
14192
  selectedDeviceSubject = new rxjs.BehaviorSubject(undefined);
14022
14193
  defaultConstraintsSubject = new rxjs.BehaviorSubject(undefined);
14194
+ errorSubject = new rxjs.BehaviorSubject(undefined);
14023
14195
  /**
14024
14196
  * @internal
14025
14197
  */
@@ -14054,6 +14226,13 @@ class DeviceManagerState {
14054
14226
  * The default constraints for the device.
14055
14227
  */
14056
14228
  defaultConstraints$ = this.defaultConstraintsSubject.asObservable();
14229
+ /**
14230
+ * An Observable that emits the error of the last failed attempt to start
14231
+ * the device (e.g. the device is in use by another app), or `undefined`.
14232
+ * For screen share, this includes the user dismissing the picker.
14233
+ * Cleared when the device is started again or disabled.
14234
+ */
14235
+ error$ = this.errorSubject.asObservable().pipe(rxjs.distinctUntilChanged());
14057
14236
  /**
14058
14237
  * An observable that will emit `true` if browser/system permission
14059
14238
  * is granted (or at least hasn't been denied), `false` otherwise.
@@ -14121,6 +14300,19 @@ class DeviceManagerState {
14121
14300
  get rootMediaStream() {
14122
14301
  return getCurrentValue(this.rootMediaStream$);
14123
14302
  }
14303
+ /**
14304
+ * The error of the last failed attempt to start the device, or `undefined`.
14305
+ */
14306
+ get error() {
14307
+ return getCurrentValue(this.error$);
14308
+ }
14309
+ /**
14310
+ * @internal
14311
+ * @param error the error to set.
14312
+ */
14313
+ setError(error) {
14314
+ setCurrentValue(this.errorSubject, error);
14315
+ }
14124
14316
  /**
14125
14317
  * @internal
14126
14318
  * @param status
@@ -14702,9 +14894,11 @@ class MicrophoneManager extends AudioDeviceManager {
14702
14894
  this.call.state.callingState$,
14703
14895
  this.call.state.ownCapabilities$,
14704
14896
  this.state.selectedDevice$,
14705
- this.state.status$,
14706
14897
  this.state.browserPermissionState$,
14707
- ]), async ([callingState, ownCapabilities, deviceId, status, permissionState,]) => {
14898
+ // only a trigger: queued handlers can run after the status changed
14899
+ // again, so the live status is read below
14900
+ this.state.status$,
14901
+ ]), async ([callingState, ownCapabilities, deviceId, permissionState]) => {
14708
14902
  try {
14709
14903
  if (callingState === exports.CallingState.LEFT) {
14710
14904
  // The muted-recording-prepared mode is reset in `callManager.stop()`
@@ -14719,7 +14913,7 @@ class MicrophoneManager extends AudioDeviceManager {
14719
14913
  return;
14720
14914
  if (ownCapabilities.includes(OwnCapability.SEND_AUDIO)) {
14721
14915
  const hasPermission = await this.hasPermission(permissionState);
14722
- if (hasPermission && status !== 'enabled') {
14916
+ if (hasPermission && this.state.status !== 'enabled') {
14723
14917
  this.setMutedRecordingPrepared(true);
14724
14918
  await this.startSpeakingWhileMutedDetection(deviceId);
14725
14919
  }
@@ -15019,6 +15213,52 @@ class MicrophoneManager extends AudioDeviceManager {
15019
15213
  await this.call.ensureMediaFactory();
15020
15214
  return getAudioStream(constraints, this.call.tracer);
15021
15215
  }
15216
+ /**
15217
+ * On mute, hands a clone of the live mic track to the speaking while muted
15218
+ * detection, so the device stays captured after the mic tracks are stopped.
15219
+ * Releasing and re-acquiring the mic flips Bluetooth headsets between audio
15220
+ * profiles.
15221
+ */
15222
+ async muteStream(stopTracks = true) {
15223
+ const [track] = this.state.rootMediaStream?.getAudioTracks() ?? [];
15224
+ const clone = track?.readyState === 'live' &&
15225
+ this.state.optimisticStatus === 'disabled' &&
15226
+ this.canHandOffMic()
15227
+ ? track.clone()
15228
+ : undefined;
15229
+ try {
15230
+ await super.muteStream(stopTracks);
15231
+ }
15232
+ catch (err) {
15233
+ clone?.stop();
15234
+ throw err;
15235
+ }
15236
+ if (!clone)
15237
+ return;
15238
+ // the mic, the call, or the selected device may have changed while
15239
+ // unpublishing
15240
+ const deviceId = resolveDeviceId(clone.getSettings().deviceId, 'audioinput');
15241
+ if (clone.readyState !== 'live' ||
15242
+ !this.canHandOffMic() ||
15243
+ deviceId !== this.state.selectedDevice) {
15244
+ clone.stop();
15245
+ return;
15246
+ }
15247
+ this.startSpeakingWhileMutedDetection(deviceId, clone).catch((err) => {
15248
+ clone.stop();
15249
+ this.logger.warn('Could not hand off mic to speaking while muted', err);
15250
+ });
15251
+ }
15252
+ /**
15253
+ * Whether a mute can hand the mic over to the speaking while muted detection.
15254
+ */
15255
+ canHandOffMic = () => {
15256
+ const { callingState, ownCapabilities } = this.call.state;
15257
+ return (!isReactNative() &&
15258
+ this.speakingWhileMutedNotificationEnabled &&
15259
+ callingState === exports.CallingState.JOINED &&
15260
+ ownCapabilities.includes(OwnCapability.SEND_AUDIO));
15261
+ };
15022
15262
  doSetAudioBitrateProfile(profile) {
15023
15263
  this.setDefaultConstraints({
15024
15264
  ...this.state.defaultConstraints,
@@ -15038,10 +15278,13 @@ class MicrophoneManager extends AudioDeviceManager {
15038
15278
  }
15039
15279
  }
15040
15280
  }
15041
- async startSpeakingWhileMutedDetection(deviceId) {
15281
+ async startSpeakingWhileMutedDetection(deviceId, track) {
15042
15282
  await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
15043
- if (this.soundDetectorCleanup && this.soundDetectorDeviceId === deviceId)
15283
+ if (this.soundDetectorCleanup &&
15284
+ this.soundDetectorDeviceId === deviceId) {
15285
+ track?.stop();
15044
15286
  return;
15287
+ }
15045
15288
  await this.teardownSpeakingWhileMutedDetection();
15046
15289
  if (isReactNative()) {
15047
15290
  const speechActivity = globalThis.streamRNVideoSDK?.nativeEvents?.speechActivity;
@@ -15057,11 +15300,14 @@ class MicrophoneManager extends AudioDeviceManager {
15057
15300
  };
15058
15301
  }
15059
15302
  else {
15060
- // Need to start a new stream that's not connected to publisher
15061
- const stream = await this.getStream({
15062
- ...this.state.defaultConstraints,
15063
- deviceId: { exact: deviceId },
15064
- });
15303
+ // Use the handed-off mic track, or start a new stream that's not
15304
+ // connected to publisher
15305
+ const stream = track
15306
+ ? new MediaStream([track])
15307
+ : await this.getStream({
15308
+ ...this.state.defaultConstraints,
15309
+ deviceId: { exact: deviceId },
15310
+ });
15065
15311
  this.soundDetectorCleanup = createSoundDetector(stream, (event) => {
15066
15312
  this.state.setSpeakingWhileMuted(event.isSoundDetected);
15067
15313
  });
@@ -15341,7 +15587,7 @@ class SpeakerManager {
15341
15587
  }
15342
15588
  async applyWeb() {
15343
15589
  const { enabled, storageKey } = this.devicePersistence;
15344
- if (!enabled)
15590
+ if (!enabled || !this.state.isDeviceSelectionSupported)
15345
15591
  return;
15346
15592
  const preferences = readPreferences(storageKey);
15347
15593
  const preferenceList = toPreferenceList(preferences.speaker);
@@ -15617,6 +15863,14 @@ class Call {
15617
15863
  networkAvailableTask;
15618
15864
  // (10 attempts per rolling 120 s window).
15619
15865
  rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
15866
+ // Backstop for slow REJOIN loops that stay under the short window
15867
+ // (20 attempts per rolling 30 min window). Dropped once the integrator
15868
+ // takes over the budget through `setRejoinAttemptLimit`.
15869
+ rejoinLongWindowRateLimiter = new SlidingWindowRateLimiter(20, 30 * 60 * 1000);
15870
+ // In-flight `doJoin` runs, awaited before acting on peer connections that
15871
+ // gathered no local ICE candidates.
15872
+ joinTasks = new Set();
15873
+ noLocalCandidatesCheckScheduled = false;
15620
15874
  // "Network doesn't support WebRTC" detector: counts peer-connection
15621
15875
  // failures where ICE never reached `connected`/`completed`.
15622
15876
  maxIceFailuresWithoutConnect = 2;
@@ -16010,6 +16264,7 @@ class Call {
16010
16264
  // `joinFlow` never runs, leaving stale values that would make the next
16011
16265
  // fresh `join()` send a stale `ReconnectDetails` to the SFU.
16012
16266
  this.rejoinRateLimiter.reset();
16267
+ this.rejoinLongWindowRateLimiter?.reset();
16013
16268
  this.iceFailuresWithoutConnect = 0;
16014
16269
  this.consecutiveNegotiationFailures = 0;
16015
16270
  this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
@@ -16405,6 +16660,16 @@ class Call {
16405
16660
  * @returns a promise which resolves once the call join-flow has finished.
16406
16661
  */
16407
16662
  doJoin = async (data) => {
16663
+ const task = this.performJoin(data);
16664
+ this.joinTasks.add(task);
16665
+ try {
16666
+ await task;
16667
+ }
16668
+ finally {
16669
+ this.joinTasks.delete(task);
16670
+ }
16671
+ };
16672
+ performJoin = async (data) => {
16408
16673
  const connectStartTime = Date.now();
16409
16674
  const callingState = this.state.callingState;
16410
16675
  const joinLeaveGeneration = this.leaveGeneration;
@@ -16762,6 +17027,9 @@ class Call {
16762
17027
  // "ICE never connected" failure budget can be cleared.
16763
17028
  this.iceFailuresWithoutConnect = 0;
16764
17029
  },
17030
+ onNoLocalCandidates: () => {
17031
+ this.checkNoLocalCandidates();
17032
+ },
16765
17033
  onPeerConnectionStateChange: (event) => {
16766
17034
  this.clientEventReporter.onPeerConnectionStateChange(this.cid, event);
16767
17035
  },
@@ -16804,15 +17072,65 @@ class Call {
16804
17072
  options: statsOptions,
16805
17073
  subscriber: this.subscriber,
16806
17074
  publisher: this.publisher,
16807
- microphone: this.microphone,
16808
- camera: this.camera,
16809
- state: this.state,
16810
17075
  tracer: this.tracer,
16811
17076
  unifiedSessionId,
16812
17077
  });
16813
17078
  this.sfuStatsReporter.start();
16814
17079
  }
16815
17080
  };
17081
+ /**
17082
+ * Escalates to REJOIN with `ICE_NEVER_CONNECTED` when the current peer
17083
+ * connections never connected and one of them completed ICE gathering
17084
+ * without a single local candidate, which means WebRTC networking is
17085
+ * blocked on this device.
17086
+ *
17087
+ * `reconnect` ignores requests while a join or another reconnect is in
17088
+ * flight, so the check is postponed until those settle and then re-run
17089
+ * against the peer connections current at that time.
17090
+ */
17091
+ checkNoLocalCandidates = () => {
17092
+ if (this.noLocalCandidatesCheckScheduled)
17093
+ return;
17094
+ if (this.joinTasks.size > 0 || hasPending(this.reconnectConcurrencyTag)) {
17095
+ this.noLocalCandidatesCheckScheduled = true;
17096
+ const leaveGeneration = this.leaveGeneration;
17097
+ this.waitForJoinsAndReconnects()
17098
+ .then(() => {
17099
+ this.noLocalCandidatesCheckScheduled = false;
17100
+ if (this.leaveGeneration !== leaveGeneration)
17101
+ return;
17102
+ this.checkNoLocalCandidates();
17103
+ })
17104
+ .catch((err) => {
17105
+ this.noLocalCandidatesCheckScheduled = false;
17106
+ this.logger.warn('[Reconnect] No-candidates check failed', err);
17107
+ });
17108
+ return;
17109
+ }
17110
+ if (this.state.callingState !== exports.CallingState.JOINED)
17111
+ return;
17112
+ // without network, gathering legitimately yields no candidates
17113
+ if (typeof navigator !== 'undefined' && navigator.onLine === false)
17114
+ return;
17115
+ const peerConnections = [this.publisher, this.subscriber];
17116
+ if (peerConnections.some((pc) => pc?.hasIceEverConnected()))
17117
+ return;
17118
+ if (!peerConnections.some((pc) => pc?.hasNoLocalCandidates()))
17119
+ return;
17120
+ this.reconnect(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED).catch((err) => {
17121
+ this.logger.warn('[Reconnect] Error reconnecting', err);
17122
+ });
17123
+ };
17124
+ /**
17125
+ * Resolves once no `doJoin` run and no reconnect is in flight.
17126
+ */
17127
+ waitForJoinsAndReconnects = async () => {
17128
+ while (this.joinTasks.size > 0 ||
17129
+ hasPending(this.reconnectConcurrencyTag)) {
17130
+ await Promise.allSettled(this.joinTasks);
17131
+ await settled(this.reconnectConcurrencyTag);
17132
+ }
17133
+ };
16816
17134
  /**
16817
17135
  * Retrieves credentials for joining the call.
16818
17136
  *
@@ -17011,6 +17329,14 @@ class Call {
17011
17329
  return;
17012
17330
  }
17013
17331
  }
17332
+ // MIGRATE is server-driven (e.g. SFU drains), so it doesn't count
17333
+ // toward the long-window backstop.
17334
+ if (this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN &&
17335
+ this.rejoinLongWindowRateLimiter &&
17336
+ !this.rejoinLongWindowRateLimiter.tryRegister()) {
17337
+ await giveUpAndLeave('rejoin_attempt_limit_exceeded');
17338
+ return;
17339
+ }
17014
17340
  // we don't increment reconnect attempts for the FAST strategy.
17015
17341
  if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
17016
17342
  this.reconnectAttempts++;
@@ -17229,7 +17555,13 @@ class Call {
17229
17555
  this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
17230
17556
  });
17231
17557
  // handles the "error" event, through which the SFU can request a reconnect
17232
- const unregisterOnError = this.on('error', (e) => {
17558
+ // Only the active SFU client may drive a reconnect: a superseded one can
17559
+ // still report errors about its own (replaced) session, and none may
17560
+ // while `leave()` tears the call down.
17561
+ const activeSfuTag = () => this.sfuClient?.tag ?? '*';
17562
+ const unregisterOnError = this.dispatcher.on('error', activeSfuTag, (e) => {
17563
+ if (!this.sfuClient)
17564
+ return;
17233
17565
  const { reconnectStrategy: strategy, error } = e;
17234
17566
  if (!SfuJoinError.isJoinErrorCode(e)) {
17235
17567
  const code = error?.code ? ErrorCode[error.code] : undefined;
@@ -17252,7 +17584,16 @@ class Call {
17252
17584
  });
17253
17585
  }
17254
17586
  else {
17255
- this.reconnect(strategy, error?.message || ReconnectReason.SFU_ERROR).catch((err) => {
17587
+ // A media transport failure on a session whose peer connections
17588
+ // never connected is an "ICE never connected" failure, and must
17589
+ // count toward the unsupported-network budget.
17590
+ const neverConnected = error?.code === ErrorCode.PARTICIPANT_MEDIA_TRANSPORT_FAILURE &&
17591
+ !this.publisher?.hasIceEverConnected() &&
17592
+ !this.subscriber?.hasIceEverConnected();
17593
+ const reason = neverConnected
17594
+ ? ReconnectReason.ICE_NEVER_CONNECTED
17595
+ : error?.message || ReconnectReason.SFU_ERROR;
17596
+ this.reconnect(strategy, reason).catch((err) => {
17256
17597
  this.logger.warn('[Reconnect] Error reconnecting', err);
17257
17598
  });
17258
17599
  }
@@ -18316,9 +18657,11 @@ class Call {
18316
18657
  * `rejoin_attempt_limit_exceeded` leave message.
18317
18658
  *
18318
18659
  * Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
18319
- * Both arguments are clamped to a minimum of 1.
18660
+ * Both arguments are clamped to a minimum of 1. Unless this method is
18661
+ * called, an additional cap of 20 REJOIN attempts per 30 minutes applies.
18320
18662
  */
18321
18663
  setRejoinAttemptLimit = (maxAttempts, windowSeconds) => {
18664
+ this.rejoinLongWindowRateLimiter = undefined;
18322
18665
  this.rejoinRateLimiter.setLimits(Math.max(1, maxAttempts), Math.max(1, windowSeconds) * 1000);
18323
18666
  };
18324
18667
  /**
@@ -19656,11 +19999,11 @@ class StreamClient {
19656
19999
  return await this.wsConnection.connect(this.defaultWSTimeout);
19657
20000
  };
19658
20001
  getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
19659
- "1.61.1";
20002
+ "1.62.0";
19660
20003
  getUserAgent = () => {
19661
20004
  if (!this.cachedUserAgent) {
19662
20005
  const { clientAppIdentifier = {} } = this.options;
19663
- const { sdkName = 'js', sdkVersion = "1.61.1", ...extras } = clientAppIdentifier;
20006
+ const { sdkName = 'js', sdkVersion = "1.62.0", ...extras } = clientAppIdentifier;
19664
20007
  this.cachedUserAgent = [
19665
20008
  `stream-video-${sdkName}-v${sdkVersion}`,
19666
20009
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -20600,6 +20943,10 @@ class StreamVideoClient {
20600
20943
  const call = this.writeableStateStore.findCall(c.call.type, c.call.id);
20601
20944
  if (call) {
20602
20945
  call.updateFromCallStateResponse(c);
20946
+ // ring events missed while the WS was down aren't replayed
20947
+ reconcileRingState(call, 'ring-poll-api').catch((err) => {
20948
+ call.logger.error('Failed to reconcile the ring state after rewatch', err);
20949
+ });
20603
20950
  }
20604
20951
  }
20605
20952
  return;