@stream-io/video-client 1.58.0 → 1.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/index.browser.es.js +319 -71
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +329 -69
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.es.js +319 -71
  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/devices/CameraManager.d.ts +2 -1
  12. package/dist/src/devices/DeviceManager.d.ts +1 -1
  13. package/dist/src/devices/MicrophoneManager.d.ts +2 -1
  14. package/dist/src/gen/coordinator/index.d.ts +26 -0
  15. package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
  16. package/dist/src/helpers/concurrency.d.ts +8 -0
  17. package/dist/src/helpers/loopback.d.ts +55 -0
  18. package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
  19. package/dist/src/rtc/Subscriber.d.ts +5 -0
  20. package/index.ts +2 -0
  21. package/package.json +1 -1
  22. package/src/Call.ts +156 -134
  23. package/src/StreamVideoClient.ts +35 -8
  24. package/src/__tests__/Call.lifecycle.test.ts +33 -2
  25. package/src/__tests__/Call.publishing.test.ts +42 -1
  26. package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
  27. package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
  28. package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
  29. package/src/__tests__/StreamVideoClient.test.ts +14 -12
  30. package/src/devices/CameraManager.ts +16 -4
  31. package/src/devices/DeviceManager.ts +7 -4
  32. package/src/devices/MicrophoneManager.ts +11 -3
  33. package/src/devices/__tests__/CameraManager.test.ts +27 -1
  34. package/src/devices/__tests__/DeviceManager.test.ts +49 -1
  35. package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
  36. package/src/events/call.ts +6 -3
  37. package/src/gen/coordinator/index.ts +26 -0
  38. package/src/helpers/MediaStreamRecorder.ts +104 -0
  39. package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
  40. package/src/helpers/__tests__/concurrency.test.ts +74 -0
  41. package/src/helpers/__tests__/loopback.test.ts +367 -0
  42. package/src/helpers/concurrency.ts +24 -0
  43. package/src/helpers/loopback.ts +200 -0
  44. package/src/reporting/ClientEventReporter.ts +15 -28
  45. package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
  46. package/src/rtc/Subscriber.ts +8 -0
  47. package/src/rtc/__tests__/Subscriber.test.ts +101 -0
package/dist/index.cjs.js CHANGED
@@ -5079,7 +5079,7 @@ const toIceCandidate = (iceTrickle) => {
5079
5079
  }
5080
5080
  };
5081
5081
 
5082
- const version = "1.58.0";
5082
+ const version = "1.59.0";
5083
5083
  const [major, minor, patch] = version.split('.');
5084
5084
  let sdkInfo = {
5085
5085
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -5336,6 +5336,27 @@ const withoutConcurrency = createRunner(wrapWithContinuationTracking);
5336
5336
  */
5337
5337
  const withCancellation = createRunner(wrapWithCancellation);
5338
5338
  const pendingPromises = new Map();
5339
+ /**
5340
+ * Wraps an async function so concurrent calls share the in-flight promise
5341
+ * instead of queueing another execution.
5342
+ *
5343
+ * @param cb Async function to wrap.
5344
+ * @returns Wrapped async function.
5345
+ */
5346
+ function singleFlight(cb) {
5347
+ let pending;
5348
+ return (...args) => {
5349
+ if (pending)
5350
+ return pending;
5351
+ const promise = cb(...args).finally(() => {
5352
+ if (pending === promise) {
5353
+ pending = undefined;
5354
+ }
5355
+ });
5356
+ pending = promise;
5357
+ return promise;
5358
+ };
5359
+ }
5339
5360
  function hasPending(tag) {
5340
5361
  return pendingPromises.has(tag);
5341
5362
  }
@@ -9824,6 +9845,11 @@ class Subscriber extends BasePeerConnection {
9824
9845
  throw e;
9825
9846
  }
9826
9847
  };
