@stream-io/video-client 1.61.1 → 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 (44) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/index.browser.es.js +281 -152
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +281 -152
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +281 -152
  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 +1 -1
  37. package/src/ringing/RingStatePoller.ts +1 -1
  38. package/src/ringing/__tests__/RingStatePoller.test.ts +1 -1
  39. package/src/ringing/__tests__/reconcileRingState.test.ts +33 -0
  40. package/src/ringing/reconcileRingState.ts +18 -10
  41. package/src/stats/SfuStatsReporter.ts +19 -93
  42. package/src/stats/__tests__/SfuStatsReporter.test.ts +2 -18
  43. package/src/stats/rtc/StatsTracer.ts +6 -6
  44. 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.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
  /**
@@ -10778,15 +10699,16 @@ class StreamSfuClient {
10778
10699
  /**
10779
10700
  * Decides what a ringing call should do next, based on the current call state.
10780
10701
  *
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.
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.
10785
10707
  *
10786
10708
  * @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.
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).
10790
10712
  * @returns whether the ring reached a terminal state. A failed join is not
10791
10713
  * terminal: the caller should keep trying while the ring is open.
10792
10714
  */
@@ -10841,10 +10763,16 @@ const reconcileAsCaller = async (call, joinSource) => {
10841
10763
  return false;
10842
10764
  };
10843
10765
  // the current user's own accept or reject, on this or another device, is
