@stream-io/video-client 1.61.0 → 1.61.2

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 (47) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/index.browser.es.js +292 -154
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +292 -154
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +292 -154
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/coordinator/connection/types.d.ts +1 -1
  9. package/dist/src/devices/DeviceManager.d.ts +6 -0
  10. package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +0 -27
  11. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +7 -2
  12. package/dist/src/helpers/DynascaleManager.d.ts +15 -0
  13. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +18 -0
  14. package/dist/src/helpers/mediaInterruptions.d.ts +14 -0
  15. package/dist/src/reporting/ClientEventReporter.d.ts +1 -1
  16. package/dist/src/ringing/reconcileRingState.d.ts +8 -7
  17. package/dist/src/stats/SfuStatsReporter.d.ts +1 -13
  18. package/dist/src/stats/rtc/StatsTracer.d.ts +6 -6
  19. package/dist/src/stats/rtc/types.d.ts +0 -11
  20. package/package.json +1 -1
  21. package/src/Call.ts +0 -3
  22. package/src/StreamVideoClient.ts +8 -0
  23. package/src/__tests__/StreamVideoClient.rewatch.test.ts +136 -0
  24. package/src/coordinator/connection/types.ts +1 -1
  25. package/src/devices/DeviceManager.ts +20 -1
  26. package/src/devices/__tests__/CameraManager.test.ts +50 -1
  27. package/src/devices/__tests__/mediaStreamTestHelpers.ts +10 -6
  28. package/src/gen/video/sfu/signal_rpc/signal.ts +0 -53
  29. package/src/helpers/AudioBindingsWatchdog.ts +91 -35
  30. package/src/helpers/DynascaleManager.ts +84 -9
  31. package/src/helpers/MediaPlaybackWatchdog.ts +55 -6
  32. package/src/helpers/__tests__/DynascaleManager.test.ts +247 -9
  33. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +140 -0
  34. package/src/helpers/__tests__/mediaInterruptions.test.ts +131 -0
  35. package/src/helpers/mediaInterruptions.ts +37 -0
  36. package/src/reporting/ClientEventReporter.ts +8 -3
  37. package/src/reporting/__tests__/ClientEventReporter.test.ts +2 -2
  38. package/src/ringing/RingStatePoller.ts +1 -1
  39. package/src/ringing/__tests__/RingStatePoller.test.ts +1 -1
  40. package/src/ringing/__tests__/reconcileRingState.test.ts +33 -0
  41. package/src/ringing/reconcileRingState.ts +18 -10
  42. package/src/rtc/BasePeerConnection.ts +6 -1
  43. package/src/rtc/__tests__/Publisher.test.ts +50 -0
  44. package/src/stats/SfuStatsReporter.ts +19 -93
  45. package/src/stats/__tests__/SfuStatsReporter.test.ts +2 -18
  46. package/src/stats/rtc/StatsTracer.ts +6 -6
  47. package/src/stats/rtc/types.ts +0 -13