9848
+ /**
9849
+ * Whether the given stream is one the SFU echoed back to us
9850
+ * (`allowOwnTracksLoopback`) rather than a local capture stream.
9851
+ */
9852
+ isSelfSubscribedStream = (stream) => !!stream && !!this.trackedStreams?.has(stream);
9827
9853
  handleOnTrack = (e) => {
9828
9854
  const { streams, track } = e;
9829
9855
  const [primaryStream] = streams;
@@ -9913,6 +9939,8 @@ class Subscriber extends BasePeerConnection {
9913
9939
  const target = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
9914
9940
  if (!target)
9915
9941
  return;
9942
+ if (target.isLocalParticipant)
9943
+ return;
9916
9944
  this.state.updateParticipant(target.sessionId, (p) => {
9917
9945
  const current = p.interruptedTracks ?? [];
9918
9946
  const has = current.includes(trackType);
@@ -10655,7 +10683,6 @@ const watchCallRejected = (call) => {
10655
10683
  */
10656
10684
  const watchCallEnded = (call) => {
10657
10685
  return function onCallEnded() {
10658
- globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
10659
10686
  const { callingState } = call.state;
10660
10687
  if (callingState !== exports.CallingState.IDLE &&
10661
10688
  callingState !== exports.CallingState.LEFT) {
@@ -10664,9 +10691,13 @@ const watchCallEnded = (call) => {
10664
10691
  reason: 'call.ended event received',
10665
10692
  });
10666
10693
  call
10667
- .leave({ message: 'call.ended event received', reject: false })
10694
+ .leave({
10695
+ message: 'call.ended event received',
10696
+ reject: false,
10697
+ reason: 'ended',
10698
+ })
10668
10699
  .catch((err) => {
10669
- call.logger.error('Failed to leave call after call.ended ', err);
10700
+ call.logger.error('Failed to leave call after call.ended', err);
10670
10701
  });
10671
10702
  }
10672
10703
  };
@@ -13472,7 +13503,7 @@ class DeviceManager {
13472
13503
  createSyntheticDevice(selectedDevice, deviceKind);
13473
13504
  writePreferences(currentDevice, deviceKey, muted, storageKey);
13474
13505
  }
13475
- async applyPersistedPreferences(enabledInCallType) {
13506
+ async applyPersistedPreferences(enabledInCallType, forceDisabled = false) {
13476
13507
  const deviceKey = this.trackType === TrackType.AUDIO ? 'microphone' : 'camera';
13477
13508
  const preferences = readPreferences(this.devicePersistence.storageKey);
13478
13509
  const preferenceList = toPreferenceList(preferences[deviceKey]);
@@ -13499,13 +13530,13 @@ class DeviceManager {
13499
13530
  }
13500
13531
  const canPublish = this.call.permissionsContext.canPublish(this.trackType);
13501
13532
  if (typeof muted === 'boolean' && enabledInCallType && canPublish) {
13502
- await this.applyMutedState(muted);
13533
+ await this.applyMutedState(muted, forceDisabled);
13503
13534
  appliedMute = true;
13504
13535
  }
13505
13536
  return appliedDevice || appliedMute;
13506
13537
  }
13507
- async applyMutedState(muted) {
13508
- if (this.state.status !== undefined)
13538
+ async applyMutedState(muted, forceDisabled) {
13539
+ if (forceDisabled || this.state.status !== undefined)
13509
13540
  return;
13510
13541
  if (muted) {
13511
13542
  await this.disable();
@@ -13857,8 +13888,9 @@ class CameraManager extends DeviceManager {
13857
13888
  *
13858
13889
  * @param settings the video settings to apply.
13859
13890
  * @param publish whether to publish the stream after applying the settings.
13891
+ * @param forceDisabled whether to keep the camera disabled, regardless of server-side or local preferences.
13860
13892
  */
13861
- async apply(settings, publish) {
13893
+ async apply(settings, publish, forceDisabled = false) {
13862
13894
  // Wait for any in progress camera operation
13863
13895
  await this.statusChangeSettled();
13864
13896
  await this.selectTargetResolution(settings.target_resolution);
@@ -13870,8 +13902,7 @@ class CameraManager extends DeviceManager {
13870
13902
  if (shouldApplyDefaults &&
13871
13903
  this.devicePersistence.enabled &&
13872
13904
  permissionState === 'granted') {
13873
- persistedPreferencesApplied =
13874
- await this.applyPersistedPreferences(enabledInCallType);
13905
+ persistedPreferencesApplied = await this.applyPersistedPreferences(enabledInCallType, forceDisabled);
13875
13906
  }
13876
13907
  // apply a direction and enable the camera only if in "pristine" state,
13877
13908
  // and there are no persisted preferences
@@ -13881,7 +13912,10 @@ class CameraManager extends DeviceManager {
13881
13912
  const direction = settings.camera_facing === 'front' ? 'front' : 'back';
13882
13913
  await this.selectDirection(direction, { enableCamera: false });
13883
13914
  }
13884
- if (canPublish && settings.camera_default_on && enabledInCallType) {
13915
+ if (!forceDisabled &&
13916
+ canPublish &&
13917
+ settings.camera_default_on &&
13918
+ enabledInCallType) {
13885
13919
  await this.enable();
13886
13920
  }
13887
13921
  }
@@ -14476,8 +14510,9 @@ class MicrophoneManager extends AudioDeviceManager {
14476
14510
  * Applies the audio settings to the microphone.
14477
14511
  * @param settings the audio settings to apply.
14478
14512
  * @param publish whether to publish the stream after applying the settings.
14513
+ * @param forceDisabled whether to keep the mic disabled, regardless of server-side or local preferences.
14479
14514
  */
14480
- async apply(settings, publish) {
14515
+ async apply(settings, publish, forceDisabled = false) {
14481
14516
  // Wait for any in progress mic operation
14482
14517
  await this.statusChangeSettled();
14483
14518
  // apply server-side settings only when the device state is pristine
@@ -14489,11 +14524,11 @@ class MicrophoneManager extends AudioDeviceManager {
14489
14524
  if (shouldApplyDefaults &&
14490
14525
  this.devicePersistence.enabled &&
14491
14526
  permissionState === 'granted') {
14492
- persistedPreferencesApplied = await this.applyPersistedPreferences(true);
14527
+ persistedPreferencesApplied = await this.applyPersistedPreferences(true, forceDisabled);
14493
14528
  }
14494
14529
  const canPublish = this.call.permissionsContext.canPublish(this.trackType);
14495
14530
  if (shouldApplyDefaults && !persistedPreferencesApplied) {
14496
- if (canPublish && settings.mic_default_on) {
14531
+ if (!forceDisabled && canPublish && settings.mic_default_on) {
14497
14532
  await this.enable();
14498
14533
  }
14499
14534
  }
@@ -15462,7 +15497,7 @@ class Call {
15462
15497
  }
15463
15498
  }
15464
15499
  }
15465
- globalThis.streamRNVideoSDK?.callingX?.endCall(this);
15500
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, reason === 'ended' ? 'remote' : undefined);
15466
15501
  this.statsReporter?.stop();
15467
15502
  this.statsReporter = undefined;
15468
15503
  const leaveReason = message ?? reason ?? 'user is leaving the call';
@@ -15625,8 +15660,18 @@ class Call {
15625
15660
  // const calls = useCalls().filter((c) => c.ringing);
15626
15661
  const calls = this.clientStore.calls.filter((c) => c.cid !== this.cid);
15627
15662
  this.clientStore.setCalls([this, ...calls]);
15628
- const skipSpeakerApply = isReactNative();
15629
- await this.applyDeviceConfig(settings, false, skipSpeakerApply);
15663
+ await this.applyDeviceConfig(settings, { publish: false });
15664
+ };
15665
+ /**
15666
+ * Applies a backend-provided call state snapshot
15667
+ * (call, members, own capabilities) to this instance.
15668
+ *
15669
+ * @internal an internal method and should not be used outside the SDK.
15670
+ */
15671
+ updateFromCallStateResponse = (response) => {
15672
+ this.state.updateFromCallResponse(response.call);
15673
+ this.state.setMembers(response.members);
15674
+ this.state.setOwnCapabilities(response.own_capabilities);
15630
15675
  };
15631
15676
  /**
15632
15677
  * Loads the information about the call.
@@ -15639,9 +15684,7 @@ class Call {
15639
15684
  get = async (params) => {
15640
15685
  await this.setup();
15641
15686
  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);
15687
+ this.updateFromCallStateResponse(response);
15645
15688
  if (params?.ring) {
15646
15689
  this.ringingSubject.next(true);
15647
15690
  }
@@ -15649,9 +15692,7 @@ class Call {
15649
15692
  this.watching = true;
15650
15693
  this.clientStore.registerOrUpdateCall(this);
15651
15694
  }
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);
15695
+ await this.applyDeviceConfig(response.call.settings, { publish: false });
15655
15696
  return response;
15656
15697
  };
15657
15698
  /**
@@ -15662,9 +15703,7 @@ class Call {
15662
15703
  getOrCreate = async (data) => {
15663
15704
  await this.setup();
15664
15705
  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);
15706
+ this.updateFromCallStateResponse(response);
15668
15707
  if (data?.ring) {
15669
15708
  this.ringingSubject.next(true);
15670
15709
  }
@@ -15672,9 +15711,7 @@ class Call {
15672
15711
  this.watching = true;
15673
15712
  this.clientStore.registerOrUpdateCall(this);
15674
15713
  }
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);
15714
+ await this.applyDeviceConfig(response.call.settings, { publish: false });
15678
15715
  return response;
15679
15716
  };
15680
15717
  /**
@@ -15738,7 +15775,7 @@ class Call {
15738
15775
  *
15739
15776
  * @returns a promise which resolves once the call join-flow has finished.
15740
15777
  */
15741
- join = async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, ...data } = {}) => {
15778
+ join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, ...data } = {}) => {
15742
15779
  const callingState = this.state.callingState;
15743
15780
  if ([exports.CallingState.JOINED, exports.CallingState.JOINING].includes(callingState)) {
15744
15781
  throw new Error(`Illegal State: call.join() shall be called only once`);
@@ -15760,7 +15797,6 @@ class Call {
15760
15797
  callId: this.id,
15761
15798
  getCallSessionId: () => this.state.session?.id ?? '',
15762
15799
  getSfuId: () => this.credentials?.server.edge_name ?? '',
15763
- getUserSessionId: () => this.sfuClient?.sessionId ?? '',
15764
15800
  });
15765
15801
  this.joinResponseTimeout = joinResponseTimeout;
15766
15802
  this.rpcRequestTimeout = rpcRequestTimeout;
@@ -15812,7 +15848,7 @@ class Call {
15812
15848
  callingX?.endCall(this, 'error');
15813
15849
  throw error;
15814
15850
  }
15815
- };
15851
+ });
15816
15852
  /**
15817
15853
  * Will make a single attempt to watch for call related WebSocket events
15818
15854
  * and initiate a call session with the server.
@@ -15984,7 +16020,10 @@ class Call {
15984
16020
  if (!this.deviceSettingsAppliedOnce &&
15985
16021
  this.state.settings &&
15986
16022
  !supersededByLeave()) {
15987
- await this.applyDeviceConfig(this.state.settings, true, false);
16023
+ await this.applyDeviceConfig(this.state.settings, {
16024
+ publish: true,
16025
+ skipSpeakerApply: false,
16026
+ });
15988
16027
  this.deviceSettingsAppliedOnce = true;
15989
16028
  }
15990
16029
  if (!this.callManagerStarted && !supersededByLeave()) {
@@ -16774,12 +16813,16 @@ class Call {
16774
16813
  const streamStateProp = trackTypeToParticipantStreamKey(trackType);
16775
16814
  if (!streamStateProp)
16776
16815
  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
- }));
16816
+ this.state.updateParticipant(sessionId, (p) => {
16817
+ const currentStream = p[streamStateProp];
16818
+ const isEchoedStream = this.subscriber?.isSelfSubscribedStream(currentStream);
16819
+ return {
16820
+ publishedTracks: mediaStream
16821
+ ? pushToIfMissing([...p.publishedTracks], trackType)
16822
+ : p.publishedTracks.filter((t) => t !== trackType),
16823
+ [streamStateProp]: isEchoedStream ? currentStream : mediaStream,
16824
+ };
16825
+ });
16783
16826
  }
16784
16827
  };
16785
16828
  /**
@@ -17469,18 +17512,21 @@ class Call {
17469
17512
  *
17470
17513
  * @internal
17471
17514
  */
17472
- applyDeviceConfig = async (settings, publish, skipSpeakerApply) => {
17515
+ applyDeviceConfig = async (settings, opts) => {
17516
+ const { publish,
17517
+ // Skip speaker setup on RN until joining (explicitly passed as false during join())
17518
+ skipSpeakerApply = isReactNative(), withDisabledDevices, } = opts;
17473
17519
  if (!skipSpeakerApply) {
17474
- await this.speaker.apply(settings).catch((err) => {
17475
- this.logger.warn('Speaker init failed', err);
17476
- });
17520
+ await this.speaker
17521
+ .apply(settings)
17522
+ .catch((err) => this.logger.warn('Speaker init failed', err));
17477
17523
  }
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
- });
17524
+ await this.camera
17525
+ .apply(settings.video, publish, withDisabledDevices)
17526
+ .catch((err) => this.logger.warn('Camera init failed', err));
17527
+ await this.microphone
17528
+ .apply(settings.audio, publish, withDisabledDevices)
17529
+ .catch((err) => this.logger.warn('Mic init failed', err));
17484
17530
  };
17485
17531
  /**
17486
17532
  * Will begin tracking the given element for visibility changes within the
@@ -18988,11 +19034,11 @@ class StreamClient {
18988
19034
  return await this.wsConnection.connect(this.defaultWSTimeout);
18989
19035
  };
18990
19036
  getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
18991
- "1.58.0";
19037
+ "1.59.0";
18992
19038
  getUserAgent = () => {
18993
19039
  if (!this.cachedUserAgent) {
18994
19040
  const { clientAppIdentifier = {} } = this.options;
18995
- const { sdkName = 'js', sdkVersion = "1.58.0", ...extras } = clientAppIdentifier;
19041
+ const { sdkName = 'js', sdkVersion = "1.59.0", ...extras } = clientAppIdentifier;
18996
19042
  this.cachedUserAgent = [
18997
19043
  `stream-video-${sdkName}-v${sdkVersion}`,
18998
19044
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -19355,7 +19401,6 @@ class ClientEventReporter {
19355
19401
  user_id: this.streamClient.userID || this.coordinatorConnectUserId,
19356
19402
  type: ctx?.callType,
19357
19403
  id: ctx?.callId,
19358
- call_cid: cid,
19359
19404
  stage: 'JoinInitiated',
19360
19405
  join_attempt_id: joinAttemptId,
19361
19406
  ...(coordinatorConnectId && {
@@ -19537,7 +19582,6 @@ class ClientEventReporter {
19537
19582
  startedAt: Date.now(),
19538
19583
  joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
19539
19584
  sfuId: this.getSfuId(cid),
19540
- userSessionId: this.getUserSessionId(cid),
19541
19585
  wasPreviouslyConnected: this.pcEverConnected.get(key) === true,
19542
19586
  lastIceState: iceConnectionState,
19543
19587
  };
@@ -19548,9 +19592,6 @@ class ClientEventReporter {
19548
19592
  peer_connection: role,
19549
19593
  was_previously_connected: pair.wasPreviouslyConnected,
19550
19594
  ...(pair.sfuId && { sfu_id: pair.sfuId }),
19551
- ...(pair.userSessionId && {
19552
- user_session_id: pair.userSessionId,
19553
- }),
19554
19595
  event_type: 'initiated',
19555
19596
  });
19556
19597
  };
@@ -19565,9 +19606,6 @@ class ClientEventReporter {
19565
19606
  peer_connection: role,
19566
19607
  was_previously_connected: pair.wasPreviouslyConnected,
19567
19608
  ...(pair.sfuId && { sfu_id: pair.sfuId }),
19568
- ...(pair.userSessionId && {
19569
- user_session_id: pair.userSessionId,
19570
- }),
19571
19609
  event_type: 'completed',
19572
19610
  outcome: 'success',
19573
19611
  retry_count_attempt: 0,
@@ -19585,9 +19623,6 @@ class ClientEventReporter {
19585
19623
  ...this.sessionIdField(cid),
19586
19624
  peer_connection: role,
19587
19625
  was_previously_connected: pair.wasPreviouslyConnected,
19588
- ...(pair.userSessionId && {
19589
- user_session_id: pair.userSessionId,
19590
- }),
19591
19626
  ...(pair.sfuId && { sfu_id: pair.sfuId }),
19592
19627
  event_type: 'completed',
19593
19628
  outcome: 'failure',
@@ -19600,7 +19635,6 @@ class ClientEventReporter {
19600
19635
  this.peerConnectionPairs.delete(key);
19601
19636
  };
19602
19637
  getSfuId = (cid) => this.callContexts.get(cid)?.getSfuId() ?? '';
19603
- getUserSessionId = (cid) => this.callContexts.get(cid)?.getUserSessionId() ?? '';
19604
19638
  sessionIdField = (cid) => {
19605
19639
  const callSessionId = this.callContexts.get(cid)?.getCallSessionId() ?? '';
19606
19640
  return callSessionId ? { call_session_id: callSessionId } : {};
@@ -19612,7 +19646,6 @@ class ClientEventReporter {
19612
19646
  user_id: this.streamClient.userID || this.coordinatorConnectUserId,
19613
19647
  type: ctx?.callType ?? '',
19614
19648
  id: ctx?.callId ?? '',
19615
- call_cid: cid,
19616
19649
  stage,
19617
19650
  stage_id: pair.sid,
19618
19651
  ...(pair.joinAttemptIdSnapshot && {
@@ -19863,6 +19896,7 @@ class StreamVideoClient {
19863
19896
  }
19864
19897
  else {
19865
19898
  await call.updateFromRingingEvent(e);
19899
+ await call.get();
19866
19900
  }
19867
19901
  }
19868
19902
  else {
@@ -19901,6 +19935,13 @@ class StreamVideoClient {
19901
19935
  this.logger.error(`Failed to init call from event ${e.type}`, err);
19902
19936
  }
19903
19937
  };
19938
+ /**
19939
+ * Queries the API for calls matching the given filters.
19940
+ * @param data the query data.
19941
+ */
19942
+ doQueryCalls = (data) => {
19943
+ return this.streamClient.post('/calls', data);
19944
+ };
19904
19945
  /**
19905
19946
  * Rewatches the given calls with retry logic.
19906
19947
  * @param callsToReWatch array of call IDs to rewatch
@@ -19911,10 +19952,16 @@ class StreamVideoClient {
19911
19952
  for (let attempt = 0; attempt < maxRetries; attempt++) {
19912
19953
  try {
19913
19954
  this.logger.info(`Rewatching calls ${callsToReWatch.join(', ')} attempt ${attempt + 1}`);
19914
- await this.queryCalls({
19955
+ const response = await this.doQueryCalls({
19915
19956
  watch: true,
19916
19957
  filter_conditions: { cid: { $in: callsToReWatch } },
19917
19958
  });
19959
+ for (const c of response.calls) {
19960
+ const call = this.writeableStateStore.findCall(c.call.type, c.call.id);
19961
+ if (call) {
19962
+ call.updateFromCallStateResponse(c);
19963
+ }
19964
+ }
19918
19965
  return;
19919
19966
  }
19920
19967
  catch (err) {
@@ -20057,9 +20104,11 @@ class StreamVideoClient {
20057
20104
  * Will query the API for calls matching the given filters.
20058
20105
  *
20059
20106
  * @param data the query data.
20107
+ * @param opts additional options, for tweaking the API behavior.
20060
20108
  */
20061
- queryCalls = async (data = {}) => {
20062
- const response = await this.streamClient.post('/calls', data);
20109
+ queryCalls = async (data = {}, opts = {}) => {
20110
+ const { withDisabledDevices = true } = opts;
20111
+ const response = await this.doQueryCalls(data);
20063
20112
  const calls = [];
20064
20113
  for (const c of response.calls) {
20065
20114
  const call = new Call({
@@ -20073,7 +20122,10 @@ class StreamVideoClient {
20073
20122
  clientStore: this.writeableStateStore,
20074
20123
  });
20075
20124
  call.state.updateFromCallResponse(c.call);
20076
- await call.applyDeviceConfig(c.call.settings, false, isReactNative());
20125
+ await call.applyDeviceConfig(c.call.settings, {
20126
+ publish: false,
20127
+ withDisabledDevices,
20128
+ });
20077
20129
  if (data.watch) {
20078
20130
  await call.setup();
20079
20131
  this.writeableStateStore.registerCall(call);
@@ -20225,6 +20277,202 @@ const humanize = (n) => {
20225
20277
  return String(n);
20226
20278
  };
20227
20279
 
20280
+ /** Default loopback stream wait timeout. */
20281
+ const LOOPBACK_STREAMS_WAIT_TIMEOUT_MS = 10 * 1000;
20282
+ /** Default loopback recording duration. */
20283
+ const DEFAULT_LOOPBACK_RECORDING_DURATION_MS = 10 * 1000;
20284
+ /** Shortest permitted loopback recording duration. */
20285
+ const MIN_LOOPBACK_RECORDING_DURATION_MS = 5 * 1000;
20286
+ /** Longest permitted loopback recording duration. */
20287
+ const MAX_LOOPBACK_RECORDING_DURATION_MS = 2 * 60 * 1000;
20288
+ /** Thrown when loopback tracks do not arrive in time. */
20289
+ class LoopbackStreamsTimeoutError extends Error {
20290
+ constructor() {
20291
+ super('Timed out waiting for loopback streams. Ensure the call was joined ' +
20292
+ 'with `allowOwnTracksLoopback: true` and that the SFU is configured ' +
20293
+ 'to echo self-sub tracks.');
20294
+ this.name = 'LoopbackStreamsTimeoutError';
20295
+ }
20296
+ }
20297
+ /**
20298
+ * Returns the streams on the given participant that the SFU echoed back to us,
20299
+ * ignoring the local capture streams the publishing path writes to the same
20300
+ * fields.
20301
+ */
20302
+ const getLoopbackStreams = (call, participant) => {
20303
+ const { subscriber } = call ?? {};
20304
+ const { audioStream, videoStream } = participant ?? {};
20305
+ return {
20306
+ loopbackVideoStream: subscriber?.isSelfSubscribedStream(videoStream)
20307
+ ? videoStream
20308
+ : undefined,
20309
+ loopbackAudioStream: subscriber?.isSelfSubscribedStream(audioStream)
20310
+ ? audioStream
20311
+ : undefined,
20312
+ };
20313
+ };
20314
+ /**
20315
+ * Returns loopback tracks when all required tracks are present.
20316
+ *
20317
+ * Prefer {@link waitForLoopbackStreams}, which observes call state until the
20318
+ * tracks arrive. This is the synchronous snapshot it is built from.
20319
+ *
20320
+ * @internal
20321
+ */
20322
+ const getLoopbackTracks = (call, participant, includeVideo) => {
20323
+ if (!participant)
20324
+ return undefined;
20325
+ const { loopbackAudioStream, loopbackVideoStream } = getLoopbackStreams(call, participant);
20326
+ const audioTrack = loopbackAudioStream?.getAudioTracks()[0];
20327
+ const videoTrack = includeVideo
20328
+ ? loopbackVideoStream?.getVideoTracks()[0]
20329
+ : undefined;
20330
+ if (!audioTrack || (includeVideo && !videoTrack)) {
20331
+ return undefined;
20332
+ }
20333
+ return { audioTrack, videoTrack };
20334
+ };
20335
+ /** Waits for the requested loopback tracks. */
20336
+ const waitForLoopbackStreams = (call, opts) => {
20337
+ const callState = call.state;
20338
+ const { includeVideo, signal, timeoutMs = LOOPBACK_STREAMS_WAIT_TIMEOUT_MS, } = opts;
20339
+ return new Promise((resolve, reject) => {
20340
+ const initial = getLoopbackTracks(call, callState.localParticipant, includeVideo);
20341
+ if (initial) {
20342
+ resolve(initial);
20343
+ return;
20344
+ }
20345
+ if (signal.aborted) {
20346
+ resolve(null);
20347
+ return;
20348
+ }
20349
+ let settled = false;
20350
+ const subscriptions = new rxjs.Subscription();
20351
+ const cleanup = () => {
20352
+ if (settled)
20353
+ return;
20354
+ settled = true;
20355
+ clearTimeout(timeoutId);
20356
+ signal.removeEventListener('abort', onAbort);
20357
+ subscriptions.unsubscribe();
20358
+ };
20359
+ const onAbort = () => {
20360
+ cleanup();
20361
+ resolve(null);
20362
+ };
20363
+ const timeoutId = setTimeout(() => {
20364
+ cleanup();
20365
+ reject(new LoopbackStreamsTimeoutError());
20366
+ }, timeoutMs);
20367
+ signal.addEventListener('abort', onAbort);
20368
+ subscriptions.add(callState.localParticipant$.subscribe((participant) => {
20369
+ const ready = getLoopbackTracks(call, participant, includeVideo);
20370
+ if (ready) {
20371
+ cleanup();
20372
+ resolve(ready);
20373
+ }
20374
+ }));
20375
+ });
20376
+ };
20377
+ /** Enables loopback audio while `fn` runs, then restores it. */
20378
+ const withLoopbackAudioEnabled = async (tracks, fn) => {
20379
+ const restore = enableLoopbackAudio(tracks);
20380
+ try {
20381
+ return await fn();
20382
+ }
20383
+ finally {
20384
+ restore();
20385
+ }
20386
+ };
20387
+ /** Enables loopback audio and returns a restore function. */
20388
+ const enableLoopbackAudio = (tracks) => {
20389
+ const { audioTrack } = tracks;
20390
+ const previous = audioTrack?.enabled ?? false;
20391
+ if (audioTrack) {
20392
+ audioTrack.enabled = true;
20393
+ }
20394
+ return () => {
20395
+ if (audioTrack) {
20396
+ audioTrack.enabled = previous;
20397
+ }
20398
+ };
20399
+ };
20400
+ /** Clamps and rounds a loopback recording duration. */
20401
+ const clampLoopbackRecordingDuration = (durationMs) => Math.round(Math.min(MAX_LOOPBACK_RECORDING_DURATION_MS, Math.max(MIN_LOOPBACK_RECORDING_DURATION_MS, durationMs)));
20402
+
20403
+ /** Single-use `MediaRecorder` wrapper that returns an in-memory blob. */
20404
+ class MediaStreamRecorder {
20405
+ recording = promiseWithResolvers();
20406
+ logger = videoLoggerSystem.getLogger('MediaStreamRecorder');
20407
+ chunks = [];
20408
+ timeoutId;
20409
+ recorder;
20410
+ tracks;
20411
+ maxDurationMs;
20412
+ constructor(stream, options = {}) {
20413
+ if (typeof MediaRecorder === 'undefined') {
20414
+ throw new Error('MediaRecorder is not available in this environment');
20415
+ }
20416
+ this.tracks = stream.getTracks();
20417
+ if (this.tracks.length === 0) {
20418
+ throw new Error('cannot record a MediaStream that has no tracks');
20419
+ }
20420
+ this.maxDurationMs = options.maxDurationMs;
20421
+ this.recorder = new MediaRecorder(new MediaStream(this.tracks));
20422
+ this.recorder.ondataavailable = (event) => {
20423
+ if (event.data?.size)
20424
+ this.chunks.push(event.data);
20425
+ };
20426
+ this.recorder.onstop = this.handleStop;
20427
+ this.recorder.onerror = this.handleError;
20428
+ }
20429
+ /** Starts recording and resolves when recording stops. */
20430
+ start = () => {
20431
+ if (this.isSettled()) {
20432
+ throw new Error('MediaStreamRecorder is single-use: this instance has already ' +
20433
+ 'recorded. Construct a new one.');
20434
+ }
20435
+ if (this.recorder.state === 'inactive') {
20436
+ this.tracks.forEach((track) => track.addEventListener('ended', this.stop));
20437
+ if (this.maxDurationMs !== undefined) {
20438
+ this.timeoutId = setTimeout(this.stop, this.maxDurationMs);
20439
+ }
20440
+ this.recorder.start();
20441
+ }
20442
+ return this.recording.promise;
20443
+ };
20444
+ /** Stops recording early. */
20445
+ stop = () => {
20446
+ if (this.recorder.state !== 'inactive')
20447
+ this.recorder.stop();
20448
+ };
20449
+ isSettled = () => this.recording.isResolved() || this.recording.isRejected();
20450
+ detach = () => {
20451
+ clearTimeout(this.timeoutId);
20452
+ this.tracks.forEach((track) => track.removeEventListener('ended', this.stop));
20453
+ };
20454
+ handleStop = () => {
20455
+ this.detach();
20456
+ if (this.chunks.length === 0) {
20457
+ this.logger.warn('MediaRecorder recording produced no data');
20458
+ this.recording.resolve(null);
20459
+ return;
20460
+ }
20461
+ const mimeType = this.chunks[0].type || this.recorder.mimeType;
20462
+ this.recording.resolve({
20463
+ blob: new Blob(this.chunks, { type: mimeType }),
20464
+ });
20465
+ };
20466
+ handleError = (event) => {
20467
+ if (this.isSettled())
20468
+ return;
20469
+ this.detach();
20470
+ const error = event.error;
20471
+ this.logger.error('MediaRecorder error', error);
20472
+ this.recording.reject(error ?? new Error('MediaRecorder failed'));
20473
+ };
20474
+ }
20475
+
20228
20476
  /**
20229
20477
  * Tiny, type-safe event emitter.
20230
20478
  *
@@ -20598,6 +20846,7 @@ exports.CallTypes = CallTypes;
20598
20846
  exports.CameraManager = CameraManager;
20599
20847
  exports.CameraManagerState = CameraManagerState;
20600
20848
  exports.CreateDeviceRequestPushProviderEnum = CreateDeviceRequestPushProviderEnum;
20849
+ exports.DEFAULT_LOOPBACK_RECORDING_DURATION_MS = DEFAULT_LOOPBACK_RECORDING_DURATION_MS;
20601
20850
  exports.DeviceManager = DeviceManager;
20602
20851
  exports.DeviceManagerState = DeviceManagerState;
20603
20852
  exports.DynascaleManager = DynascaleManager;
@@ -20615,7 +20864,12 @@ exports.IndividualRecordingSettingsResponseModeEnum = IndividualRecordingSetting
20615
20864
  exports.IngressAudioEncodingOptionsRequestChannelsEnum = IngressAudioEncodingOptionsRequestChannelsEnum;
20616
20865
  exports.IngressSourceRequestFpsEnum = IngressSourceRequestFpsEnum;
20617
20866
  exports.IngressVideoLayerRequestCodecEnum = IngressVideoLayerRequestCodecEnum;
20867
+ exports.LOOPBACK_STREAMS_WAIT_TIMEOUT_MS = LOOPBACK_STREAMS_WAIT_TIMEOUT_MS;
20618
20868
  exports.LayoutSettingsRequestNameEnum = LayoutSettingsRequestNameEnum;
20869
+ exports.LoopbackStreamsTimeoutError = LoopbackStreamsTimeoutError;
20870
+ exports.MAX_LOOPBACK_RECORDING_DURATION_MS = MAX_LOOPBACK_RECORDING_DURATION_MS;
20871
+ exports.MIN_LOOPBACK_RECORDING_DURATION_MS = MIN_LOOPBACK_RECORDING_DURATION_MS;
20872
+ exports.MediaStreamRecorder = MediaStreamRecorder;
20619
20873
  exports.MicrophoneManager = MicrophoneManager;
20620
20874
  exports.MicrophoneManagerState = MicrophoneManagerState;
20621
20875
  exports.NoiseCancellationSettingsModeEnum = NoiseCancellationSettingsModeEnum;
@@ -20656,6 +20910,7 @@ exports.VideoSettingsResponseCameraFacingEnum = VideoSettingsResponseCameraFacin
20656
20910
  exports.ViewportTracker = ViewportTracker;
20657
20911
  exports.WSAuthMessageProductsEnum = WSAuthMessageProductsEnum;
20658
20912
  exports.checkIfAudioOutputChangeSupported = checkIfAudioOutputChangeSupported;
20913
+ exports.clampLoopbackRecordingDuration = clampLoopbackRecordingDuration;
20659
20914
  exports.combineComparators = combineComparators;
20660
20915
  exports.conditional = conditional;
20661
20916
  exports.createSoundDetector = createSoundDetector;
@@ -20664,6 +20919,7 @@ exports.descending = descending;
20664
20919
  exports.deviceIds$ = deviceIds$;
20665
20920
  exports.disposeOfMediaStream = disposeOfMediaStream;
20666
20921
  exports.dominantSpeaker = dominantSpeaker;
20922
+ exports.enableLoopbackAudio = enableLoopbackAudio;
20667
20923
  exports.getAudioBrowserPermission = getAudioBrowserPermission;
20668
20924
  exports.getAudioDevices = getAudioDevices;
20669
20925
  exports.getAudioOutputDevices = getAudioOutputDevices;
@@ -20671,6 +20927,8 @@ exports.getAudioStream = getAudioStream;
20671
20927
  exports.getCallMediaEngineProvider = getCallMediaEngineProvider;
20672
20928
  exports.getClientDetails = getClientDetails;
20673
20929
  exports.getDeviceState = getDeviceState;
20930
+ exports.getLoopbackStreams = getLoopbackStreams;
20931
+ exports.getLoopbackTracks = getLoopbackTracks;
20674
20932
  exports.getScreenShareStream = getScreenShareStream;
20675
20933
  exports.getSdkInfo = getSdkInfo;
20676
20934
  exports.getVideoBrowserPermission = getVideoBrowserPermission;
@@ -20707,5 +20965,7 @@ exports.setWebRTCInfo = setWebRTCInfo;
20707
20965
  exports.speakerLayoutSortPreset = speakerLayoutSortPreset;
20708
20966
  exports.speaking = speaking;
20709
20967
  exports.videoLoggerSystem = videoLoggerSystem;
20968
+ exports.waitForLoopbackStreams = waitForLoopbackStreams;
20969
+ exports.withLoopbackAudioEnabled = withLoopbackAudioEnabled;
20710
20970
  exports.withParticipantSource = withParticipantSource;
20711
20971
  //# sourceMappingURL=index.cjs.js.map