@stream-io/video-client 1.58.0 → 1.59.1

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 (57) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.browser.es.js +367 -76
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +378 -74
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.es.js +367 -76
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +15 -4
  10. package/dist/src/StreamVideoClient.d.ts +9 -1
  11. package/dist/src/coordinator/connection/utils.d.ts +10 -0
  12. package/dist/src/devices/CameraManager.d.ts +2 -1
  13. package/dist/src/devices/DeviceManager.d.ts +1 -1
  14. package/dist/src/devices/MicrophoneManager.d.ts +2 -1
  15. package/dist/src/gen/coordinator/index.d.ts +26 -0
  16. package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
  17. package/dist/src/helpers/concurrency.d.ts +8 -0
  18. package/dist/src/helpers/loopback.d.ts +55 -0
  19. package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
  20. package/dist/src/rtc/Subscriber.d.ts +5 -0
  21. package/dist/src/sorting/participants.d.ts +13 -0
  22. package/index.ts +2 -0
  23. package/package.json +1 -1
  24. package/src/Call.ts +156 -134
  25. package/src/StreamVideoClient.ts +35 -8
  26. package/src/__tests__/Call.lifecycle.test.ts +33 -2
  27. package/src/__tests__/Call.publishing.test.ts +42 -1
  28. package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
  29. package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
  30. package/src/__tests__/StreamVideoClient.ringing.test.ts +43 -24
  31. package/src/__tests__/StreamVideoClient.test.ts +14 -12
  32. package/src/coordinator/connection/__tests__/eventListenerIsolation.test.ts +115 -0
  33. package/src/coordinator/connection/client.ts +3 -2
  34. package/src/coordinator/connection/utils.ts +28 -0
  35. package/src/devices/CameraManager.ts +16 -4
  36. package/src/devices/DeviceManager.ts +7 -4
  37. package/src/devices/MicrophoneManager.ts +11 -3
  38. package/src/devices/__tests__/CameraManager.test.ts +27 -1
  39. package/src/devices/__tests__/DeviceManager.test.ts +49 -1
  40. package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
  41. package/src/events/call.ts +6 -3
  42. package/src/gen/coordinator/index.ts +26 -0
  43. package/src/helpers/MediaStreamRecorder.ts +104 -0
  44. package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
  45. package/src/helpers/__tests__/concurrency.test.ts +74 -0
  46. package/src/helpers/__tests__/loopback.test.ts +367 -0
  47. package/src/helpers/concurrency.ts +24 -0
  48. package/src/helpers/loopback.ts +200 -0
  49. package/src/reporting/ClientEventReporter.ts +15 -28
  50. package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
  51. package/src/rtc/Subscriber.ts +8 -0
  52. package/src/rtc/__tests__/Subscriber.test.ts +101 -0
  53. package/src/sorting/__tests__/presets.test.ts +188 -3
  54. package/src/sorting/__tests__/sorting.test.ts +22 -1
  55. package/src/sorting/participants.ts +20 -0
  56. package/src/sorting/presets.ts +4 -0
  57. package/src/store/__tests__/CallState.test.ts +2 -2
