@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.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.0";
5078
+ const version = "1.61.2";
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
  /**
@@ -8774,7 +8695,12 @@ class BasePeerConnection {
8774
8695
  }
8775
8696
  // we can't recover from a failed connection state (contrary to ICE)
8776
8697
  if (state === 'failed') {
8777
- this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.CONNECTION_FAILED, this.peerType);
8698
+ const reason = this.iceHasEverConnected
8699
+ ? ReconnectReason.CONNECTION_FAILED
8700
+ : ReconnectReason.ICE_NEVER_CONNECTED;
8701
+ clearTimeout(this.preConnectStuckTimeout);
8702
+ this.preConnectStuckTimeout = undefined;
8703
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, reason, this.peerType);
8778
8704
  return;
8779
8705
  }
8780
8706
  this.handleConnectionStateUpdate(state);
@@ -10773,15 +10699,16 @@ class StreamSfuClient {
10773
10699
  /**
10774
10700
  * Decides what a ringing call should do next, based on the current call state.
10775
10701
  *
10776
- * The `call.accepted`, `call.rejected` and `call.missed` handlers and the ring
10777
- * state poller both run this. They differ only in how the state got there: the
10778
- * handlers rely on `CallState.updateFromEvent`, which runs before them, and the
10779
- * poller applies the polled ring state itself.
10702
+ * The `call.accepted` and `call.rejected` handlers, the ring state poller and
10703
+ * the rewatch after a WS reconnect all run this. They differ only in how the
10704
+ * state got there: the handlers rely on `CallState.updateFromEvent`, which runs
10705
+ * before them, while the poller applies the polled ring state itself and the
10706
+ * rewatch applies the queried call state.
10780
10707
  *
10781
10708
  * @param call the call to reconcile.
10782
- * @param joinSource which of the two triggered this run, reported on the
10783
- * caller's join: `ring-ws` for the event handlers, `ring-poll-api` for the
10784
- * poller.
10709
+ * @param joinSource what triggered this run, reported on the caller's join:
10710
+ * `ring-ws` for the event handlers, `ring-poll-api` for the poller and the
10711
+ * rewatch (both come from an API response).
10785
10712
  * @returns whether the ring reached a terminal state. A failed join is not
10786
10713
  * terminal: the caller should keep trying while the ring is open.
10787
10714
  */
@@ -10836,10 +10763,16 @@ const reconcileAsCaller = async (call, joinSource) => {
10836
10763
  return false;
10837
10764
  };
10838
10765
  // the current user's own accept or reject, on this or another device, is