10844
- // 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.
10845
10768
  const reconcileAsCallee = async (call) => {
10846
- const createdById = call.state.createdBy?.id;
10847
- 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 ?? {};
10848
10776
  if (createdById && rejectedBy[createdById]) {
10849
10777
  call.logger.info('ring: the caller cancelled, leaving');
10850
10778
  return leave(call, {
@@ -10893,7 +10821,7 @@ class RingStatePoller {
10893
10821
  unsubscribe = [];
10894
10822
  constructor(call, options = {}) {
10895
10823
  this.call = call;
10896
- this.startAfterMs = options.startAfterMs ?? 15_000;
10824
+ this.startAfterMs = options.startAfterMs ?? 9_000;
10897
10825
  this.intervalMs = options.intervalMs ?? 5_000;
10898
10826
  }
10899
10827
  /**
@@ -11558,12 +11486,33 @@ const registerRingingCallEventHandlers = (call) => {
11558
11486
  };
11559
11487
 
11560
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
+ };
11561
11507
  /**
11562
11508
  * Tracks audio element bindings and periodically warns about
11563
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.
11564
11512
  */
11565
11513
  class AudioBindingsWatchdog {
11566
11514
  bindings = new Map();
11515
+ tracedStates = new Map();
11567
11516
  enabled = true;
11568
11517
  watchdogInterval;
11569
11518
  unsubscribeCallingState;
@@ -11574,8 +11523,6 @@ class AudioBindingsWatchdog {
11574
11523
  this.tracer = tracer;
11575
11524
  this.state = state;
11576
11525
  this.unsubscribeCallingState = createSubscription(state.callingState$, (callingState) => {
11577
- if (!this.enabled)
11578
- return;
11579
11526
  if (callingState !== exports.CallingState.JOINED) {
11580
11527
  this.stop();
11581
11528
  }
@@ -11604,15 +11551,15 @@ class AudioBindingsWatchdog {
11604
11551
  this.bindings.delete(toBindingKey(sessionId, trackType));
11605
11552
  };
11606
11553
  /**
11607
- * Enables or disables the watchdog.
11608
- * 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.
11609
11556
  */
11610
11557
  setEnabled = (enabled) => {
11611
11558
  this.enabled = enabled;
11612
11559
  if (enabled) {
11613
11560
  this.start();
11614
11561
  }
11615
- else {
11562
+ else if (this.state.callingState !== exports.CallingState.JOINED) {
11616
11563
  this.stop();
11617
11564
  }
11618
11565
  };
@@ -11622,33 +11569,61 @@ class AudioBindingsWatchdog {
11622
11569
  dispose = () => {
11623
11570
  this.stop();
11624
11571
  this.bindings.clear();
11572
+ this.tracedStates.clear();
11625
11573
  this.unsubscribeCallingState();
11626
11574
  };
11627
11575
  start = () => {
11628
11576
  clearInterval(this.watchdogInterval);
11629
11577
  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
- }
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);
11645
11593
  }
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}.`);
11594
+ if (screenShareAudioStream &&
11595
+ publishedTracks.includes(TrackType.SCREEN_SHARE_AUDIO) &&
11596
+ !this.bindings.has(toBindingKey(sessionId, 'screenShareAudioTrack'))) {
11597
+ danglingUserIds.push(userId);
11650
11598
  }
11651
- }, 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);
11652
11627
  };
11653
11628
  stop = () => {
11654
11629
  clearInterval(this.watchdogInterval);
@@ -11927,7 +11902,9 @@ class TrackSubscriptionManager {
11927
11902
  class MediaPlaybackWatchdog {
11928
11903
  logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
11929
11904
  kind;
11905
+ traceInfo;
11930
11906
  isBlocked;
11907
+ onGiveUp;
11931
11908
  element;
11932
11909
  tracer;
11933
11910
  controller = new AbortController();
@@ -11939,8 +11916,14 @@ class MediaPlaybackWatchdog {
11939
11916
  constructor(opts) {
11940
11917
  this.element = opts.element;
11941
11918
  this.kind = opts.kind;
11919
+ this.traceInfo = {
11920
+ kind: opts.kind,
11921
+ sessionId: opts.sessionId,
11922
+ trackType: opts.trackType,
11923
+ };
11942
11924
  this.tracer = opts.tracer;
11943
11925
  this.isBlocked = opts.isBlocked ?? (() => false);
11926
+ this.onGiveUp = opts.onGiveUp ?? (() => { });
11944
11927
  this.attach();
11945
11928
  }
11946
11929
  attach = () => {
@@ -11958,6 +11941,23 @@ class MediaPlaybackWatchdog {
11958
11941
  this.controller.abort();
11959
11942
  this.clearTimers();
11960
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
+ };
11961
11961
  clearTimers = () => {
11962
11962
  if (this.pendingTimer)
11963
11963
  clearTimeout(this.pendingTimer);
@@ -11979,17 +11979,34 @@ class MediaPlaybackWatchdog {
11979
11979
  if (this.element.paused)
11980
11980
  return;
11981
11981
  this.tracer.trace('mediaPlayback.recover.success', {
11982
- kind: this.kind,
11982
+ ...this.traceInfo,
11983
11983
  attempts: this.attempt,
11984
11984
  });
11985
11985
  this.attempt = 0;
11986
11986
  this.stopped = false;
11987
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
+ */
11988
11994
  onPauseOrSuspend = (event) => {
11989
- 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
+ }
11990
12006
  return;
12007
+ }
11991
12008
  this.tracer.trace('mediaPlayback.paused', {
11992
- kind: this.kind,
12009
+ ...this.traceInfo,
11993
12010
  reason: event.type,
11994
12011
  });
11995
12012
  if (this.settleTimer)
@@ -12003,7 +12020,7 @@ class MediaPlaybackWatchdog {
12003
12020
  const skipReason = this.computeSkipReason();
12004
12021
  if (skipReason) {
12005
12022
  this.tracer.trace('mediaPlayback.recover.skipped', {
12006
- kind: this.kind,
12023
+ ...this.traceInfo,
12007
12024
  reason: skipReason,
12008
12025
  });
12009
12026
  return;
@@ -12012,9 +12029,10 @@ class MediaPlaybackWatchdog {
12012
12029
  this.stopped = true;
12013
12030
  this.clearTimers();
12014
12031
  this.tracer.trace('mediaPlayback.recover.giveUp', {
12015
- kind: this.kind,
12032
+ ...this.traceInfo,
12016
12033
  attempts: this.attempt,
12017
12034
  });
12035
+ this.onGiveUp();
12018
12036
  return;
12019
12037
  }
12020
12038
  const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
@@ -12042,7 +12060,7 @@ class MediaPlaybackWatchdog {
12042
12060
  return;
12043
12061
  this.attempt += 1;
12044
12062
  this.tracer.trace('mediaPlayback.recover.attempt', {
12045
- kind: this.kind,
12063
+ ...this.traceInfo,
12046
12064
  attempt: this.attempt,
12047
12065
  });
12048
12066
  try {
@@ -12057,6 +12075,41 @@ class MediaPlaybackWatchdog {
12057
12075
  };
12058
12076
  }
12059
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
+
12060
12113
  /**
12061
12114
  * A manager class that handles dynascale related tasks like:
12062
12115
  *
@@ -12070,6 +12123,9 @@ class DynascaleManager {
12070
12123
  tracer;
12071
12124
  useWebAudio = false;
12072
12125
  audioContext;
12126
+ playbackWatchdogs = new Set();
12127
+ stopWatchingInterruptions;
12128
+ stopTracingAudioSession;
12073
12129
  trackSubscriptionManager;
12074
12130
  blockedAudioTracker;
12075
12131
  /**
@@ -12086,6 +12142,10 @@ class DynascaleManager {
12086
12142
  * Closes the audio context if it was created.
12087
12143
  */
12088
12144
  dispose = async () => {
12145
+ this.stopWatchingInterruptions?.();
12146
+ this.stopWatchingInterruptions = undefined;
12147
+ this.stopTracingAudioSession?.();
12148
+ this.stopTracingAudioSession = undefined;
12089
12149
  const context = this.audioContext;
12090
12150
  if (context && context.state !== 'closed') {
12091
12151
  document.removeEventListener('click', this.resumeAudioContext);
@@ -12225,10 +12285,12 @@ class DynascaleManager {
12225
12285
  // without prior user interaction:
12226
12286
  // https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
12227
12287
  videoElement.muted = true;
12228
- const playbackWatchdog = new MediaPlaybackWatchdog({
12288
+ const playbackWatchdog = this.createPlaybackWatchdog({
12229
12289
  element: videoElement,
12230
12290
  kind: 'video',
12231
12291
  tracer: this.tracer,
12292
+ sessionId,
12293
+ trackType,
12232
12294
  });
12233
12295
  const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
12234
12296
  const streamSubscription = participant$
@@ -12256,7 +12318,7 @@ class DynascaleManager {
12256
12318
  publishedTracksSubscription?.unsubscribe();
12257
12319
  streamSubscription.unsubscribe();
12258
12320
  resizeObserver?.disconnect();
12259
- playbackWatchdog.dispose();
12321
+ this.disposePlaybackWatchdog(playbackWatchdog);
12260
12322
  };
12261
12323
  };
12262
12324
  /**
@@ -12274,6 +12336,7 @@ class DynascaleManager {
12274
12336
  const participant = this.callState.findParticipantBySessionId(sessionId);
12275
12337
  if (!participant || participant.isLocalParticipant)
12276
12338
  return;
12339
+ this.traceAudioSessionState();
12277
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 }));
12278
12341
  const updateSinkId = (deviceId, audioContext) => {
12279
12342
  if (!deviceId)
@@ -12293,7 +12356,9 @@ class DynascaleManager {
12293
12356
  let sourceNode = undefined;
12294
12357
  let gainNode = undefined;
12295
12358
  let audioWatchdog = undefined;
12359
+ let blockedByGiveUp = false;
12296
12360
  const clearBlockedAudio = () => {
12361
+ blockedByGiveUp = false;
12297
12362
  if (!this.blockedAudioTracker.isBlocked(audioElement))
12298
12363
  return;
12299
12364
  this.blockedAudioTracker.markBlocked(audioElement, false);
@@ -12301,15 +12366,18 @@ class DynascaleManager {
12301
12366
  audioElement.addEventListener('playing', clearBlockedAudio);
12302
12367
  const isAudioTrack = trackType === 'audioTrack';
12303
12368
  const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
12369
+ let sourceChangeTimer;
12304
12370
  const updateMediaStreamSubscription = participant$
12305
12371
  .pipe(rxjs.distinctUntilKeyChanged(trackKey))
12306
12372
  .subscribe((p) => {
12307
12373
  const source = isAudioTrack ? p.audioStream : p.screenShareAudioStream;
12308
12374
  if (audioElement.srcObject === source)
12309
12375
  return;
12310
- setTimeout(() => {
12376
+ clearTimeout(sourceChangeTimer);
12377
+ sourceChangeTimer = setTimeout(() => {
12378
+ sourceChangeTimer = undefined;
12311
12379
  audioElement.srcObject = source ?? null;
12312
- audioWatchdog?.dispose();
12380
+ this.disposePlaybackWatchdog(audioWatchdog);
12313
12381
  audioWatchdog = undefined;
12314
12382
  if (!source) {
12315
12383
  clearBlockedAudio();
@@ -12340,15 +12408,25 @@ class DynascaleManager {
12340
12408
  this.tracer.trace('audioPlaybackError', e.message);
12341
12409
  if (e.name === 'NotAllowedError') {
12342
12410
  this.tracer.trace('audioPlaybackBlocked', null);
12411
+ blockedByGiveUp = false;
12343
12412
  this.blockedAudioTracker.markBlocked(audioElement, true, sessionId);
12344
12413
  }
12345
12414
  this.logger.warn(`Failed to play audio stream`, e);
12346
12415
  });
12347
- audioWatchdog = new MediaPlaybackWatchdog({
12416
+ audioWatchdog = this.createPlaybackWatchdog({
12348
12417
  element: audioElement,
12349
12418
  kind: 'audio',
12350
12419
  tracer: this.tracer,
12351
- 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
+ },
12352
12430
  });
12353
12431
  }
12354
12432
  const { selectedDevice } = this.speaker.state;
@@ -12378,13 +12456,51 @@ class DynascaleManager {
12378
12456
  sinkIdSubscription?.unsubscribe();
12379
12457
  volumeSubscription.unsubscribe();
12380
12458
  updateMediaStreamSubscription.unsubscribe();
12459
+ clearTimeout(sourceChangeTimer);
12381
12460
  audioElement.srcObject = null;
12382
12461
  sourceNode?.disconnect();
12383
12462
  gainNode?.disconnect();
12384
- audioWatchdog?.dispose();
12463
+ this.disposePlaybackWatchdog(audioWatchdog);
12385
12464
  audioWatchdog = undefined;
12386
12465
  };
12387
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
+ };
12388
12504
  getOrCreateAudioContext = () => {
12389
12505
  if (!this.useWebAudio)
12390
12506
  return;
@@ -12407,7 +12523,6 @@ class DynascaleManager {
12407
12523
  audioSession.type = 'play-and-record';
12408
12524
  let isSessionInterrupted = false;
12409
12525
  audioSession.addEventListener('statechange', () => {
12410
- this.tracer.trace('audioSession.state', audioSession.state);
12411
12526
  if (audioSession.state === 'interrupted') {
12412
12527
  isSessionInterrupted = true;
12413
12528
  }
@@ -13966,9 +14081,22 @@ class DeviceManager {
13966
14081
  }
13967
14082
  const devices = getCurrentValue(this.listDevices()) || [];
13968
14083
  const currentDevice = this.findDevice(devices, selectedDevice) ??
13969
- createSyntheticDevice(selectedDevice, deviceKind);
14084
+ createSyntheticDevice(selectedDevice, deviceKind, this.getActiveTrackLabel(selectedDevice));
13970
14085
  writePreferences(currentDevice, deviceKey, muted, storageKey);
13971
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
+ };
13972
14100
  async applyPersistedPreferences(enabledInCallType, forceDisabled = false) {
13973
14101
  const deviceKey = this.trackType === TrackType.AUDIO ? 'microphone' : 'camera';
13974
14102
  const preferences = readPreferences(this.devicePersistence.storageKey);
@@ -16804,9 +16932,6 @@ class Call {
16804
16932
  options: statsOptions,
16805
16933
  subscriber: this.subscriber,
16806
16934
  publisher: this.publisher,
16807
- microphone: this.microphone,
16808
- camera: this.camera,
16809
- state: this.state,
16810
16935
  tracer: this.tracer,
16811
16936
  unifiedSessionId,
16812
16937
  });
@@ -19656,11 +19781,11 @@ class StreamClient {
19656
19781
  return await this.wsConnection.connect(this.defaultWSTimeout);
19657
19782
  };
19658
19783
  getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
19659
- "1.61.1";
19784
+ "1.61.2";
19660
19785
  getUserAgent = () => {
19661
19786
  if (!this.cachedUserAgent) {
19662
19787
  const { clientAppIdentifier = {} } = this.options;
19663
- const { sdkName = 'js', sdkVersion = "1.61.1", ...extras } = clientAppIdentifier;
19788
+ const { sdkName = 'js', sdkVersion = "1.61.2", ...extras } = clientAppIdentifier;
19664
19789
  this.cachedUserAgent = [
19665
19790
  `stream-video-${sdkName}-v${sdkVersion}`,
19666
19791
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -20600,6 +20725,10 @@ class StreamVideoClient {
20600
20725
  const call = this.writeableStateStore.findCall(c.call.type, c.call.id);
20601
20726
  if (call) {
20602
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
+ });
20603
20732
  }
20604
20733
  }
20605
20734
  return;