package/dist/index.cjs.js CHANGED
@@ -4431,6 +4431,28 @@ function isErrorResponse(res) {
4431
4431
  function isCloseEvent(res) {
4432
4432
  return res.code !== undefined;
4433
4433
  }
4434
+ /**
4435
+ * Invokes a single event listener in isolation, so that a faulty listener
4436
+ * cannot abort the dispatch loop nor the bookkeeping that follows it.
4437
+ *
4438
+ * @param listener the listener to invoke.
4439
+ * @param event the event to pass to the listener.
4440
+ * @param logger the logger to report listener failures with.
4441
+ */
4442
+ const invokeEventListener = (listener, event, logger) => {
4443
+ try {
4444
+ const result = listener(event);
4445
+ if (result && typeof result.then === 'function') {
4446
+ Promise.resolve(result).catch((error) => report(logger, error, event));
4447
+ }
4448
+ }
4449
+ catch (error) {
4450
+ report(logger, error, event);
4451
+ }
4452
+ };
4453
+ const report = (logger, error, event) => {
4454
+ logger.error('Unhandled error in event listener', error, event);
4455
+ };
4434
4456
 
4435
4457
  /**
4436
4458
  * Checks whether we are using React Native
@@ -5079,7 +5101,7 @@ const toIceCandidate = (iceTrickle) => {
5079
5101
  }
5080
5102
  };
5081
5103
 
5082
- const version = "1.58.0";
5104
+ const version = "1.59.1";
5083
5105
  const [major, minor, patch] = version.split('.');
5084
5106
  let sdkInfo = {
5085
5107
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -5336,6 +5358,27 @@ const withoutConcurrency = createRunner(wrapWithContinuationTracking);
5336
5358
  */
5337
5359
  const withCancellation = createRunner(wrapWithCancellation);
5338
5360
  const pendingPromises = new Map();
5361
+ /**
5362
+ * Wraps an async function so concurrent calls share the in-flight promise
5363
+ * instead of queueing another execution.
5364
+ *
5365
+ * @param cb Async function to wrap.
5366
+ * @returns Wrapped async function.
5367
+ */
5368
+ function singleFlight(cb) {
5369
+ let pending;
5370
+ return (...args) => {
5371
+ if (pending)
5372
+ return pending;
5373
+ const promise = cb(...args).finally(() => {
5374
+ if (pending === promise) {
5375
+ pending = undefined;
5376
+ }
5377
+ });
5378
+ pending = promise;
5379
+ return promise;
5380
+ };
5381
+ }
5339
5382
  function hasPending(tag) {
5340
5383
  return pendingPromises.has(tag);
5341
5384
  }
@@ -5907,6 +5950,27 @@ const publishingAudio = (a, b) => {
5907
5950
  return 1;
5908
5951
  return 0;
5909
5952
  };
5953
+ /**
5954
+ * A comparator which prioritizes participants who are publishing any track
5955
+ * over participants who are not publishing at all.
5956
+ *
5957
+ * Unlike `publishingVideo` and `publishingAudio`, this comparator does not
5958
+ * distinguish between track types. Muting the microphone or turning off the
5959
+ * camera does not change a participant's rank as long as they still publish
5960
+ * something, which keeps the ordering stable across ordinary device toggles.
5961
+ *
5962
+ * @param a the first participant.
5963
+ * @param b the second participant.
5964
+ */
5965
+ const publishing = (a, b) => {
5966
+ const hasA = a.publishedTracks.length > 0;
5967
+ const hasB = b.publishedTracks.length > 0;
5968
+ if (hasA && !hasB)
5969
+ return -1;
5970
+ if (!hasA && hasB)
5971
+ return 1;
5972
+ return 0;
5973
+ };
5910
5974
  /**
5911
5975
  * A comparator which prioritizes participants who are pinned.
5912
5976
  *
@@ -6017,11 +6081,11 @@ const withVideoIngressSource = withParticipantSource(ParticipantSource.RTMP, Par
6017
6081
  /**
6018
6082
  * The default sorting preset.
6019
6083
  */
6020
- const defaultSortPreset = combineComparators(screenSharing, pinned, ifInvisibleBy(combineComparators(dominantSpeaker, speaking, reactionType('raised-hand'), publishingVideo, publishingAudio)));
6084
+ const defaultSortPreset = combineComparators(screenSharing, pinned, publishing, ifInvisibleBy(combineComparators(dominantSpeaker, speaking, reactionType('raised-hand'), publishingVideo, publishingAudio)));
6021
6085
  /**
6022
6086
  * The sorting preset for speaker layout.
6023
6087
  */
6024
- const speakerLayoutSortPreset = combineComparators(screenSharing, pinned, dominantSpeaker, ifInvisibleBy(combineComparators(speaking, reactionType('raised-hand'), withVideoIngressSource, publishingVideo, publishingAudio)));
6088
+ const speakerLayoutSortPreset = combineComparators(screenSharing, pinned, dominantSpeaker, publishing, ifInvisibleBy(combineComparators(speaking, reactionType('raised-hand'), withVideoIngressSource, publishingVideo, publishingAudio)));
6025
6089
  /**
6026
6090
  * The sorting preset for layouts that don't render all participants but
6027
6091
  * instead, render them in pages.
@@ -6030,7 +6094,7 @@ const paginatedLayoutSortPreset = combineComparators(pinned, ifInvisibleOrUnknow
6030
6094
  /**
6031
6095
  * The sorting preset for livestreams and audio rooms.
6032
6096
  */
6033
- const livestreamOrAudioRoomSortPreset = combineComparators(ifInvisibleOrUnknownBy(combineComparators(dominantSpeaker, speaking, reactionType('raised-hand'), withVideoIngressSource, publishingVideo, publishingAudio)), role('admin', 'host', 'speaker'));
6097
+ const livestreamOrAudioRoomSortPreset = combineComparators(publishing, ifInvisibleOrUnknownBy(combineComparators(dominantSpeaker, speaking, reactionType('raised-hand'), withVideoIngressSource, publishingVideo, publishingAudio)), role('admin', 'host', 'speaker'));
6034
6098
 
6035
6099
  /**
6036
6100
  * Returns the default egress object - when no egress data is available.
@@ -9824,6 +9888,11 @@ class Subscriber extends BasePeerConnection {
9824
9888
  throw e;
9825
9889
  }
9826
9890
  };
9891
+ /**
9892
+ * Whether the given stream is one the SFU echoed back to us
9893
+ * (`allowOwnTracksLoopback`) rather than a local capture stream.
9894
+ */
9895
+ isSelfSubscribedStream = (stream) => !!stream && !!this.trackedStreams?.has(stream);
9827
9896
  handleOnTrack = (e) => {
9828
9897
  const { streams, track } = e;
9829
9898
  const [primaryStream] = streams;
@@ -9913,6 +9982,8 @@ class Subscriber extends BasePeerConnection {
9913
9982
  const target = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
9914
9983
  if (!target)
9915
9984
  return;
9985
+ if (target.isLocalParticipant)
9986
+ return;
9916
9987
  this.state.updateParticipant(target.sessionId, (p) => {
9917
9988
  const current = p.interruptedTracks ?? [];
9918
9989
  const has = current.includes(trackType);
@@ -10655,7 +10726,6 @@ const watchCallRejected = (call) => {
10655
10726
  */
10656
10727
  const watchCallEnded = (call) => {
10657
10728
  return function onCallEnded() {
10658
- globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
10659
10729
  const { callingState } = call.state;
10660
10730
  if (callingState !== exports.CallingState.IDLE &&
10661
10731
  callingState !== exports.CallingState.LEFT) {
@@ -10664,9 +10734,13 @@ const watchCallEnded = (call) => {
10664
10734
  reason: 'call.ended event received',
10665
10735
  });
10666
10736
  call
10667
- .leave({ message: 'call.ended event received', reject: false })
10737
+ .leave({
10738
+ message: 'call.ended event received',
10739
+ reject: false,
10740
+ reason: 'ended',
10741
+ })
10668
10742
  .catch((err) => {
10669
- call.logger.error('Failed to leave call after call.ended ', err);
10743
+ call.logger.error('Failed to leave call after call.ended', err);
10670
10744
  });
10671
10745
  }
10672
10746
  };
@@ -13472,7 +13546,7 @@ class DeviceManager {
13472
13546
  createSyntheticDevice(selectedDevice, deviceKind);
13473
13547
  writePreferences(currentDevice, deviceKey, muted, storageKey);
13474
13548
  }
13475
- async applyPersistedPreferences(enabledInCallType) {
13549
+ async applyPersistedPreferences(enabledInCallType, forceDisabled = false) {
13476
13550
  const deviceKey = this.trackType === TrackType.AUDIO ? 'microphone' : 'camera';
13477
13551
  const preferences = readPreferences(this.devicePersistence.storageKey);
13478
13552
  const preferenceList = toPreferenceList(preferences[deviceKey]);
@@ -13499,13 +13573,13 @@ class DeviceManager {
13499
13573
  }
13500
13574
  const canPublish = this.call.permissionsContext.canPublish(this.trackType);
13501
13575
  if (typeof muted === 'boolean' && enabledInCallType && canPublish) {
13502
- await this.applyMutedState(muted);
13576
+ await this.applyMutedState(muted, forceDisabled);
13503
13577
  appliedMute = true;
13504
13578
  }
13505
13579
  return appliedDevice || appliedMute;
13506
13580
  }
13507
- async applyMutedState(muted) {
13508
- if (this.state.status !== undefined)
13581
+ async applyMutedState(muted, forceDisabled) {
13582
+ if (forceDisabled || this.state.status !== undefined)
13509
13583
  return;
13510
13584
  if (muted) {
13511
13585
  await this.disable();
@@ -13857,8 +13931,9 @@ class CameraManager extends DeviceManager {
13857
13931
  *
13858
13932
  * @param settings the video settings to apply.
13859
13933
  * @param publish whether to publish the stream after applying the settings.
13934
+ * @param forceDisabled whether to keep the camera disabled, regardless of server-side or local preferences.
13860
13935
  */
13861
- async apply(settings, publish) {
13936
+ async apply(settings, publish, forceDisabled = false) {
13862
13937
  // Wait for any in progress camera operation
13863
13938
  await this.statusChangeSettled();
13864
13939
  await this.selectTargetResolution(settings.target_resolution);
@@ -13870,8 +13945,7 @@ class CameraManager extends DeviceManager {
13870
13945
  if (shouldApplyDefaults &&
13871
13946
  this.devicePersistence.enabled &&
13872
13947
  permissionState === 'granted') {
13873
- persistedPreferencesApplied =
13874
- await this.applyPersistedPreferences(enabledInCallType);
13948
+ persistedPreferencesApplied = await this.applyPersistedPreferences(enabledInCallType, forceDisabled);
13875
13949
  }
13876
13950
  // apply a direction and enable the camera only if in "pristine" state,
13877
13951
  // and there are no persisted preferences
@@ -13881,7 +13955,10 @@ class CameraManager extends DeviceManager {
13881
13955
  const direction = settings.camera_facing === 'front' ? 'front' : 'back';
13882
13956
  await this.selectDirection(direction, { enableCamera: false });
13883
13957
  }
13884
- if (canPublish && settings.camera_default_on && enabledInCallType) {
13958
+ if (!forceDisabled &&
13959
+ canPublish &&
13960
+ settings.camera_default_on &&
13961
+ enabledInCallType) {
13885
13962
  await this.enable();
13886
13963
  }
13887
13964
  }
@@ -14476,8 +14553,9 @@ class MicrophoneManager extends AudioDeviceManager {
14476
14553
  * Applies the audio settings to the microphone.
14477
14554
  * @param settings the audio settings to apply.
14478
14555
  * @param publish whether to publish the stream after applying the settings.
14556
+ * @param forceDisabled whether to keep the mic disabled, regardless of server-side or local preferences.
14479
14557
  */
14480
- async apply(settings, publish) {
14558
+ async apply(settings, publish, forceDisabled = false) {
14481
14559
  // Wait for any in progress mic operation
14482
14560
  await this.statusChangeSettled();
14483
14561
  // apply server-side settings only when the device state is pristine
@@ -14489,11 +14567,11 @@ class MicrophoneManager extends AudioDeviceManager {
14489
14567
  if (shouldApplyDefaults &&
14490
14568
  this.devicePersistence.enabled &&
14491
14569
  permissionState === 'granted') {
14492
- persistedPreferencesApplied = await this.applyPersistedPreferences(true);
14570
+ persistedPreferencesApplied = await this.applyPersistedPreferences(true, forceDisabled);
14493
14571
  }
14494
14572
  const canPublish = this.call.permissionsContext.canPublish(this.trackType);
14495
14573
  if (shouldApplyDefaults && !persistedPreferencesApplied) {
14496
- if (canPublish && settings.mic_default_on) {
14574
+ if (!forceDisabled && canPublish && settings.mic_default_on) {
14497
14575
  await this.enable();
14498
14576
  }
14499
14577
  }
@@ -15462,7 +15540,7 @@ class Call {
15462
15540
  }
15463
15541
  }
15464
15542
  }
15465
- globalThis.streamRNVideoSDK?.callingX?.endCall(this);
15543
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, reason === 'ended' ? 'remote' : undefined);
15466
15544
  this.statsReporter?.stop();
15467
15545
  this.statsReporter = undefined;
15468
15546
  const leaveReason = message ?? reason ?? 'user is leaving the call';
@@ -15625,8 +15703,18 @@ class Call {
15625
15703
  // const calls = useCalls().filter((c) => c.ringing);
15626
15704
  const calls = this.clientStore.calls.filter((c) => c.cid !== this.cid);
15627
15705
  this.clientStore.setCalls([this, ...calls]);
15628
- const skipSpeakerApply = isReactNative();
15629
- await this.applyDeviceConfig(settings, false, skipSpeakerApply);
15706
+ await this.applyDeviceConfig(settings, { publish: false });
15707
+ };
15708
+ /**
15709
+ * Applies a backend-provided call state snapshot
15710
+ * (call, members, own capabilities) to this instance.
15711
+ *
15712
+ * @internal an internal method and should not be used outside the SDK.
15713
+ */
15714
+ updateFromCallStateResponse = (response) => {
15715
+ this.state.updateFromCallResponse(response.call);
15716
+ this.state.setMembers(response.members);
15717
+ this.state.setOwnCapabilities(response.own_capabilities);
15630
15718
  };
15631
15719
  /**
15632
15720
  * Loads the information about the call.
@@ -15639,9 +15727,7 @@ class Call {
15639
15727
  get = async (params) => {
15640
15728
  await this.setup();
15641
15729
  const response = await this.streamClient.get(this.streamClientBasePath, params);
15642
- this.state.updateFromCallResponse(response.call);
15643
- this.state.setMembers(response.members);
15644
- this.state.setOwnCapabilities(response.own_capabilities);
15730
+ this.updateFromCallStateResponse(response);
15645
15731
  if (params?.ring) {
15646
15732
  this.ringingSubject.next(true);
15647
15733
  }
@@ -15649,9 +15735,7 @@ class Call {
15649
15735
  this.watching = true;
15650
15736
  this.clientStore.registerOrUpdateCall(this);
15651
15737
  }
15652
- // Skip speaker setup on RN if ringing was requested or the call is already ringing
15653
- const skipSpeakerApply = isReactNative();
15654
- await this.applyDeviceConfig(response.call.settings, false, skipSpeakerApply);
15738
+ await this.applyDeviceConfig(response.call.settings, { publish: false });
15655
15739
  return response;
15656
15740
  };
15657
15741
  /**
@@ -15662,9 +15746,7 @@ class Call {
15662
15746
  getOrCreate = async (data) => {
15663
15747
  await this.setup();
15664
15748
  const response = await this.streamClient.post(this.streamClientBasePath, data);
15665
- this.state.updateFromCallResponse(response.call);
15666
- this.state.setMembers(response.members);
15667
- this.state.setOwnCapabilities(response.own_capabilities);
15749
+ this.updateFromCallStateResponse(response);
15668
15750
  if (data?.ring) {
15669
15751
  this.ringingSubject.next(true);
15670
15752
  }
@@ -15672,9 +15754,7 @@ class Call {
15672
15754
  this.watching = true;
15673
15755
  this.clientStore.registerOrUpdateCall(this);
15674
15756
  }
15675
- // Skip speaker setup on RN if ringing was requested or the call is already ringing
15676
- const skipSpeakerApply = isReactNative();
15677
- await this.applyDeviceConfig(response.call.settings, false, skipSpeakerApply);
15757
+ await this.applyDeviceConfig(response.call.settings, { publish: false });
15678
15758
  return response;
15679
15759
  };
15680
15760
  /**
@@ -15738,7 +15818,7 @@ class Call {
15738
15818
  *
15739
15819
  * @returns a promise which resolves once the call join-flow has finished.
15740
15820
  */
15741
- join = async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, ...data } = {}) => {
15821
+ join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, ...data } = {}) => {
15742
15822
  const callingState = this.state.callingState;
15743
15823
  if ([exports.CallingState.JOINED, exports.CallingState.JOINING].includes(callingState)) {
15744
15824
  throw new Error(`Illegal State: call.join() shall be called only once`);
@@ -15760,7 +15840,6 @@ class Call {
15760
15840
  callId: this.id,
15761
15841
  getCallSessionId: () => this.state.session?.id ?? '',
15762
15842
  getSfuId: () => this.credentials?.server.edge_name ?? '',
15763
- getUserSessionId: () => this.sfuClient?.sessionId ?? '',
15764
15843
  });
15765
15844
  this.joinResponseTimeout = joinResponseTimeout;
15766
15845
  this.rpcRequestTimeout = rpcRequestTimeout;
@@ -15812,7 +15891,7 @@ class Call {
15812
15891
  callingX?.endCall(this, 'error');
15813
15892
  throw error;
15814
15893
  }
15815
- };
15894
+ });
15816
15895
  /**
15817
15896
  * Will make a single attempt to watch for call related WebSocket events
15818
15897
  * and initiate a call session with the server.
@@ -15984,7 +16063,10 @@ class Call {
15984
16063
  if (!this.deviceSettingsAppliedOnce &&
15985
16064
  this.state.settings &&
15986
16065
  !supersededByLeave()) {
15987
- await this.applyDeviceConfig(this.state.settings, true, false);
16066
+ await this.applyDeviceConfig(this.state.settings, {
16067
+ publish: true,
16068
+ skipSpeakerApply: false,
16069
+ });
15988
16070
  this.deviceSettingsAppliedOnce = true;
15989
16071
  }
15990
16072
  if (!this.callManagerStarted && !supersededByLeave()) {
@@ -16774,12 +16856,16 @@ class Call {
16774
16856
  const streamStateProp = trackTypeToParticipantStreamKey(trackType);
16775
16857
  if (!streamStateProp)
16776
16858
  continue;
16777
- this.state.updateParticipant(sessionId, (p) => ({
16778
- publishedTracks: mediaStream
16779
- ? pushToIfMissing([...p.publishedTracks], trackType)
16780
- : p.publishedTracks.filter((t) => t !== trackType),
16781
- [streamStateProp]: mediaStream,
16782
- }));
16859
+ this.state.updateParticipant(sessionId, (p) => {
16860
+ const currentStream = p[streamStateProp];
16861
+ const isEchoedStream = this.subscriber?.isSelfSubscribedStream(currentStream);
16862
+ return {
16863
+ publishedTracks: mediaStream
16864
+ ? pushToIfMissing([...p.publishedTracks], trackType)
16865
+ : p.publishedTracks.filter((t) => t !== trackType),
16866
+ [streamStateProp]: isEchoedStream ? currentStream : mediaStream,
16867
+ };
16868
+ });
16783
16869
  }
16784
16870
  };
16785
16871
  /**
@@ -17469,18 +17555,21 @@ class Call {
17469
17555
  *
17470
17556
  * @internal
17471
17557
  */
17472
- applyDeviceConfig = async (settings, publish, skipSpeakerApply) => {
17558
+ applyDeviceConfig = async (settings, opts) => {
17559
+ const { publish,
17560
+ // Skip speaker setup on RN until joining (explicitly passed as false during join())
17561
+ skipSpeakerApply = isReactNative(), withDisabledDevices, } = opts;
17473
17562
  if (!skipSpeakerApply) {
17474
- await this.speaker.apply(settings).catch((err) => {
17475
- this.logger.warn('Speaker init failed', err);
17476
- });
17563
+ await this.speaker
17564
+ .apply(settings)
17565
+ .catch((err) => this.logger.warn('Speaker init failed', err));
17477
17566
  }
17478
- await this.camera.apply(settings.video, publish).catch((err) => {
17479
- this.logger.warn('Camera init failed', err);
17480
- });
17481
- await this.microphone.apply(settings.audio, publish).catch((err) => {
17482
- this.logger.warn('Mic init failed', err);
17483
- });
17567
+ await this.camera
17568
+ .apply(settings.video, publish, withDisabledDevices)
17569
+ .catch((err) => this.logger.warn('Camera init failed', err));
17570
+ await this.microphone
17571
+ .apply(settings.audio, publish, withDisabledDevices)
17572
+ .catch((err) => this.logger.warn('Mic init failed', err));
17484
17573
  };
17485
17574
  /**
17486
17575
  * Will begin tracking the given element for visibility changes within the
@@ -18964,11 +19053,11 @@ class StreamClient {
18964
19053
  return;
18965
19054
  // call generic listeners
18966
19055
  for (const listener of this.listeners.all || []) {
18967
- listener(event);
19056
+ invokeEventListener(listener, event, this.logger);
18968
19057
  }
18969
19058
  // call type specific listeners
18970
19059
  for (const listener of this.listeners[event.type] || []) {
18971
- listener(event);
19060
+ invokeEventListener(listener, event, this.logger);
18972
19061
  }
18973
19062
  };
18974
19063
  /**
@@ -18988,11 +19077,11 @@ class StreamClient {
18988
19077
  return await this.wsConnection.connect(this.defaultWSTimeout);
18989
19078
  };
18990
19079
  getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
18991
- "1.58.0";
19080
+ "1.59.1";
18992
19081
  getUserAgent = () => {
18993
19082
  if (!this.cachedUserAgent) {
18994
19083
  const { clientAppIdentifier = {} } = this.options;
18995
- const { sdkName = 'js', sdkVersion = "1.58.0", ...extras } = clientAppIdentifier;
19084
+ const { sdkName = 'js', sdkVersion = "1.59.1", ...extras } = clientAppIdentifier;
18996
19085
  this.cachedUserAgent = [
18997
19086
  `stream-video-${sdkName}-v${sdkVersion}`,
18998
19087
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -19355,7 +19444,6 @@ class ClientEventReporter {
19355
19444
  user_id: this.streamClient.userID || this.coordinatorConnectUserId,
19356
19445
  type: ctx?.callType,
19357
19446
  id: ctx?.callId,
19358
- call_cid: cid,
19359
19447
  stage: 'JoinInitiated',
19360
19448
  join_attempt_id: joinAttemptId,
19361
19449
  ...(coordinatorConnectId && {
@@ -19537,7 +19625,6 @@ class ClientEventReporter {
19537
19625
  startedAt: Date.now(),
19538
19626
  joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
19539
19627
  sfuId: this.getSfuId(cid),
19540
- userSessionId: this.getUserSessionId(cid),
19541
19628
  wasPreviouslyConnected: this.pcEverConnected.get(key) === true,
19542
19629
  lastIceState: iceConnectionState,
19543
19630
  };
@@ -19548,9 +19635,6 @@ class ClientEventReporter {
19548
19635
  peer_connection: role,
19549
19636
  was_previously_connected: pair.wasPreviouslyConnected,
19550
19637
  ...(pair.sfuId && { sfu_id: pair.sfuId }),
19551
- ...(pair.userSessionId && {
19552
- user_session_id: pair.userSessionId,
19553
- }),
19554
19638
  event_type: 'initiated',
19555
19639
  });
19556
19640
  };
@@ -19565,9 +19649,6 @@ class ClientEventReporter {
19565
19649
  peer_connection: role,
19566
19650
  was_previously_connected: pair.wasPreviouslyConnected,
19567
19651
  ...(pair.sfuId && { sfu_id: pair.sfuId }),
19568
- ...(pair.userSessionId && {
19569
- user_session_id: pair.userSessionId,
19570
- }),
19571
19652
  event_type: 'completed',
19572
19653
  outcome: 'success',
19573
19654
  retry_count_attempt: 0,
@@ -19585,9 +19666,6 @@ class ClientEventReporter {
19585
19666
  ...this.sessionIdField(cid),
19586
19667
  peer_connection: role,
19587
19668
  was_previously_connected: pair.wasPreviouslyConnected,
19588
- ...(pair.userSessionId && {
19589
- user_session_id: pair.userSessionId,
19590
- }),
19591
19669
  ...(pair.sfuId && { sfu_id: pair.sfuId }),
19592
19670
  event_type: 'completed',
19593
19671
  outcome: 'failure',
@@ -19600,7 +19678,6 @@ class ClientEventReporter {
19600
19678
  this.peerConnectionPairs.delete(key);
19601
19679
  };
19602
19680
  getSfuId = (cid) => this.callContexts.get(cid)?.getSfuId() ?? '';
19603
- getUserSessionId = (cid) => this.callContexts.get(cid)?.getUserSessionId() ?? '';
19604
19681
  sessionIdField = (cid) => {
19605
19682
  const callSessionId = this.callContexts.get(cid)?.getCallSessionId() ?? '';
19606
19683
  return callSessionId ? { call_session_id: callSessionId } : {};
@@ -19612,7 +19689,6 @@ class ClientEventReporter {
19612
19689
  user_id: this.streamClient.userID || this.coordinatorConnectUserId,
19613
19690
  type: ctx?.callType ?? '',
19614
19691
  id: ctx?.callId ?? '',
19615
- call_cid: cid,
19616
19692
  stage,
19617
19693
  stage_id: pair.sid,
19618
19694
  ...(pair.joinAttemptIdSnapshot && {
@@ -19863,6 +19939,7 @@ class StreamVideoClient {
19863
19939
  }
19864
19940
  else {
19865
19941
  await call.updateFromRingingEvent(e);
19942
+ await call.get();
19866
19943
  }
19867
19944
  }
19868
19945
  else {
@@ -19901,6 +19978,13 @@ class StreamVideoClient {
19901
19978
  this.logger.error(`Failed to init call from event ${e.type}`, err);
19902
19979
  }
19903
19980
  };
19981
+ /**
19982
+ * Queries the API for calls matching the given filters.
19983
+ * @param data the query data.
19984
+ */
19985
+ doQueryCalls = (data) => {
19986
+ return this.streamClient.post('/calls', data);
19987
+ };
19904
19988
  /**
19905
19989
  * Rewatches the given calls with retry logic.
19906
19990
  * @param callsToReWatch array of call IDs to rewatch
@@ -19911,10 +19995,16 @@ class StreamVideoClient {
19911
19995
  for (let attempt = 0; attempt < maxRetries; attempt++) {
19912
19996
  try {
19913
19997
  this.logger.info(`Rewatching calls ${callsToReWatch.join(', ')} attempt ${attempt + 1}`);
19914
- await this.queryCalls({
19998
+ const response = await this.doQueryCalls({
19915
19999
  watch: true,
19916
20000
  filter_conditions: { cid: { $in: callsToReWatch } },
19917
20001
  });
20002
+ for (const c of response.calls) {
20003
+ const call = this.writeableStateStore.findCall(c.call.type, c.call.id);
20004
+ if (call) {
20005
+ call.updateFromCallStateResponse(c);
20006
+ }
20007
+ }
19918
20008
  return;
19919
20009
  }
19920
20010
  catch (err) {
@@ -20057,9 +20147,11 @@ class StreamVideoClient {
20057
20147
  * Will query the API for calls matching the given filters.
20058
20148
  *
20059
20149
  * @param data the query data.
20150
+ * @param opts additional options, for tweaking the API behavior.
20060
20151
  */
20061
- queryCalls = async (data = {}) => {
20062
- const response = await this.streamClient.post('/calls', data);
20152
+ queryCalls = async (data = {}, opts = {}) => {
20153
+ const { withDisabledDevices = true } = opts;
20154
+ const response = await this.doQueryCalls(data);
20063
20155
  const calls = [];
20064
20156
  for (const c of response.calls) {
20065
20157
  const call = new Call({
@@ -20073,7 +20165,10 @@ class StreamVideoClient {
20073
20165
  clientStore: this.writeableStateStore,
20074
20166
  });
20075
20167
  call.state.updateFromCallResponse(c.call);
20076
- await call.applyDeviceConfig(c.call.settings, false, isReactNative());
20168
+ await call.applyDeviceConfig(c.call.settings, {
20169
+ publish: false,
20170
+ withDisabledDevices,
20171
+ });
20077
20172
  if (data.watch) {
20078
20173
  await call.setup();
20079
20174
  this.writeableStateStore.registerCall(call);
@@ -20225,6 +20320,202 @@ const humanize = (n) => {
20225
20320
  return String(n);
20226
20321
  };
20227
20322
 
20323
+ /** Default loopback stream wait timeout. */
20324
+ const LOOPBACK_STREAMS_WAIT_TIMEOUT_MS = 10 * 1000;
20325
+ /** Default loopback recording duration. */
20326
+ const DEFAULT_LOOPBACK_RECORDING_DURATION_MS = 10 * 1000;
20327
+ /** Shortest permitted loopback recording duration. */
20328
+ const MIN_LOOPBACK_RECORDING_DURATION_MS = 5 * 1000;
20329
+ /** Longest permitted loopback recording duration. */
20330
+ const MAX_LOOPBACK_RECORDING_DURATION_MS = 2 * 60 * 1000;
20331
+ /** Thrown when loopback tracks do not arrive in time. */
20332
+ class LoopbackStreamsTimeoutError extends Error {
20333
+ constructor() {
20334
+ super('Timed out waiting for loopback streams. Ensure the call was joined ' +
20335
+ 'with `allowOwnTracksLoopback: true` and that the SFU is configured ' +
20336
+ 'to echo self-sub tracks.');
20337
+ this.name = 'LoopbackStreamsTimeoutError';
20338
+ }
20339
+ }
20340
+ /**
20341
+ * Returns the streams on the given participant that the SFU echoed back to us,
20342
+ * ignoring the local capture streams the publishing path writes to the same
20343
+ * fields.
20344
+ */
20345
+ const getLoopbackStreams = (call, participant) => {
20346
+ const { subscriber } = call ?? {};
20347
+ const { audioStream, videoStream } = participant ?? {};
20348
+ return {
20349
+ loopbackVideoStream: subscriber?.isSelfSubscribedStream(videoStream)
20350
+ ? videoStream
20351
+ : undefined,
20352
+ loopbackAudioStream: subscriber?.isSelfSubscribedStream(audioStream)
20353
+ ? audioStream
20354
+ : undefined,
20355
+ };
20356
+ };
20357
+ /**
20358
+ * Returns loopback tracks when all required tracks are present.
20359
+ *
20360
+ * Prefer {@link waitForLoopbackStreams}, which observes call state until the
20361
+ * tracks arrive. This is the synchronous snapshot it is built from.
20362
+ *
20363
+ * @internal
20364
+ */
20365
+ const getLoopbackTracks = (call, participant, includeVideo) => {
20366
+ if (!participant)
20367
+ return undefined;
20368
+ const { loopbackAudioStream, loopbackVideoStream } = getLoopbackStreams(call, participant);
20369
+ const audioTrack = loopbackAudioStream?.getAudioTracks()[0];
20370
+ const videoTrack = includeVideo
20371
+ ? loopbackVideoStream?.getVideoTracks()[0]
20372
+ : undefined;
20373
+ if (!audioTrack || (includeVideo && !videoTrack)) {
20374
+ return undefined;
20375
+ }
20376
+ return { audioTrack, videoTrack };
20377
+ };
20378
+ /** Waits for the requested loopback tracks. */
20379
+ const waitForLoopbackStreams = (call, opts) => {
20380
+ const callState = call.state;
20381
+ const { includeVideo, signal, timeoutMs = LOOPBACK_STREAMS_WAIT_TIMEOUT_MS, } = opts;
20382
+ return new Promise((resolve, reject) => {
20383
+ const initial = getLoopbackTracks(call, callState.localParticipant, includeVideo);
20384
+ if (initial) {
20385
+ resolve(initial);
20386
+ return;
20387
+ }
20388
+ if (signal.aborted) {
20389
+ resolve(null);
20390
+ return;
20391
+ }
20392
+ let settled = false;
20393
+ const subscriptions = new rxjs.Subscription();
20394
+ const cleanup = () => {
20395
+ if (settled)
20396
+ return;
20397
+ settled = true;
20398
+ clearTimeout(timeoutId);
20399
+ signal.removeEventListener('abort', onAbort);
20400
+ subscriptions.unsubscribe();
20401
+ };
20402
+ const onAbort = () => {
20403
+ cleanup();
20404
+ resolve(null);
20405
+ };
20406
+ const timeoutId = setTimeout(() => {
20407
+ cleanup();
20408
+ reject(new LoopbackStreamsTimeoutError());
20409
+ }, timeoutMs);
20410
+ signal.addEventListener('abort', onAbort);
20411
+ subscriptions.add(callState.localParticipant$.subscribe((participant) => {
20412
+ const ready = getLoopbackTracks(call, participant, includeVideo);
20413
+ if (ready) {
20414
+ cleanup();
20415
+ resolve(ready);
20416
+ }
20417
+ }));
20418
+ });
20419
+ };
20420
+ /** Enables loopback audio while `fn` runs, then restores it. */
20421
+ const withLoopbackAudioEnabled = async (tracks, fn) => {
20422
+ const restore = enableLoopbackAudio(tracks);
20423
+ try {
20424
+ return await fn();
20425
+ }
20426
+ finally {
20427
+ restore();
20428
+ }
20429
+ };
20430
+ /** Enables loopback audio and returns a restore function. */
20431
+ const enableLoopbackAudio = (tracks) => {
20432
+ const { audioTrack } = tracks;
20433
+ const previous = audioTrack?.enabled ?? false;
20434
+ if (audioTrack) {
20435
+ audioTrack.enabled = true;
20436
+ }
20437
+ return () => {
20438
+ if (audioTrack) {
20439
+ audioTrack.enabled = previous;
20440
+ }
20441
+ };
20442
+ };
20443
+ /** Clamps and rounds a loopback recording duration. */
20444
+ const clampLoopbackRecordingDuration = (durationMs) => Math.round(Math.min(MAX_LOOPBACK_RECORDING_DURATION_MS, Math.max(MIN_LOOPBACK_RECORDING_DURATION_MS, durationMs)));
20445
+
20446
+ /** Single-use `MediaRecorder` wrapper that returns an in-memory blob. */
20447
+ class MediaStreamRecorder {
20448
+ recording = promiseWithResolvers();
20449
+ logger = videoLoggerSystem.getLogger('MediaStreamRecorder');
20450
+ chunks = [];
20451
+ timeoutId;
20452
+ recorder;
20453
+ tracks;
20454
+ maxDurationMs;
20455
+ constructor(stream, options = {}) {
20456
+ if (typeof MediaRecorder === 'undefined') {
20457
+ throw new Error('MediaRecorder is not available in this environment');
20458
+ }
20459
+ this.tracks = stream.getTracks();
20460
+ if (this.tracks.length === 0) {
20461
+ throw new Error('cannot record a MediaStream that has no tracks');
20462
+ }
20463
+ this.maxDurationMs = options.maxDurationMs;
20464
+ this.recorder = new MediaRecorder(new MediaStream(this.tracks));
20465
+ this.recorder.ondataavailable = (event) => {
20466
+ if (event.data?.size)
20467
+ this.chunks.push(event.data);
20468
+ };
20469
+ this.recorder.onstop = this.handleStop;
20470
+ this.recorder.onerror = this.handleError;
20471
+ }
20472
+ /** Starts recording and resolves when recording stops. */
20473
+ start = () => {
20474
+ if (this.isSettled()) {
20475
+ throw new Error('MediaStreamRecorder is single-use: this instance has already ' +
20476
+ 'recorded. Construct a new one.');
20477
+ }
20478
+ if (this.recorder.state === 'inactive') {
20479
+ this.tracks.forEach((track) => track.addEventListener('ended', this.stop));
20480
+ if (this.maxDurationMs !== undefined) {
20481
+ this.timeoutId = setTimeout(this.stop, this.maxDurationMs);
20482
+ }
20483
+ this.recorder.start();
20484
+ }
20485
+ return this.recording.promise;
20486
+ };
20487
+ /** Stops recording early. */
20488
+ stop = () => {
20489
+ if (this.recorder.state !== 'inactive')
20490
+ this.recorder.stop();
20491
+ };
20492
+ isSettled = () => this.recording.isResolved() || this.recording.isRejected();
20493
+ detach = () => {
20494
+ clearTimeout(this.timeoutId);
20495
+ this.tracks.forEach((track) => track.removeEventListener('ended', this.stop));
20496
+ };
20497
+ handleStop = () => {
20498
+ this.detach();
20499
+ if (this.chunks.length === 0) {
20500
+ this.logger.warn('MediaRecorder recording produced no data');
20501
+ this.recording.resolve(null);
20502
+ return;
20503
+ }
20504
+ const mimeType = this.chunks[0].type || this.recorder.mimeType;
20505
+ this.recording.resolve({
20506
+ blob: new Blob(this.chunks, { type: mimeType }),
20507
+ });
20508
+ };
20509
+ handleError = (event) => {
20510
+ if (this.isSettled())
20511
+ return;
20512
+ this.detach();
20513
+ const error = event.error;
20514
+ this.logger.error('MediaRecorder error', error);
20515
+ this.recording.reject(error ?? new Error('MediaRecorder failed'));
20516
+ };
20517
+ }
20518
+
20228
20519
  /**
20229
20520
  * Tiny, type-safe event emitter.
20230
20521
  *
@@ -20598,6 +20889,7 @@ exports.CallTypes = CallTypes;
20598
20889
  exports.CameraManager = CameraManager;
20599
20890
  exports.CameraManagerState = CameraManagerState;
20600
20891
  exports.CreateDeviceRequestPushProviderEnum = CreateDeviceRequestPushProviderEnum;
20892
+ exports.DEFAULT_LOOPBACK_RECORDING_DURATION_MS = DEFAULT_LOOPBACK_RECORDING_DURATION_MS;
20601
20893
  exports.DeviceManager = DeviceManager;
20602
20894
  exports.DeviceManagerState = DeviceManagerState;
20603
20895
  exports.DynascaleManager = DynascaleManager;
@@ -20615,7 +20907,12 @@ exports.IndividualRecordingSettingsResponseModeEnum = IndividualRecordingSetting
20615
20907
  exports.IngressAudioEncodingOptionsRequestChannelsEnum = IngressAudioEncodingOptionsRequestChannelsEnum;
20616
20908
  exports.IngressSourceRequestFpsEnum = IngressSourceRequestFpsEnum;
20617
20909
  exports.IngressVideoLayerRequestCodecEnum = IngressVideoLayerRequestCodecEnum;
20910
+ exports.LOOPBACK_STREAMS_WAIT_TIMEOUT_MS = LOOPBACK_STREAMS_WAIT_TIMEOUT_MS;
20618
20911
  exports.LayoutSettingsRequestNameEnum = LayoutSettingsRequestNameEnum;
20912
+ exports.LoopbackStreamsTimeoutError = LoopbackStreamsTimeoutError;
20913
+ exports.MAX_LOOPBACK_RECORDING_DURATION_MS = MAX_LOOPBACK_RECORDING_DURATION_MS;
20914
+ exports.MIN_LOOPBACK_RECORDING_DURATION_MS = MIN_LOOPBACK_RECORDING_DURATION_MS;
20915
+ exports.MediaStreamRecorder = MediaStreamRecorder;
20619
20916
  exports.MicrophoneManager = MicrophoneManager;
20620
20917
  exports.MicrophoneManagerState = MicrophoneManagerState;
20621
20918
  exports.NoiseCancellationSettingsModeEnum = NoiseCancellationSettingsModeEnum;
@@ -20656,6 +20953,7 @@ exports.VideoSettingsResponseCameraFacingEnum = VideoSettingsResponseCameraFacin
20656
20953
  exports.ViewportTracker = ViewportTracker;
20657
20954
  exports.WSAuthMessageProductsEnum = WSAuthMessageProductsEnum;
20658
20955
  exports.checkIfAudioOutputChangeSupported = checkIfAudioOutputChangeSupported;
20956
+ exports.clampLoopbackRecordingDuration = clampLoopbackRecordingDuration;
20659
20957
  exports.combineComparators = combineComparators;
20660
20958
  exports.conditional = conditional;
20661
20959
  exports.createSoundDetector = createSoundDetector;
@@ -20664,6 +20962,7 @@ exports.descending = descending;
20664
20962
  exports.deviceIds$ = deviceIds$;
20665
20963
  exports.disposeOfMediaStream = disposeOfMediaStream;
20666
20964
  exports.dominantSpeaker = dominantSpeaker;
20965
+ exports.enableLoopbackAudio = enableLoopbackAudio;
20667
20966
  exports.getAudioBrowserPermission = getAudioBrowserPermission;
20668
20967
  exports.getAudioDevices = getAudioDevices;
20669
20968
  exports.getAudioOutputDevices = getAudioOutputDevices;
@@ -20671,6 +20970,8 @@ exports.getAudioStream = getAudioStream;
20671
20970
  exports.getCallMediaEngineProvider = getCallMediaEngineProvider;
20672
20971
  exports.getClientDetails = getClientDetails;
20673
20972
  exports.getDeviceState = getDeviceState;
20973
+ exports.getLoopbackStreams = getLoopbackStreams;
20974
+ exports.getLoopbackTracks = getLoopbackTracks;
20674
20975
  exports.getScreenShareStream = getScreenShareStream;
20675
20976
  exports.getSdkInfo = getSdkInfo;
20676
20977
  exports.getVideoBrowserPermission = getVideoBrowserPermission;
@@ -20691,6 +20992,7 @@ exports.name = name;
20691
20992
  exports.noopComparator = noopComparator;
20692
20993
  exports.paginatedLayoutSortPreset = paginatedLayoutSortPreset;
20693
20994
  exports.pinned = pinned;
20995
+ exports.publishing = publishing;
20694
20996
  exports.publishingAudio = publishingAudio;
20695
20997
  exports.publishingVideo = publishingVideo;
20696
20998
  exports.reactionType = reactionType;
@@ -20707,5 +21009,7 @@ exports.setWebRTCInfo = setWebRTCInfo;
20707
21009
  exports.speakerLayoutSortPreset = speakerLayoutSortPreset;
20708
21010
  exports.speaking = speaking;
20709
21011
  exports.videoLoggerSystem = videoLoggerSystem;
21012
+ exports.waitForLoopbackStreams = waitForLoopbackStreams;
21013
+ exports.withLoopbackAudioEnabled = withLoopbackAudioEnabled;
20710
21014
  exports.withParticipantSource = withParticipantSource;
20711
21015
  //# sourceMappingURL=index.cjs.js.map