package/dist/index.es.js CHANGED
@@ -2666,18 +2666,6 @@ class SendStatsRequest$Type extends MessageType {
2666
2666
  constructor() {
2667
2667
  super('stream.video.sfu.signal.SendStatsRequest', [
2668
2668
  { no: 1, name: 'session_id', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
2669
- {
2670
- no: 2,
2671
- name: 'subscriber_stats',
2672
- kind: 'scalar',
2673
- T: 9 /*ScalarType.STRING*/,
2674
- },
2675
- {
2676
- no: 3,
2677
- name: 'publisher_stats',
2678
- kind: 'scalar',
2679
- T: 9 /*ScalarType.STRING*/,
2680
- },
2681
2669
  {
2682
2670
  no: 4,
2683
2671
  name: 'webrtc_version',
@@ -2691,8 +2679,6 @@ class SendStatsRequest$Type extends MessageType {
2691
2679
  kind: 'scalar',
2692
2680
  T: 9 /*ScalarType.STRING*/,
2693
2681
  },
2694
- { no: 7, name: 'audio_devices', kind: 'message', T: () => InputDevices },
2695
- { no: 8, name: 'video_devices', kind: 'message', T: () => InputDevices },
2696
2682
  {
2697
2683
  no: 9,
2698
2684
  name: 'android',
@@ -2709,18 +2695,6 @@ class SendStatsRequest$Type extends MessageType {
2709
2695
  },
2710
2696
  { no: 11, name: 'telemetry', kind: 'message', T: () => Telemetry },
2711
2697
  { no: 12, name: 'rtmp', kind: 'message', T: () => RTMPIngress },
2712
- {
2713
- no: 13,
2714
- name: 'subscriber_rtc_stats',
2715
- kind: 'scalar',
2716
- T: 9 /*ScalarType.STRING*/,
2717
- },
2718
- {
2719
- no: 14,
2720
- name: 'publisher_rtc_stats',
2721
- kind: 'scalar',
2722
- T: 9 /*ScalarType.STRING*/,
2723
- },
2724
2698
  { no: 15, name: 'rtc_stats', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
2725
2699
  {
2726
2700
  no: 16,
@@ -5082,7 +5056,7 @@ const toIceCandidate = (iceTrickle) => {
5082
5056
  }
5083
5057
  };
5084
5058
 
5085
- const version = "1.61.0";
5059
+ const version = "1.61.2";
5086
5060
  const [major, minor, patch] = version.split('.');
5087
5061
  let sdkInfo = {
5088
5062
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -7810,30 +7784,21 @@ class SfuStatsReporter {
7810
7784
  sfuClient;
7811
7785
  subscriber;
7812
7786
  publisher;
7813
- microphone;
7814
- camera;
7815
- state;
7816
7787
  tracer;
7817
7788
  unifiedSessionId;
7818
7789
  intervalId;
7819
7790
  timeoutId;
7820
7791
  reportCount = 0;
7821
- unsubscribeDevicePermissionsSubscription;
7822
- unsubscribeListDevicesSubscription;
7823
7792
  sdkName;
7824
7793
  sdkVersion;
7825
7794
  webRTCVersion;
7826
- inputDevices = new Map();
7827
7795
  statsConcurrencyTag = Symbol('sfuStatsReporter');
7828
7796
  isStopped = false;
7829
- constructor(sfuClient, { options, clientDetails, subscriber, publisher, microphone, camera, state, tracer, unifiedSessionId, }) {
7797
+ constructor(sfuClient, { options, clientDetails, subscriber, publisher, tracer, unifiedSessionId, }) {
7830
7798
  this.sfuClient = sfuClient;
7831
7799
  this.options = options;
7832
7800
  this.subscriber = subscriber;
7833
7801
  this.publisher = publisher;
7834
- this.microphone = microphone;
7835
- this.camera = camera;
7836
- this.state = state;
7837
7802
  this.tracer = tracer;
7838
7803
  this.unifiedSessionId = unifiedSessionId;
7839
7804
  const { sdk, browser } = clientDetails;
@@ -7847,35 +7812,6 @@ class SfuStatsReporter {
7847
7812
  `${browser?.name || ''}-${browser?.version || ''}` ||
7848
7813
  'N/A';
7849
7814
  }
7850
- observeDevice = (device, kind) => {
7851
- const { browserPermissionState$ } = device.state;
7852
- this.unsubscribeDevicePermissionsSubscription?.();
7853
- this.unsubscribeDevicePermissionsSubscription = createSubscription(combineLatest([browserPermissionState$, this.state.ownCapabilities$]), ([browserPermissionState, ownCapabilities]) => {
7854
- // cleanup the previous listDevices() subscription in case
7855
- // permissions or capabilities have changed.
7856
- // we will subscribe again if everything is in order.
7857
- this.unsubscribeListDevicesSubscription?.();
7858
- const hasCapability = kind === 'mic'
7859
- ? ownCapabilities.includes(OwnCapability.SEND_AUDIO)
7860
- : ownCapabilities.includes(OwnCapability.SEND_VIDEO);
7861
- if (browserPermissionState !== 'granted' || !hasCapability) {
7862
- this.inputDevices.set(kind, {
7863
- currentDevice: '',
7864
- availableDevices: [],
7865
- isPermitted: false,
7866
- });
7867
- return;
7868
- }
7869
- this.unsubscribeListDevicesSubscription = createSubscription(combineLatest([device.listDevices(), device.state.selectedDevice$]), ([devices, deviceId]) => {
7870
- const selected = devices.find((d) => d.deviceId === deviceId);
7871
- this.inputDevices.set(kind, {
7872
- currentDevice: selected?.label || deviceId || '',
7873
- availableDevices: devices.map((d) => d.label),
7874
- isPermitted: true,
7875
- });
7876
- });
7877
- });
7878
- };
7879
7815
  sendConnectionTime = (connectionTimeSeconds) => {
7880
7816
  this.sendTelemetryData({
7881
7817
  data: {
@@ -7910,7 +7846,7 @@ class SfuStatsReporter {
7910
7846
  this.subscriber.stats.takeSample(),
7911
7847
  this.publisher?.stats.takeSample(),
7912
7848
  ]);
7913
- send = (subscriberStats, publisherStats, telemetry) => {
7849
+ send = (subscriberPerfStats, publisherPerfStats, telemetry) => {
7914
7850
  // serialize sends so overlapping ones can't race on the trace buffers or
7915
7851
  // deliver an older delta after a newer one already succeeded. Not gated by
7916
7852
  // `isStopped`: an explicit final flush must still deliver after stop().
@@ -7928,8 +7864,8 @@ class SfuStatsReporter {
7928
7864
  this.subscriber.stats.clearPendingDeltas();
7929
7865
  }
7930
7866
  let pubPending = [];
7931
- if (pubTracer && publisherStats) {
7932
- pubPending = this.publisher?.stats.getPendingDeltas() ?? [];
7867
+ if (pubTracer && this.publisher) {
7868
+ pubPending = this.publisher.stats.getPendingDeltas();
7933
7869
  }
7934
7870
  else {
7935
7871
  this.publisher?.stats.clearPendingDeltas();
@@ -7951,17 +7887,9 @@ class SfuStatsReporter {
7951
7887
  sdk: this.sdkName,
7952
7888
  sdkVersion: this.sdkVersion,
7953
7889
  webrtcVersion: this.webRTCVersion,
7954
- subscriberStats: JSON.stringify(flatten(subscriberStats.stats)),
7955
- publisherStats: publisherStats
7956
- ? JSON.stringify(flatten(publisherStats.stats))
7957
- : '[]',
7958
- subscriberRtcStats: '',
7959
- publisherRtcStats: '',
7960
7890
  rtcStats: JSON.stringify(traces),
7961
- encodeStats: publisherStats?.performanceStats ?? [],
7962
- decodeStats: subscriberStats.performanceStats,
7963
- audioDevices: this.inputDevices.get('mic'),
7964
- videoDevices: this.inputDevices.get('camera'),
7891
+ encodeStats: publisherPerfStats ?? [],
7892
+ decodeStats: subscriberPerfStats,
7965
7893
  unifiedSessionId: this.unifiedSessionId,
7966
7894
  deviceState: getDeviceState(),
7967
7895
  telemetry,
@@ -7975,8 +7903,8 @@ class SfuStatsReporter {
7975
7903
  // delivery confirmed: advance the delivery baseline for each chain.
7976
7904
  if (subTracer)
7977
7905
  this.subscriber.stats.commitDeltas(subPending);
7978
- if (pubTracer && publisherStats) {
7979
- this.publisher?.stats.commitDeltas(pubPending);
7906
+ if (pubTracer && this.publisher) {
7907
+ this.publisher.stats.commitDeltas(pubPending);
7980
7908
  }
7981
7909
  }
7982
7910
  catch (err) {
@@ -7998,8 +7926,8 @@ class SfuStatsReporter {
7998
7926
  run = async (telemetry) => {
7999
7927
  if (this.isStopped)
8000
7928
  return;
8001
- const [subscriberStats, publisherStats] = await this.sample();
8002
- await this.send(subscriberStats, publisherStats, telemetry);
7929
+ const [subscriberPerfStats, publisherPerfStats] = await this.sample();
7930
+ await this.send(subscriberPerfStats, publisherPerfStats, telemetry);
8003
7931
  };
8004
7932
  scheduleNextReport = () => {
8005
7933
  const intervals = [1500, 3000, 3000, 5000];
@@ -8020,8 +7948,6 @@ class SfuStatsReporter {
8020
7948
  start = () => {
8021
7949
  if (this.options.reporting_interval_ms <= 0)
8022
7950
  return;
8023
- this.observeDevice(this.microphone, 'mic');
8024
- this.observeDevice(this.camera, 'camera');
8025
7951
  this.isStopped = false;
8026
7952
  this.reportCount = 0;
8027
7953
  clearInterval(this.intervalId);
@@ -8030,11 +7956,6 @@ class SfuStatsReporter {
8030
7956
  };
8031
7957
  stop = () => {
8032
7958
  this.isStopped = true;
8033
- this.unsubscribeDevicePermissionsSubscription?.();
8034
- this.unsubscribeDevicePermissionsSubscription = undefined;
8035
- this.unsubscribeListDevicesSubscription?.();
8036
- this.unsubscribeListDevicesSubscription = undefined;
8037
- this.inputDevices.clear();
8038
7959
  clearInterval(this.intervalId);
8039
7960
  this.intervalId = undefined;
8040
7961
  clearTimeout(this.timeoutId);
@@ -8055,8 +7976,8 @@ class SfuStatsReporter {
8055
7976
  return;
8056
7977
  try {
8057
7978
  const [sample] = await timeboxed([this.sample()], 2000);
8058
- const [subscriberStats, publisherStats] = sample;
8059
- this.send(subscriberStats, publisherStats).catch((err) => {
7979
+ const [subscriberPerfStats, publisherPerfStats] = sample;
7980
+ this.send(subscriberPerfStats, publisherPerfStats).catch((err) => {
8060
7981
  this.logger.warn('Failed to flush report stats', err);
8061
7982
  });
8062
7983
  }
@@ -8183,9 +8104,9 @@ class StatsTracer {
8183
8104
  this.maxPendingDeltas = maxPendingDeltas;
8184
8105
  }
8185
8106
  /**
8186
- * Samples the RTCPeerConnection: returns the current stats report and the
8187
- * derived performance stats, and appends the delta-compressed sample to the
8188
- * un-acked delivery chain (retrieved via `getPendingDeltas`).
8107
+ * Samples the RTCPeerConnection: returns the derived performance stats and
8108
+ * appends the delta-compressed sample to the un-acked delivery chain
8109
+ * (retrieved via `getPendingDeltas`).
8189
8110
  *
8190
8111
  * @internal
8191
8112
  */
@@ -8210,7 +8131,7 @@ class StatsTracer {
8210
8131
  this.pendingDeltas.push({ delta, ts: now });
8211
8132
  this.frameTimeHistory = this.frameTimeHistory.slice(-2);
8212
8133
  this.fpsHistory = this.fpsHistory.slice(-2);
8213
- return { performanceStats, stats };
8134
+ return performanceStats;
8214
8135
  });
8215
8136
  };
8216
8137
  /**
@@ -8755,7 +8676,12 @@ class BasePeerConnection {
8755
8676
  }
8756
8677
  // we can't recover from a failed connection state (contrary to ICE)
8757
8678
  if (state === 'failed') {
8758
- this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.CONNECTION_FAILED, this.peerType);
8679
+ const reason = this.iceHasEverConnected
8680
+ ? ReconnectReason.CONNECTION_FAILED
8681
+ : ReconnectReason.ICE_NEVER_CONNECTED;
8682
+ clearTimeout(this.preConnectStuckTimeout);
8683
+ this.preConnectStuckTimeout = undefined;
8684
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, reason, this.peerType);
8759
8685
  return;
8760
8686
  }
8761
8687
  this.handleConnectionStateUpdate(state);
@@ -10754,15 +10680,16 @@ class StreamSfuClient {
10754
10680
  /**
10755
10681
  * Decides what a ringing call should do next, based on the current call state.
10756
10682
  *
10757
- * The `call.accepted`, `call.rejected` and `call.missed` handlers and the ring
10758
- * state poller both run this. They differ only in how the state got there: the
10759
- * handlers rely on `CallState.updateFromEvent`, which runs before them, and the
10760
- * poller applies the polled ring state itself.
10683
+ * The `call.accepted` and `call.rejected` handlers, the ring state poller and
10684
+ * the rewatch after a WS reconnect all run this. They differ only in how the
10685
+ * state got there: the handlers rely on `CallState.updateFromEvent`, which runs
10686
+ * before them, while the poller applies the polled ring state itself and the
10687
+ * rewatch applies the queried call state.
10761
10688
  *
10762
10689
  * @param call the call to reconcile.
10763
- * @param joinSource which of the two triggered this run, reported on the
10764
- * caller's join: `ring-ws` for the event handlers, `ring-poll-api` for the
10765
- * poller.
10690
+ * @param joinSource what triggered this run, reported on the caller's join:
10691
+ * `ring-ws` for the event handlers, `ring-poll-api` for the poller and the
10692
+ * rewatch (both come from an API response).
10766
10693
  * @returns whether the ring reached a terminal state. A failed join is not
10767
10694
  * terminal: the caller should keep trying while the ring is open.
10768
10695
  */
@@ -10817,10 +10744,16 @@ const reconcileAsCaller = async (call, joinSource) => {
10817
10744
  return false;
10818
10745
  };
10819
10746
  // the current user's own accept or reject, on this or another device, is
10820
- // handled by `resolveOwnRingOutcome`, and `call.ended` by `watchCallEnded`.
10747
+ // handled by `resolveOwnRingOutcome`, and a live `call.ended` by
10748
+ // `watchCallEnded`. An end missed while offline is handled here.
10821
10749
  const reconcileAsCallee = async (call) => {
10822
- const createdById = call.state.createdBy?.id;
10823
- const rejectedBy = call.state.session?.rejected_by ?? {};
10750
+ const { session, endedAt, createdBy } = call.state;
10751
+ if (endedAt || session?.ended_at) {
10752
+ call.logger.info('ring: the call has ended, leaving');
10753
+ return leave(call, { reason: 'ended', message: 'ring: call ended' });
10754
+ }
10755
+ const createdById = createdBy?.id;
10756
+ const rejectedBy = session?.rejected_by ?? {};
10824
10757
  if (createdById && rejectedBy[createdById]) {
10825
10758
  call.logger.info('ring: the caller cancelled, leaving');
10826
10759
  return leave(call, {
@@ -10869,7 +10802,7 @@ class RingStatePoller {
10869
10802
  unsubscribe = [];
10870
10803
  constructor(call, options = {}) {
10871
10804
  this.call = call;
10872
- this.startAfterMs = options.startAfterMs ?? 15_000;
10805
+ this.startAfterMs = options.startAfterMs ?? 9_000;
10873
10806
  this.intervalMs = options.intervalMs ?? 5_000;
10874
10807
  }
10875
10808
  /**
@@ -11534,12 +11467,33 @@ const registerRingingCallEventHandlers = (call) => {
11534
11467
  };
11535
11468
 
11536
11469
  const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
11470
+ const toElementState = (element) => {
11471
+ const stream = element.srcObject;
11472
+ const [track] = stream?.getAudioTracks?.() ?? [];
11473
+ return {
11474
+ paused: element.paused,
11475
+ muted: element.muted,
11476
+ volume: Math.round(element.volume * 100) / 100,
11477
+ readyState: element.readyState,
11478
+ sinkId: element.sinkId,
11479
+ track: track
11480
+ ? {
11481
+ enabled: track.enabled,
11482
+ muted: track.muted,
11483
+ readyState: track.readyState,
11484
+ }
11485
+ : null,
11486
+ };
11487
+ };
11537
11488
  /**
11538
11489
  * Tracks audio element bindings and periodically warns about
11539
11490
  * remote participants whose audio streams have no bound element.
11491
+ * On the same tick, it traces the playback state of the bound elements,
11492
+ * limited to the bindings whose state changed since the previous trace.
11540
11493
  */
11541
11494
  class AudioBindingsWatchdog {
11542
11495
  bindings = new Map();
11496
+ tracedStates = new Map();
11543
11497
  enabled = true;
11544
11498
  watchdogInterval;
11545
11499
  unsubscribeCallingState;
@@ -11550,8 +11504,6 @@ class AudioBindingsWatchdog {
11550
11504
  this.tracer = tracer;
11551
11505
  this.state = state;
11552
11506
  this.unsubscribeCallingState = createSubscription(state.callingState$, (callingState) => {
11553
- if (!this.enabled)
11554
- return;
11555
11507
  if (callingState !== CallingState.JOINED) {
11556
11508
  this.stop();
11557
11509
  }
@@ -11580,15 +11532,15 @@ class AudioBindingsWatchdog {
11580
11532
  this.bindings.delete(toBindingKey(sessionId, trackType));
11581
11533
  };
11582
11534
  /**
11583
- * Enables or disables the watchdog.
11584
- * When disabled, the periodic check stops but bindings are still tracked.
11535
+ * Enables or disables the dangling binding warnings.
11536
+ * Bindings are still tracked and their playback state is still traced.
11585
11537
  */
11586
11538
  setEnabled = (enabled) => {
11587
11539
  this.enabled = enabled;
11588
11540
  if (enabled) {
11589
11541
  this.start();
11590
11542
  }
11591
- else {
11543
+ else if (this.state.callingState !== CallingState.JOINED) {
11592
11544
  this.stop();
11593
11545
  }
11594
11546
  };
@@ -11598,33 +11550,61 @@ class AudioBindingsWatchdog {
11598
11550
  dispose = () => {
11599
11551
  this.stop();
11600
11552
  this.bindings.clear();
11553
+ this.tracedStates.clear();
11601
11554
  this.unsubscribeCallingState();
11602
11555
  };
11603
11556
  start = () => {
11604
11557
  clearInterval(this.watchdogInterval);
11605
11558
  this.watchdogInterval = setInterval(() => {
11606
- const danglingUserIds = [];
11607
- for (const p of this.state.participants) {
11608
- if (p.isLocalParticipant)
11609
- continue;
11610
- const { audioStream, screenShareAudioStream, sessionId, userId, publishedTracks, } = p;
11611
- if (audioStream &&
11612
- publishedTracks.includes(TrackType.AUDIO) &&
11613
- !this.bindings.has(toBindingKey(sessionId))) {
11614
- danglingUserIds.push(userId);
11615
- }
11616
- if (screenShareAudioStream &&
11617
- publishedTracks.includes(TrackType.SCREEN_SHARE_AUDIO) &&
11618
- !this.bindings.has(toBindingKey(sessionId, 'screenShareAudioTrack'))) {
11619
- danglingUserIds.push(userId);
11620
- }
11559
+ if (this.enabled)
11560
+ this.warnAboutDanglingBindings();
11561
+ this.traceBindingStates();
11562
+ }, 3000);
11563
+ };
11564
+ warnAboutDanglingBindings = () => {
11565
+ const danglingUserIds = [];
11566
+ for (const p of this.state.participants) {
11567
+ if (p.isLocalParticipant)
11568
+ continue;
11569
+ const { audioStream, screenShareAudioStream, sessionId, userId, publishedTracks, } = p;
11570
+ if (audioStream &&
11571
+ publishedTracks.includes(TrackType.AUDIO) &&
11572
+ !this.bindings.has(toBindingKey(sessionId))) {
11573
+ danglingUserIds.push(userId);
11621
11574
  }
11622
- if (danglingUserIds.length > 0) {
11623
- const key = 'audioBinding.danglingWarning';
11624
- this.tracer.traceOnce(key, key, danglingUserIds);
11625
- this.logger.warn(`Dangling audio bindings detected. Did you forget to bind the audio element? user_ids: ${danglingUserIds}.`);
11575
+ if (screenShareAudioStream &&
11576
+ publishedTracks.includes(TrackType.SCREEN_SHARE_AUDIO) &&
11577
+ !this.bindings.has(toBindingKey(sessionId, 'screenShareAudioTrack'))) {
11578
+ danglingUserIds.push(userId);
11626
11579
  }
11627
- }, 3000);
11580
+ }
11581
+ if (danglingUserIds.length > 0) {
11582
+ const key = 'audioBinding.danglingWarning';
11583
+ this.tracer.traceOnce(key, key, danglingUserIds);
11584
+ this.logger.warn(`Dangling audio bindings detected. Did you forget to bind the audio element? user_ids: ${danglingUserIds}.`);
11585
+ }
11586
+ };
11587
+ traceBindingStates = () => {
11588
+ const changes = {};
11589
+ let hasChanges = false;
11590
+ for (const [key, element] of this.bindings) {
11591
+ const state = toElementState(element);
11592
+ const serialized = JSON.stringify(state);
11593
+ if (this.tracedStates.get(key) === serialized)
11594
+ continue;
11595
+ this.tracedStates.set(key, serialized);
11596
+ changes[key] = state;
11597
+ hasChanges = true;
11598
+ }
11599
+ for (const key of this.tracedStates.keys()) {
11600
+ if (this.bindings.has(key))
11601
+ continue;
11602
+ this.tracedStates.delete(key);
11603
+ changes[key] = null;
11604
+ hasChanges = true;
11605
+ }
11606
+ if (hasChanges)
11607
+ this.tracer.trace('audioBinding.state', changes);
11628
11608
  };
11629
11609
  stop = () => {
11630
11610
  clearInterval(this.watchdogInterval);
@@ -11903,7 +11883,9 @@ class TrackSubscriptionManager {
11903
11883
  class MediaPlaybackWatchdog {
11904
11884
  logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
11905
11885
  kind;
11886
+ traceInfo;
11906
11887
  isBlocked;
11888
+ onGiveUp;
11907
11889
  element;
11908
11890
  tracer;
11909
11891
  controller = new AbortController();
@@ -11915,8 +11897,14 @@ class MediaPlaybackWatchdog {
11915
11897
  constructor(opts) {
11916
11898
  this.element = opts.element;
11917
11899
  this.kind = opts.kind;
11900
+ this.traceInfo = {
11901
+ kind: opts.kind,
11902
+ sessionId: opts.sessionId,
11903
+ trackType: opts.trackType,
11904
+ };
11918
11905
  this.tracer = opts.tracer;
11919
11906
  this.isBlocked = opts.isBlocked ?? (() => false);
11907
+ this.onGiveUp = opts.onGiveUp ?? (() => { });
11920
11908
  this.attach();
11921
11909
  }
11922
11910
  attach = () => {
@@ -11934,6 +11922,23 @@ class MediaPlaybackWatchdog {
11934
11922
  this.controller.abort();
11935
11923
  this.clearTimers();
11936
11924
  };
11925
+ /**
11926
+ * Starts a fresh recovery cycle, e.g. after a media interruption ended.
11927
+ * Unlike a regular pause-triggered recovery, it also tries elements that
11928
+ * were skipped as blocked or not ready, since those conditions may have
11929
+ * cleared together with the interruption.
11930
+ */
11931
+ rearm = () => {
11932
+ if (this.disposed)
11933
+ return;
11934
+ this.clearTimers();
11935
+ this.attempt = 0;
11936
+ this.stopped = false;
11937
+ const { paused, srcObject, ended } = this.element;
11938
+ if (!paused || !srcObject || ended)
11939
+ return;
11940
+ this.pendingTimer = setTimeout(this.attemptPlay, 0);
11941
+ };
11937
11942
  clearTimers = () => {
11938
11943
  if (this.pendingTimer)
11939
11944
  clearTimeout(this.pendingTimer);
@@ -11955,17 +11960,34 @@ class MediaPlaybackWatchdog {
11955
11960
  if (this.element.paused)
11956
11961
  return;
11957
11962
  this.tracer.trace('mediaPlayback.recover.success', {
11958
- kind: this.kind,
11963
+ ...this.traceInfo,
11959
11964
  attempts: this.attempt,
11960
11965
  });
11961
11966
  this.attempt = 0;
11962
11967
  this.stopped = false;
11963
11968
  };
11969
+ /**
11970
+ * Media elements backed by a MediaStream fire `suspend` routinely (after
11971
+ * the initial load, on source swaps, on layer switches), so a `suspend`
11972
+ * that needs no recovery is ignored without a trace. An explicit `pause`
11973
+ * is rare and always traced, along with the reason recovery was skipped.
11974
+ */
11964
11975
  onPauseOrSuspend = (event) => {
11965
- if (this.disposed || this.stopped)
11976
+ if (this.disposed || this.stopped || this.pendingTimer)
11977
+ return;
11978
+ const skipReason = this.computeSkipReason();
11979
+ if (skipReason) {
11980
+ if (event.type === 'pause') {
11981
+ this.tracer.trace('mediaPlayback.paused', {
11982
+ ...this.traceInfo,
11983
+ reason: event.type,
11984
+ skipped: skipReason,
11985
+ });
11986
+ }
11966
11987
  return;
11988
+ }
11967
11989
  this.tracer.trace('mediaPlayback.paused', {
11968
- kind: this.kind,
11990
+ ...this.traceInfo,
11969
11991
  reason: event.type,
11970
11992
  });
11971
11993
  if (this.settleTimer)
@@ -11979,7 +12001,7 @@ class MediaPlaybackWatchdog {
11979
12001
  const skipReason = this.computeSkipReason();
11980
12002
  if (skipReason) {
11981
12003
  this.tracer.trace('mediaPlayback.recover.skipped', {
11982
- kind: this.kind,
12004
+ ...this.traceInfo,
11983
12005
  reason: skipReason,
11984
12006
  });
11985
12007
  return;
@@ -11988,9 +12010,10 @@ class MediaPlaybackWatchdog {
11988
12010
  this.stopped = true;
11989
12011
  this.clearTimers();
11990
12012
  this.tracer.trace('mediaPlayback.recover.giveUp', {
11991
- kind: this.kind,
12013
+ ...this.traceInfo,
11992
12014
  attempts: this.attempt,
11993
12015
  });
12016
+ this.onGiveUp();
11994
12017
  return;
11995
12018
  }
11996
12019
  const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
@@ -12018,7 +12041,7 @@ class MediaPlaybackWatchdog {
12018
12041
  return;
12019
12042
  this.attempt += 1;
12020
12043
  this.tracer.trace('mediaPlayback.recover.attempt', {
12021
- kind: this.kind,
12044
+ ...this.traceInfo,
12022
12045
  attempt: this.attempt,
12023
12046
  });
12024
12047
  try {
@@ -12033,6 +12056,41 @@ class MediaPlaybackWatchdog {
12033
12056
  };
12034
12057
  }
12035
12058
 
12059
+ /**
12060
+ * Checks whether media playback and capture are likely interrupted: the page
12061
+ * is hidden, or WebKit reports the audio session as interrupted (a phone
12062
+ * call, Siri, or another app taking over audio).
12063
+ */
12064
+ const isMediaInterrupted = () => {
12065
+ if (typeof document === 'undefined')
12066
+ return false;
12067
+ return (document.visibilityState === 'hidden' ||
12068
+ navigator.audioSession?.state === 'interrupted');
12069
+ };
12070
+ /**
12071
+ * Invokes `onResumed` every time the page leaves a media interruption,
12072
+ * as defined by {@link isMediaInterrupted}.
12073
+ *
12074
+ * @param onResumed the callback to invoke.
12075
+ * @returns a function that stops watching.
12076
+ */
12077
+ const watchMediaInterruptions = (onResumed) => {
12078
+ if (typeof document === 'undefined')
12079
+ return () => { };
12080
+ let interrupted = isMediaInterrupted();
12081
+ const check = () => {
12082
+ const wasInterrupted = interrupted;
12083
+ interrupted = isMediaInterrupted();
12084
+ if (wasInterrupted && !interrupted)
12085
+ onResumed();
12086
+ };
12087
+ const controller = new AbortController();
12088
+ const { signal } = controller;
12089
+ document.addEventListener('visibilitychange', check, { signal });
12090
+ navigator.audioSession?.addEventListener('statechange', check, { signal });
12091
+ return () => controller.abort();
12092
+ };
12093
+
12036
12094
  /**
12037
12095
  * A manager class that handles dynascale related tasks like:
12038
12096
  *
@@ -12046,6 +12104,9 @@ class DynascaleManager {
12046
12104
  tracer;
12047
12105
  useWebAudio = false;
12048
12106
  audioContext;
12107
+ playbackWatchdogs = new Set();
12108
+ stopWatchingInterruptions;
12109
+ stopTracingAudioSession;
12049
12110
  trackSubscriptionManager;
12050
12111
  blockedAudioTracker;
12051
12112
  /**
@@ -12062,6 +12123,10 @@ class DynascaleManager {
12062
12123
  * Closes the audio context if it was created.
12063
12124
  */
12064
12125
  dispose = async () => {
12126
+ this.stopWatchingInterruptions?.();
12127
+ this.stopWatchingInterruptions = undefined;
12128
+ this.stopTracingAudioSession?.();
12129
+ this.stopTracingAudioSession = undefined;
12065
12130
  const context = this.audioContext;
12066
12131
  if (context && context.state !== 'closed') {
12067
12132
  document.removeEventListener('click', this.resumeAudioContext);
@@ -12201,10 +12266,12 @@ class DynascaleManager {
12201
12266
  // without prior user interaction:
12202
12267
  // https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
12203
12268
  videoElement.muted = true;
12204
- const playbackWatchdog = new MediaPlaybackWatchdog({
12269
+ const playbackWatchdog = this.createPlaybackWatchdog({
12205
12270
  element: videoElement,
12206
12271
  kind: 'video',
12207
12272
  tracer: this.tracer,
12273
+ sessionId,
12274
+ trackType,
12208
12275
  });
12209
12276
  const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
12210
12277
  const streamSubscription = participant$
@@ -12232,7 +12299,7 @@ class DynascaleManager {
12232
12299
  publishedTracksSubscription?.unsubscribe();
12233
12300
  streamSubscription.unsubscribe();
12234
12301
  resizeObserver?.disconnect();
12235
- playbackWatchdog.dispose();
12302
+ this.disposePlaybackWatchdog(playbackWatchdog);
12236
12303
  };
12237
12304
  };
12238
12305
  /**
@@ -12250,6 +12317,7 @@ class DynascaleManager {
12250
12317
  const participant = this.callState.findParticipantBySessionId(sessionId);
12251
12318
  if (!participant || participant.isLocalParticipant)
12252
12319
  return;
12320
+ this.traceAudioSessionState();
12253
12321
  const participant$ = this.callState.participants$.pipe(map((ps) => ps.find((p) => p.sessionId === sessionId)), takeWhile((p) => !!p), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
12254
12322
  const updateSinkId = (deviceId, audioContext) => {
12255
12323
  if (!deviceId)
@@ -12269,7 +12337,9 @@ class DynascaleManager {
12269
12337
  let sourceNode = undefined;
12270
12338
  let gainNode = undefined;
12271
12339
  let audioWatchdog = undefined;
12340
+ let blockedByGiveUp = false;
12272
12341
  const clearBlockedAudio = () => {
12342
+ blockedByGiveUp = false;
12273
12343
  if (!this.blockedAudioTracker.isBlocked(audioElement))
12274
12344
  return;
12275
12345
  this.blockedAudioTracker.markBlocked(audioElement, false);
@@ -12277,15 +12347,18 @@ class DynascaleManager {
12277
12347
  audioElement.addEventListener('playing', clearBlockedAudio);
12278
12348
  const isAudioTrack = trackType === 'audioTrack';
12279
12349
  const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
12350
+ let sourceChangeTimer;
12280
12351
  const updateMediaStreamSubscription = participant$
12281
12352
  .pipe(distinctUntilKeyChanged(trackKey))
12282
12353
  .subscribe((p) => {
12283
12354
  const source = isAudioTrack ? p.audioStream : p.screenShareAudioStream;
12284
12355
  if (audioElement.srcObject === source)
12285
12356
  return;
12286
- setTimeout(() => {
12357
+ clearTimeout(sourceChangeTimer);
12358
+ sourceChangeTimer = setTimeout(() => {
12359
+ sourceChangeTimer = undefined;
12287
12360
  audioElement.srcObject = source ?? null;
12288
- audioWatchdog?.dispose();
12361
+ this.disposePlaybackWatchdog(audioWatchdog);
12289
12362
  audioWatchdog = undefined;
12290
12363
  if (!source) {
12291
12364
  clearBlockedAudio();
@@ -12316,15 +12389,25 @@ class DynascaleManager {
12316
12389
  this.tracer.trace('audioPlaybackError', e.message);
12317
12390
  if (e.name === 'NotAllowedError') {
12318
12391
  this.tracer.trace('audioPlaybackBlocked', null);
12392
+ blockedByGiveUp = false;
12319
12393
  this.blockedAudioTracker.markBlocked(audioElement, true, sessionId);
12320
12394
  }
12321
12395
  this.logger.warn(`Failed to play audio stream`, e);
12322
12396
  });
12323
- audioWatchdog = new MediaPlaybackWatchdog({
12397
+ audioWatchdog = this.createPlaybackWatchdog({
12324
12398
  element: audioElement,
12325
12399
  kind: 'audio',
12326
12400
  tracer: this.tracer,
12327
- isBlocked: () => this.blockedAudioTracker.isBlocked(audioElement),
12401
+ sessionId,
12402
+ trackType,
12403
+ // only an autoplay-policy block needs a user gesture; a give-up
12404
+ // block is for the UI and must not stop a re-armed cycle
12405
+ isBlocked: () => !blockedByGiveUp &&
12406
+ this.blockedAudioTracker.isBlocked(audioElement),
12407
+ onGiveUp: () => {
12408
+ blockedByGiveUp = true;
12409
+ this.blockedAudioTracker.markBlocked(audioElement, true, sessionId);
12410
+ },
12328
12411
  });
12329
12412
  }
12330
12413
  const { selectedDevice } = this.speaker.state;
@@ -12354,13 +12437,51 @@ class DynascaleManager {
12354
12437
  sinkIdSubscription?.unsubscribe();
12355
12438
  volumeSubscription.unsubscribe();
12356
12439
  updateMediaStreamSubscription.unsubscribe();
12440
+ clearTimeout(sourceChangeTimer);
12357
12441
  audioElement.srcObject = null;
12358
12442
  sourceNode?.disconnect();
12359
12443
  gainNode?.disconnect();
12360
- audioWatchdog?.dispose();
12444
+ this.disposePlaybackWatchdog(audioWatchdog);
12361
12445
  audioWatchdog = undefined;
12362
12446
  };
12363
12447
  };
12448
+ /**
12449
+ * Creates a playback watchdog that is re-armed whenever a media
12450
+ * interruption (backgrounding, phone call, Siri) ends.
12451
+ */
12452
+ createPlaybackWatchdog = (opts) => {
12453
+ const watchdog = new MediaPlaybackWatchdog(opts);
12454
+ this.playbackWatchdogs.add(watchdog);
12455
+ this.stopWatchingInterruptions ??= watchMediaInterruptions(this.rearmPlayback);
12456
+ return watchdog;
12457
+ };
12458
+ disposePlaybackWatchdog = (watchdog) => {
12459
+ if (!watchdog)
12460
+ return;
12461
+ watchdog.dispose();
12462
+ this.playbackWatchdogs.delete(watchdog);
12463
+ };
12464
+ rearmPlayback = () => {
12465
+ this.tracer.trace('mediaPlayback.rearm', this.playbackWatchdogs.size);
12466
+ this.playbackWatchdogs.forEach((watchdog) => watchdog.rearm());
12467
+ };
12468
+ /**
12469
+ * Traces the WebKit audio session state (initial value and changes) in
12470
+ * both playback modes, as it explains most iOS playback interruptions.
12471
+ */
12472
+ traceAudioSessionState = () => {
12473
+ const audioSession = navigator.audioSession;
12474
+ if (!audioSession || this.stopTracingAudioSession)
12475
+ return;
12476
+ const trace = () => {
12477
+ this.tracer.trace('audioSession.state', audioSession.state);
12478
+ };
12479
+ trace();
12480
+ audioSession.addEventListener('statechange', trace);
12481
+ this.stopTracingAudioSession = () => {
12482
+ audioSession.removeEventListener('statechange', trace);
12483
+ };
12484
+ };
12364
12485
  getOrCreateAudioContext = () => {
12365
12486
  if (!this.useWebAudio)
12366
12487
  return;
@@ -12383,7 +12504,6 @@ class DynascaleManager {
12383
12504
  audioSession.type = 'play-and-record';
12384
12505
  let isSessionInterrupted = false;
12385
12506
  audioSession.addEventListener('statechange', () => {
12386
- this.tracer.trace('audioSession.state', audioSession.state);
12387
12507
  if (audioSession.state === 'interrupted') {
12388
12508
  isSessionInterrupted = true;
12389
12509
  }
@@ -13942,9 +14062,22 @@ class DeviceManager {
13942
14062
  }
13943
14063
  const devices = getCurrentValue(this.listDevices()) || [];
13944
14064
  const currentDevice = this.findDevice(devices, selectedDevice) ??
13945
- createSyntheticDevice(selectedDevice, deviceKind);
14065
+ createSyntheticDevice(selectedDevice, deviceKind, this.getActiveTrackLabel(selectedDevice));
13946
14066
  writePreferences(currentDevice, deviceKey, muted, storageKey);
13947
14067
  }
14068
+ /**
14069
+ * Returns the label of the active track if it was captured from the given
14070
+ * device. Used as a fallback when the device list hasn't been enumerated yet,
14071
+ * so the persisted preference still carries the device label.
14072
+ */
14073
+ getActiveTrackLabel = (deviceId) => {
14074
+ const stream = this.state.rootMediaStream;
14075
+ const tracks = this.trackType === TrackType.AUDIO
14076
+ ? stream?.getAudioTracks()
14077
+ : stream?.getVideoTracks();
14078
+ const track = tracks?.find((t) => t.getSettings().deviceId === deviceId);
14079
+ return track?.label;
14080
+ };
13948
14081
  async applyPersistedPreferences(enabledInCallType, forceDisabled = false) {
13949
14082
  const deviceKey = this.trackType === TrackType.AUDIO ? 'microphone' : 'camera';
13950
14083
  const preferences = readPreferences(this.devicePersistence.storageKey);
@@ -16780,9 +16913,6 @@ class Call {
16780
16913
  options: statsOptions,
16781
16914
  subscriber: this.subscriber,
16782
16915
  publisher: this.publisher,
16783
- microphone: this.microphone,
16784
- camera: this.camera,
16785
- state: this.state,
16786
16916
  tracer: this.tracer,
16787
16917
  unifiedSessionId,
16788
16918
  });
@@ -19632,11 +19762,11 @@ class StreamClient {
19632
19762
  return await this.wsConnection.connect(this.defaultWSTimeout);
19633
19763
  };
19634
19764
  getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
19635
- "1.61.0";
19765
+ "1.61.2";
19636
19766
  getUserAgent = () => {
19637
19767
  if (!this.cachedUserAgent) {
19638
19768
  const { clientAppIdentifier = {} } = this.options;
19639
- const { sdkName = 'js', sdkVersion = "1.61.0", ...extras } = clientAppIdentifier;
19769
+ const { sdkName = 'js', sdkVersion = "1.61.2", ...extras } = clientAppIdentifier;
19640
19770
  this.cachedUserAgent = [
19641
19771
  `stream-video-${sdkName}-v${sdkVersion}`,
19642
19772
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -20171,7 +20301,11 @@ class ClientEventReporter {
20171
20301
  return;
20172
20302
  }
20173
20303
  if (event.state === 'failed') {
20174
- this.emitPeerConnectionFailure(cid, role, 'DTLS_CONNECTIVITY_FAILED', 'DTLS connectivity checks failed');
20304
+ const iceConnected = event.iceConnectionState === 'connected' ||
20305
+ event.iceConnectionState === 'completed';
20306
+ this.emitPeerConnectionFailure(cid, role, iceConnected ? 'DTLS_CONNECTIVITY_FAILED' : 'ICE_CONNECTIVITY_FAILED', iceConnected
20307
+ ? 'DTLS connectivity checks failed'
20308
+ : 'ICE connectivity checks failed');
20175
20309
  return;
20176
20310
  }
20177
20311
  switch (event.state) {
@@ -20572,6 +20706,10 @@ class StreamVideoClient {
20572
20706
  const call = this.writeableStateStore.findCall(c.call.type, c.call.id);
20573
20707
  if (call) {
20574
20708
  call.updateFromCallStateResponse(c);
20709
+ // ring events missed while the WS was down aren't replayed
20710
+ reconcileRingState(call, 'ring-poll-api').catch((err) => {
20711
+ call.logger.error('Failed to reconcile the ring state after rewatch', err);
20712
+ });
20575
20713
  }
20576
20714
  }
20577
20715
  return;