10839
- // handled by `resolveOwnRingOutcome`, and `call.ended` by `watchCallEnded`.
10766
+ // handled by `resolveOwnRingOutcome`, and a live `call.ended` by
10767
+ // `watchCallEnded`. An end missed while offline is handled here.
10840
10768
  const reconcileAsCallee = async (call) => {
10841
- const createdById = call.state.createdBy?.id;
10842
- const rejectedBy = call.state.session?.rejected_by ?? {};
10769
+ const { session, endedAt, createdBy } = call.state;
10770
+ if (endedAt || session?.ended_at) {
10771
+ call.logger.info('ring: the call has ended, leaving');
10772
+ return leave(call, { reason: 'ended', message: 'ring: call ended' });
10773
+ }
10774
+ const createdById = createdBy?.id;
10775
+ const rejectedBy = session?.rejected_by ?? {};
10843
10776
  if (createdById && rejectedBy[createdById]) {
10844
10777
  call.logger.info('ring: the caller cancelled, leaving');
10845
10778
  return leave(call, {
@@ -10888,7 +10821,7 @@ class RingStatePoller {
10888
10821
  unsubscribe = [];
10889
10822
  constructor(call, options = {}) {
10890
10823
  this.call = call;
10891
- this.startAfterMs = options.startAfterMs ?? 15_000;
10824
+ this.startAfterMs = options.startAfterMs ?? 9_000;
10892
10825
  this.intervalMs = options.intervalMs ?? 5_000;
10893
10826
  }
10894
10827
  /**
@@ -11553,12 +11486,33 @@ const registerRingingCallEventHandlers = (call) => {
11553
11486
  };
11554
11487
 
11555
11488
  const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
11489
+ const toElementState = (element) => {
11490
+ const stream = element.srcObject;
11491
+ const [track] = stream?.getAudioTracks?.() ?? [];
11492
+ return {
11493
+ paused: element.paused,
11494
+ muted: element.muted,
11495
+ volume: Math.round(element.volume * 100) / 100,
11496
+ readyState: element.readyState,
11497
+ sinkId: element.sinkId,
11498
+ track: track
11499
+ ? {
11500
+ enabled: track.enabled,
11501
+ muted: track.muted,
11502
+ readyState: track.readyState,
11503
+ }
11504
+ : null,
11505
+ };
11506
+ };
11556
11507
  /**
11557
11508
  * Tracks audio element bindings and periodically warns about
11558
11509
  * remote participants whose audio streams have no bound element.
11510
+ * On the same tick, it traces the playback state of the bound elements,
11511
+ * limited to the bindings whose state changed since the previous trace.
11559
11512
  */
11560
11513
  class AudioBindingsWatchdog {
11561
11514
  bindings = new Map();
11515
+ tracedStates = new Map();
11562
11516
  enabled = true;
11563
11517
  watchdogInterval;
11564
11518
  unsubscribeCallingState;
@@ -11569,8 +11523,6 @@ class AudioBindingsWatchdog {
11569
11523
  this.tracer = tracer;
11570
11524
  this.state = state;
11571
11525
  this.unsubscribeCallingState = createSubscription(state.callingState$, (callingState) => {
11572
- if (!this.enabled)
11573
- return;
11574
11526
  if (callingState !== exports.CallingState.JOINED) {
11575
11527
  this.stop();
11576
11528
  }
@@ -11599,15 +11551,15 @@ class AudioBindingsWatchdog {
11599
11551
  this.bindings.delete(toBindingKey(sessionId, trackType));
11600
11552
  };
11601
11553
  /**
11602
- * Enables or disables the watchdog.
11603
- * When disabled, the periodic check stops but bindings are still tracked.
11554
+ * Enables or disables the dangling binding warnings.
11555
+ * Bindings are still tracked and their playback state is still traced.
11604
11556
  */
11605
11557
  setEnabled = (enabled) => {
11606
11558
  this.enabled = enabled;
11607
11559
  if (enabled) {
11608
11560
  this.start();
11609
11561
  }
11610
- else {
11562
+ else if (this.state.callingState !== exports.CallingState.JOINED) {
11611
11563
  this.stop();
11612
11564
  }
11613
11565
  };
@@ -11617,33 +11569,61 @@ class AudioBindingsWatchdog {
11617
11569
  dispose = () => {
11618
11570
  this.stop();
11619
11571
  this.bindings.clear();
11572
+ this.tracedStates.clear();
11620
11573
  this.unsubscribeCallingState();
11621
11574
  };
11622
11575
  start = () => {
11623
11576
  clearInterval(this.watchdogInterval);
11624
11577
  this.watchdogInterval = setInterval(() => {
11625
- const danglingUserIds = [];
11626
- for (const p of this.state.participants) {
11627
- if (p.isLocalParticipant)
11628
- continue;
11629
- const { audioStream, screenShareAudioStream, sessionId, userId, publishedTracks, } = p;
11630
- if (audioStream &&
11631
- publishedTracks.includes(TrackType.AUDIO) &&
11632
- !this.bindings.has(toBindingKey(sessionId))) {
11633
- danglingUserIds.push(userId);
11634
- }
11635
- if (screenShareAudioStream &&
11636
- publishedTracks.includes(TrackType.SCREEN_SHARE_AUDIO) &&
11637
- !this.bindings.has(toBindingKey(sessionId, 'screenShareAudioTrack'))) {
11638
- danglingUserIds.push(userId);
11639
- }
11578
+ if (this.enabled)
11579
+ this.warnAboutDanglingBindings();
11580
+ this.traceBindingStates();
11581
+ }, 3000);
11582
+ };
11583
+ warnAboutDanglingBindings = () => {
11584
+ const danglingUserIds = [];
11585
+ for (const p of this.state.participants) {
11586
+ if (p.isLocalParticipant)
11587
+ continue;
11588
+ const { audioStream, screenShareAudioStream, sessionId, userId, publishedTracks, } = p;
11589
+ if (audioStream &&
11590
+ publishedTracks.includes(TrackType.AUDIO) &&
11591
+ !this.bindings.has(toBindingKey(sessionId))) {
11592
+ danglingUserIds.push(userId);
11640
11593
  }
11641
- if (danglingUserIds.length > 0) {
11642
- const key = 'audioBinding.danglingWarning';
11643
- this.tracer.traceOnce(key, key, danglingUserIds);
11644
- this.logger.warn(`Dangling audio bindings detected. Did you forget to bind the audio element? user_ids: ${danglingUserIds}.`);
11594
+ if (screenShareAudioStream &&
11595
+ publishedTracks.includes(TrackType.SCREEN_SHARE_AUDIO) &&
11596
+ !this.bindings.has(toBindingKey(sessionId, 'screenShareAudioTrack'))) {
11597
+ danglingUserIds.push(userId);
11645
11598
  }
11646
- }, 3000);
11599
+ }
11600
+ if (danglingUserIds.length > 0) {
11601
+ const key = 'audioBinding.danglingWarning';
11602
+ this.tracer.traceOnce(key, key, danglingUserIds);
11603
+ this.logger.warn(`Dangling audio bindings detected. Did you forget to bind the audio element? user_ids: ${danglingUserIds}.`);
11604
+ }
11605
+ };
11606
+ traceBindingStates = () => {
11607
+ const changes = {};
11608
+ let hasChanges = false;
11609
+ for (const [key, element] of this.bindings) {
11610
+ const state = toElementState(element);
11611
+ const serialized = JSON.stringify(state);
11612
+ if (this.tracedStates.get(key) === serialized)
11613
+ continue;
11614
+ this.tracedStates.set(key, serialized);
11615
+ changes[key] = state;
11616
+ hasChanges = true;
11617
+ }
11618
+ for (const key of this.tracedStates.keys()) {
11619
+ if (this.bindings.has(key))
11620
+ continue;
11621
+ this.tracedStates.delete(key);
11622
+ changes[key] = null;
11623
+ hasChanges = true;
11624
+ }
11625
+ if (hasChanges)
11626
+ this.tracer.trace('audioBinding.state', changes);
11647
11627
  };
11648
11628
  stop = () => {
11649
11629
  clearInterval(this.watchdogInterval);
@@ -11922,7 +11902,9 @@ class TrackSubscriptionManager {
11922
11902
  class MediaPlaybackWatchdog {
11923
11903
  logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
11924
11904
  kind;
11905
+ traceInfo;
11925
11906
  isBlocked;
11907
+ onGiveUp;
11926
11908
  element;
11927
11909
  tracer;
11928
11910
  controller = new AbortController();
@@ -11934,8 +11916,14 @@ class MediaPlaybackWatchdog {
11934
11916
  constructor(opts) {
11935
11917
  this.element = opts.element;
11936
11918
  this.kind = opts.kind;
11919
+ this.traceInfo = {
11920
+ kind: opts.kind,
11921
+ sessionId: opts.sessionId,
11922
+ trackType: opts.trackType,
11923
+ };
11937
11924
  this.tracer = opts.tracer;
11938
11925
  this.isBlocked = opts.isBlocked ?? (() => false);
11926
+ this.onGiveUp = opts.onGiveUp ?? (() => { });
11939
11927
  this.attach();
11940
11928
  }
11941
11929
  attach = () => {
@@ -11953,6 +11941,23 @@ class MediaPlaybackWatchdog {
11953
11941
  this.controller.abort();
11954
11942
  this.clearTimers();
11955
11943
  };
11944
+ /**
11945
+ * Starts a fresh recovery cycle, e.g. after a media interruption ended.
11946
+ * Unlike a regular pause-triggered recovery, it also tries elements that
11947
+ * were skipped as blocked or not ready, since those conditions may have
11948
+ * cleared together with the interruption.
11949
+ */
11950
+ rearm = () => {
11951
+ if (this.disposed)
11952
+ return;
11953
+ this.clearTimers();
11954
+ this.attempt = 0;
11955
+ this.stopped = false;
11956
+ const { paused, srcObject, ended } = this.element;
11957
+ if (!paused || !srcObject || ended)
11958
+ return;
11959
+ this.pendingTimer = setTimeout(this.attemptPlay, 0);
11960
+ };
11956
11961
  clearTimers = () => {
11957
11962
  if (this.pendingTimer)
11958
11963
  clearTimeout(this.pendingTimer);
@@ -11974,17 +11979,34 @@ class MediaPlaybackWatchdog {
11974
11979
  if (this.element.paused)
11975
11980
  return;
11976
11981
  this.tracer.trace('mediaPlayback.recover.success', {
11977
- kind: this.kind,
11982
+ ...this.traceInfo,
11978
11983
  attempts: this.attempt,
11979
11984
  });
11980
11985
  this.attempt = 0;
11981
11986
  this.stopped = false;
11982
11987
  };
11988
+ /**
11989
+ * Media elements backed by a MediaStream fire `suspend` routinely (after
11990
+ * the initial load, on source swaps, on layer switches), so a `suspend`
11991
+ * that needs no recovery is ignored without a trace. An explicit `pause`
11992
+ * is rare and always traced, along with the reason recovery was skipped.
11993
+ */
11983
11994
  onPauseOrSuspend = (event) => {
11984
- if (this.disposed || this.stopped)
11995
+ if (this.disposed || this.stopped || this.pendingTimer)
11996
+ return;
11997
+ const skipReason = this.computeSkipReason();
11998
+ if (skipReason) {
11999
+ if (event.type === 'pause') {
12000
+ this.tracer.trace('mediaPlayback.paused', {
12001
+ ...this.traceInfo,
12002
+ reason: event.type,
12003
+ skipped: skipReason,
12004
+ });
12005
+ }
11985
12006
  return;
12007
+ }
11986
12008
  this.tracer.trace('mediaPlayback.paused', {
11987
- kind: this.kind,
12009
+ ...this.traceInfo,
11988
12010
  reason: event.type,
11989
12011
  });
11990
12012
  if (this.settleTimer)
@@ -11998,7 +12020,7 @@ class MediaPlaybackWatchdog {
11998
12020
  const skipReason = this.computeSkipReason();
11999
12021
  if (skipReason) {
12000
12022
  this.tracer.trace('mediaPlayback.recover.skipped', {
12001
- kind: this.kind,
12023
+ ...this.traceInfo,
12002
12024
  reason: skipReason,
12003
12025
  });
12004
12026
  return;
@@ -12007,9 +12029,10 @@ class MediaPlaybackWatchdog {
12007
12029
  this.stopped = true;
12008
12030
  this.clearTimers();
12009
12031
  this.tracer.trace('mediaPlayback.recover.giveUp', {
12010
- kind: this.kind,
12032
+ ...this.traceInfo,
12011
12033
  attempts: this.attempt,
12012
12034
  });
12035
+ this.onGiveUp();
12013
12036
  return;
12014
12037
  }
12015
12038
  const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
@@ -12037,7 +12060,7 @@ class MediaPlaybackWatchdog {
12037
12060
  return;
12038
12061
  this.attempt += 1;
12039
12062
  this.tracer.trace('mediaPlayback.recover.attempt', {
12040
- kind: this.kind,
12063
+ ...this.traceInfo,
12041
12064
  attempt: this.attempt,
12042
12065
  });
12043
12066
  try {
@@ -12052,6 +12075,41 @@ class MediaPlaybackWatchdog {
12052
12075
  };
12053
12076
  }
12054
12077
 
12078
+ /**
12079
+ * Checks whether media playback and capture are likely interrupted: the page
12080
+ * is hidden, or WebKit reports the audio session as interrupted (a phone
12081
+ * call, Siri, or another app taking over audio).
12082
+ */
12083
+ const isMediaInterrupted = () => {
12084
+ if (typeof document === 'undefined')
12085
+ return false;
12086
+ return (document.visibilityState === 'hidden' ||
12087
+ navigator.audioSession?.state === 'interrupted');
12088
+ };
12089
+ /**
12090
+ * Invokes `onResumed` every time the page leaves a media interruption,
12091
+ * as defined by {@link isMediaInterrupted}.
12092
+ *
12093
+ * @param onResumed the callback to invoke.
12094
+ * @returns a function that stops watching.
12095
+ */
12096
+ const watchMediaInterruptions = (onResumed) => {
12097
+ if (typeof document === 'undefined')
12098
+ return () => { };
12099
+ let interrupted = isMediaInterrupted();
12100
+ const check = () => {
12101
+ const wasInterrupted = interrupted;
12102
+ interrupted = isMediaInterrupted();
12103
+ if (wasInterrupted && !interrupted)
12104
+ onResumed();
12105
+ };
12106
+ const controller = new AbortController();
12107
+ const { signal } = controller;
12108
+ document.addEventListener('visibilitychange', check, { signal });
12109
+ navigator.audioSession?.addEventListener('statechange', check, { signal });
12110
+ return () => controller.abort();
12111
+ };
12112
+
12055
12113
  /**
12056
12114
  * A manager class that handles dynascale related tasks like:
12057
12115
  *
@@ -12065,6 +12123,9 @@ class DynascaleManager {
12065
12123
  tracer;
12066
12124
  useWebAudio = false;
12067
12125
  audioContext;
12126
+ playbackWatchdogs = new Set();
12127
+ stopWatchingInterruptions;
12128
+ stopTracingAudioSession;
12068
12129
  trackSubscriptionManager;
12069
12130
  blockedAudioTracker;
12070
12131
  /**
@@ -12081,6 +12142,10 @@ class DynascaleManager {
12081
12142
  * Closes the audio context if it was created.
12082
12143
  */
12083
12144
  dispose = async () => {
12145
+ this.stopWatchingInterruptions?.();
12146
+ this.stopWatchingInterruptions = undefined;
12147
+ this.stopTracingAudioSession?.();
12148
+ this.stopTracingAudioSession = undefined;
12084
12149
  const context = this.audioContext;
12085
12150
  if (context && context.state !== 'closed') {
12086
12151
  document.removeEventListener('click', this.resumeAudioContext);
@@ -12220,10 +12285,12 @@ class DynascaleManager {
12220
12285
  // without prior user interaction:
12221
12286
  // https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
12222
12287
  videoElement.muted = true;
12223
- const playbackWatchdog = new MediaPlaybackWatchdog({
12288
+ const playbackWatchdog = this.createPlaybackWatchdog({
12224
12289
  element: videoElement,
12225
12290
  kind: 'video',
12226
12291
  tracer: this.tracer,
12292
+ sessionId,
12293
+ trackType,
12227
12294
  });
12228
12295
  const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
12229
12296
  const streamSubscription = participant$
@@ -12251,7 +12318,7 @@ class DynascaleManager {
12251
12318
  publishedTracksSubscription?.unsubscribe();
12252
12319
  streamSubscription.unsubscribe();
12253
12320
  resizeObserver?.disconnect();
12254
- playbackWatchdog.dispose();
12321
+ this.disposePlaybackWatchdog(playbackWatchdog);
12255
12322
  };
12256
12323
  };
12257
12324
  /**
@@ -12269,6 +12336,7 @@ class DynascaleManager {
12269
12336
  const participant = this.callState.findParticipantBySessionId(sessionId);
12270
12337
  if (!participant || participant.isLocalParticipant)
12271
12338
  return;
12339
+ this.traceAudioSessionState();
12272
12340
  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 }));
12273
12341
  const updateSinkId = (deviceId, audioContext) => {
12274
12342
  if (!deviceId)
@@ -12288,7 +12356,9 @@ class DynascaleManager {
12288
12356
  let sourceNode = undefined;
12289
12357
  let gainNode = undefined;
12290
12358
  let audioWatchdog = undefined;
12359
+ let blockedByGiveUp = false;
12291
12360
  const clearBlockedAudio = () => {
12361
+ blockedByGiveUp = false;
12292
12362
  if (!this.blockedAudioTracker.isBlocked(audioElement))
12293
12363
  return;
12294
12364
  this.blockedAudioTracker.markBlocked(audioElement, false);
@@ -12296,15 +12366,18 @@ class DynascaleManager {
12296
12366
  audioElement.addEventListener('playing', clearBlockedAudio);
12297
12367
  const isAudioTrack = trackType === 'audioTrack';
12298
12368
  const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
12369
+ let sourceChangeTimer;
12299
12370
  const updateMediaStreamSubscription = participant$
12300
12371
  .pipe(rxjs.distinctUntilKeyChanged(trackKey))
12301
12372
  .subscribe((p) => {
12302
12373
  const source = isAudioTrack ? p.audioStream : p.screenShareAudioStream;
12303
12374
  if (audioElement.srcObject === source)
12304
12375
  return;
12305
- setTimeout(() => {
12376
+ clearTimeout(sourceChangeTimer);
12377
+ sourceChangeTimer = setTimeout(() => {
12378
+ sourceChangeTimer = undefined;
12306
12379
  audioElement.srcObject = source ?? null;
12307
- audioWatchdog?.dispose();
12380
+ this.disposePlaybackWatchdog(audioWatchdog);
12308
12381
  audioWatchdog = undefined;
12309
12382
  if (!source) {
12310
12383
  clearBlockedAudio();
@@ -12335,15 +12408,25 @@ class DynascaleManager {
12335
12408
  this.tracer.trace('audioPlaybackError', e.message);
12336
12409
  if (e.name === 'NotAllowedError') {
12337
12410
  this.tracer.trace('audioPlaybackBlocked', null);
12411
+ blockedByGiveUp = false;
12338
12412
  this.blockedAudioTracker.markBlocked(audioElement, true, sessionId);
12339
12413
  }
12340
12414
  this.logger.warn(`Failed to play audio stream`, e);
12341
12415
  });
12342
- audioWatchdog = new MediaPlaybackWatchdog({
12416
+ audioWatchdog = this.createPlaybackWatchdog({
12343
12417
  element: audioElement,
12344
12418
  kind: 'audio',
12345
12419
  tracer: this.tracer,
12346
- isBlocked: () => this.blockedAudioTracker.isBlocked(audioElement),
12420
+ sessionId,
12421
+ trackType,
12422
+ // only an autoplay-policy block needs a user gesture; a give-up
12423
+ // block is for the UI and must not stop a re-armed cycle
12424
+ isBlocked: () => !blockedByGiveUp &&
12425
+ this.blockedAudioTracker.isBlocked(audioElement),
12426
+ onGiveUp: () => {
12427
+ blockedByGiveUp = true;
12428
+ this.blockedAudioTracker.markBlocked(audioElement, true, sessionId);
12429
+ },
12347
12430
  });
12348
12431
  }
12349
12432
  const { selectedDevice } = this.speaker.state;
@@ -12373,13 +12456,51 @@ class DynascaleManager {
12373
12456
  sinkIdSubscription?.unsubscribe();
12374
12457
  volumeSubscription.unsubscribe();
12375
12458
  updateMediaStreamSubscription.unsubscribe();
12459
+ clearTimeout(sourceChangeTimer);
12376
12460
  audioElement.srcObject = null;
12377
12461
  sourceNode?.disconnect();
12378
12462
  gainNode?.disconnect();
12379
- audioWatchdog?.dispose();
12463
+ this.disposePlaybackWatchdog(audioWatchdog);
12380
12464
  audioWatchdog = undefined;
12381
12465
  };
12382
12466
  };
12467
+ /**
12468
+ * Creates a playback watchdog that is re-armed whenever a media
12469
+ * interruption (backgrounding, phone call, Siri) ends.
12470
+ */
12471
+ createPlaybackWatchdog = (opts) => {
12472
+ const watchdog = new MediaPlaybackWatchdog(opts);
12473
+ this.playbackWatchdogs.add(watchdog);
12474
+ this.stopWatchingInterruptions ??= watchMediaInterruptions(this.rearmPlayback);
12475
+ return watchdog;
12476
+ };
12477
+ disposePlaybackWatchdog = (watchdog) => {
12478
+ if (!watchdog)
12479
+ return;
12480
+ watchdog.dispose();
12481
+ this.playbackWatchdogs.delete(watchdog);
12482
+ };
12483
+ rearmPlayback = () => {
12484
+ this.tracer.trace('mediaPlayback.rearm', this.playbackWatchdogs.size);
12485
+ this.playbackWatchdogs.forEach((watchdog) => watchdog.rearm());
12486
+ };
12487
+ /**
12488
+ * Traces the WebKit audio session state (initial value and changes) in
12489
+ * both playback modes, as it explains most iOS playback interruptions.
12490
+ */
12491
+ traceAudioSessionState = () => {
12492
+ const audioSession = navigator.audioSession;
12493
+ if (!audioSession || this.stopTracingAudioSession)
12494
+ return;
12495
+ const trace = () => {
12496
+ this.tracer.trace('audioSession.state', audioSession.state);
12497
+ };
12498
+ trace();
12499
+ audioSession.addEventListener('statechange', trace);
12500
+ this.stopTracingAudioSession = () => {
12501
+ audioSession.removeEventListener('statechange', trace);
12502
+ };
12503
+ };
12383
12504
  getOrCreateAudioContext = () => {
12384
12505
  if (!this.useWebAudio)
12385
12506
  return;
@@ -12402,7 +12523,6 @@ class DynascaleManager {
12402
12523
  audioSession.type = 'play-and-record';
12403
12524
  let isSessionInterrupted = false;
12404
12525
  audioSession.addEventListener('statechange', () => {
12405
- this.tracer.trace('audioSession.state', audioSession.state);
12406
12526
  if (audioSession.state === 'interrupted') {
12407
12527
  isSessionInterrupted = true;
12408
12528
  }
@@ -13961,9 +14081,22 @@ class DeviceManager {
13961
14081
  }
13962
14082
  const devices = getCurrentValue(this.listDevices()) || [];
13963
14083
  const currentDevice = this.findDevice(devices, selectedDevice) ??
13964
- createSyntheticDevice(selectedDevice, deviceKind);
14084
+ createSyntheticDevice(selectedDevice, deviceKind, this.getActiveTrackLabel(selectedDevice));
13965
14085
  writePreferences(currentDevice, deviceKey, muted, storageKey);
13966
14086
  }
14087
+ /**
14088
+ * Returns the label of the active track if it was captured from the given
14089
+ * device. Used as a fallback when the device list hasn't been enumerated yet,
14090
+ * so the persisted preference still carries the device label.
14091
+ */
14092
+ getActiveTrackLabel = (deviceId) => {
14093
+ const stream = this.state.rootMediaStream;
14094
+ const tracks = this.trackType === TrackType.AUDIO
14095
+ ? stream?.getAudioTracks()
14096
+ : stream?.getVideoTracks();
14097
+ const track = tracks?.find((t) => t.getSettings().deviceId === deviceId);
14098
+ return track?.label;
14099
+ };
13967
14100
  async applyPersistedPreferences(enabledInCallType, forceDisabled = false) {
13968
14101
  const deviceKey = this.trackType === TrackType.AUDIO ? 'microphone' : 'camera';
13969
14102
  const preferences = readPreferences(this.devicePersistence.storageKey);
@@ -16799,9 +16932,6 @@ class Call {
16799
16932
  options: statsOptions,
16800
16933
  subscriber: this.subscriber,
16801
16934
  publisher: this.publisher,
16802
- microphone: this.microphone,
16803
- camera: this.camera,
16804
- state: this.state,
16805
16935
  tracer: this.tracer,
16806
16936
  unifiedSessionId,
16807
16937
  });
@@ -19651,11 +19781,11 @@ class StreamClient {
19651
19781
  return await this.wsConnection.connect(this.defaultWSTimeout);
19652
19782
  };
19653
19783
  getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
19654
- "1.61.0";
19784
+ "1.61.2";
19655
19785
  getUserAgent = () => {
19656
19786
  if (!this.cachedUserAgent) {
19657
19787
  const { clientAppIdentifier = {} } = this.options;
19658
- const { sdkName = 'js', sdkVersion = "1.61.0", ...extras } = clientAppIdentifier;
19788
+ const { sdkName = 'js', sdkVersion = "1.61.2", ...extras } = clientAppIdentifier;
19659
19789
  this.cachedUserAgent = [
19660
19790
  `stream-video-${sdkName}-v${sdkVersion}`,
19661
19791
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -20190,7 +20320,11 @@ class ClientEventReporter {
20190
20320
  return;
20191
20321
  }
20192
20322
  if (event.state === 'failed') {
20193
- this.emitPeerConnectionFailure(cid, role, 'DTLS_CONNECTIVITY_FAILED', 'DTLS connectivity checks failed');
20323
+ const iceConnected = event.iceConnectionState === 'connected' ||
20324
+ event.iceConnectionState === 'completed';
20325
+ this.emitPeerConnectionFailure(cid, role, iceConnected ? 'DTLS_CONNECTIVITY_FAILED' : 'ICE_CONNECTIVITY_FAILED', iceConnected
20326
+ ? 'DTLS connectivity checks failed'
20327
+ : 'ICE connectivity checks failed');
20194
20328
  return;
20195
20329
  }
20196
20330
  switch (event.state) {
@@ -20591,6 +20725,10 @@ class StreamVideoClient {
20591
20725
  const call = this.writeableStateStore.findCall(c.call.type, c.call.id);
20592
20726
  if (call) {
20593
20727
  call.updateFromCallStateResponse(c);
20728
+ // ring events missed while the WS was down aren't replayed
20729
+ reconcileRingState(call, 'ring-poll-api').catch((err) => {
20730
+ call.logger.error('Failed to reconcile the ring state after rewatch', err);
20731
+ });
20594
20732
  }
20595
20733
  }
20596
20734
  return;