@stream-io/video-client 1.23.2 → 1.23.3

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.
package/dist/index.es.js CHANGED
@@ -4,7 +4,7 @@ import { ServiceType, stackIntercept, RpcError } from '@protobuf-ts/runtime-rpc'
4
4
  import axios from 'axios';
5
5
  export { AxiosError } from 'axios';
6
6
  import { TwirpFetchTransport, TwirpErrorCode } from '@protobuf-ts/twirp-transport';
7
- import { ReplaySubject, combineLatest, BehaviorSubject, map, shareReplay, distinctUntilChanged, takeWhile, distinctUntilKeyChanged, fromEventPattern, startWith, concatMap, merge, from, fromEvent, debounceTime, pairwise, of } from 'rxjs';
7
+ import { ReplaySubject, combineLatest, BehaviorSubject, shareReplay, map, distinctUntilChanged, takeWhile, distinctUntilKeyChanged, fromEventPattern, startWith, concatMap, merge, from, fromEvent, debounceTime, pairwise, of } from 'rxjs';
8
8
  import { UAParser } from 'ua-parser-js';
9
9
  import { parse } from 'sdp-transform';
10
10
  import https from 'https';
@@ -5032,6 +5032,9 @@ class CallState {
5032
5032
  return nextQueue.slice(-maxVisibleCaptions);
5033
5033
  });
5034
5034
  };
5035
+ this.rawParticipants$ = this.participantsSubject
5036
+ .asObservable()
5037
+ .pipe(shareReplay({ bufferSize: 1, refCount: true }));
5035
5038
  this.participants$ = this.participantsSubject.asObservable().pipe(
5036
5039
  // maintain stable-sort by mutating the participants stored
5037
5040
  // in the original subject
@@ -5205,6 +5208,12 @@ class CallState {
5205
5208
  get participants() {
5206
5209
  return this.getCurrentValue(this.participants$);
5207
5210
  }
5211
+ /**
5212
+ * The stable list of participants in the current call, unsorted.
5213
+ */
5214
+ get rawParticipants() {
5215
+ return this.getCurrentValue(this.rawParticipants$);
5216
+ }
5208
5217
  /**
5209
5218
  * The local participant in the current call.
5210
5219
  */
@@ -5677,7 +5686,7 @@ const aggregate = (stats) => {
5677
5686
  return report;
5678
5687
  };
5679
5688
 
5680
- const version = "1.23.2";
5689
+ const version = "1.23.3";
5681
5690
  const [major, minor, patch] = version.split('.');
5682
5691
  let sdkInfo = {
5683
5692
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -8434,6 +8443,20 @@ class DynascaleManager {
8434
8443
  true,
8435
8444
  };
8436
8445
  }), shareReplay(1));
8446
+ /**
8447
+ * Disposes the allocated resources and closes the audio context if it was created.
8448
+ */
8449
+ this.dispose = async () => {
8450
+ if (this.pendingSubscriptionsUpdate) {
8451
+ clearTimeout(this.pendingSubscriptionsUpdate);
8452
+ }
8453
+ const context = this.getOrCreateAudioContext();
8454
+ if (context && context.state !== 'closed') {
8455
+ document.removeEventListener('click', this.resumeAudioContext);
8456
+ await context.close();
8457
+ this.audioContext = undefined;
8458
+ }
8459
+ };
8437
8460
  this.setVideoTrackSubscriptionOverrides = (override, sessionIds) => {
8438
8461
  if (!sessionIds) {
8439
8462
  return setCurrentValue(this.videoTrackSubscriptionOverridesSubject, override ? { [globalOverrideKey]: override } : {});
@@ -8549,7 +8572,7 @@ class DynascaleManager {
8549
8572
  });
8550
8573
  this.applyTrackSubscriptions(debounceType);
8551
8574
  };
8552
- const participant$ = this.callState.participants$.pipe(map((participants) => participants.find((participant) => participant.sessionId === sessionId)), takeWhile((participant) => !!participant), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
8575
+ const participant$ = this.callState.participants$.pipe(map((ps) => ps.find((p) => p.sessionId === sessionId)), takeWhile((participant) => !!participant), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
8553
8576
  /**
8554
8577
  * Since the video elements are now being removed from the DOM (React SDK) upon
8555
8578
  * visibility change, this subscription is not in use an stays here only for the
@@ -8677,7 +8700,24 @@ class DynascaleManager {
8677
8700
  const participant = this.callState.findParticipantBySessionId(sessionId);
8678
8701
  if (!participant || participant.isLocalParticipant)
8679
8702
  return;
8680
- const participant$ = this.callState.participants$.pipe(map((participants) => participants.find((p) => p.sessionId === sessionId)), takeWhile((p) => !!p), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
8703
+ const participant$ = this.callState.participants$.pipe(map((ps) => ps.find((p) => p.sessionId === sessionId)), takeWhile((p) => !!p), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
8704
+ const updateSinkId = (deviceId, audioContext) => {
8705
+ if (!deviceId)
8706
+ return;
8707
+ if ('setSinkId' in audioElement) {
8708
+ audioElement.setSinkId(deviceId).catch((e) => {
8709
+ this.logger('warn', `Can't to set AudioElement sinkId`, e);
8710
+ });
8711
+ }
8712
+ if (audioContext && 'setSinkId' in audioContext) {
8713
+ // @ts-expect-error setSinkId is not available in all browsers
8714
+ audioContext.setSinkId(deviceId).catch((e) => {
8715
+ this.logger('warn', `Can't to set AudioContext sinkId`, e);
8716
+ });
8717
+ }
8718
+ };
8719
+ let sourceNode = undefined;
8720
+ let gainNode = undefined;
8681
8721
  const updateMediaStreamSubscription = participant$
8682
8722
  .pipe(distinctUntilKeyChanged(trackType === 'screenShareAudioTrack'
8683
8723
  ? 'screenShareAudioStream'
@@ -8690,40 +8730,82 @@ class DynascaleManager {
8690
8730
  return;
8691
8731
  setTimeout(() => {
8692
8732
  audioElement.srcObject = source ?? null;
8693
- if (audioElement.srcObject) {
8733
+ if (!source)
8734
+ return;
8735
+ // Safari has a special quirk that prevents playing audio until the user
8736
+ // interacts with the page or focuses on the tab where the call happens.
8737
+ // This is a workaround for the issue where:
8738
+ // - A and B are in a call
8739
+ // - A switches to another tab
8740
+ // - B mutes their microphone and unmutes it
8741
+ // - A does not hear B's unmuted audio until they focus the tab
8742
+ const audioContext = this.getOrCreateAudioContext();
8743
+ if (audioContext) {
8744
+ // we will play audio through the audio context in Safari
8745
+ audioElement.muted = true;
8746
+ sourceNode?.disconnect();
8747
+ sourceNode = audioContext.createMediaStreamSource(source);
8748
+ gainNode ?? (gainNode = audioContext.createGain());
8749
+ gainNode.gain.value = p.audioVolume ?? this.speaker.state.volume;
8750
+ sourceNode.connect(gainNode).connect(audioContext.destination);
8751
+ this.resumeAudioContext();
8752
+ }
8753
+ else {
8754
+ // we will play audio directly through the audio element in other browsers
8755
+ audioElement.muted = false;
8694
8756
  audioElement.play().catch((e) => {
8695
- this.logger('warn', `Failed to play stream`, e);
8757
+ this.logger('warn', `Failed to play audio stream`, e);
8696
8758
  });
8697
- // audio output device shall be set after the audio element is played
8698
- // otherwise, the browser will not pick it up, and will always
8699
- // play audio through the system's default device
8700
- const { selectedDevice } = this.speaker.state;
8701
- if (selectedDevice && 'setSinkId' in audioElement) {
8702
- audioElement.setSinkId(selectedDevice);
8703
- }
8704
8759
  }
8760
+ const { selectedDevice } = this.speaker.state;
8761
+ if (selectedDevice)
8762
+ updateSinkId(selectedDevice, audioContext);
8705
8763
  });
8706
8764
  });
8707
8765
  const sinkIdSubscription = !('setSinkId' in audioElement)
8708
8766
  ? null
8709
8767
  : this.speaker.state.selectedDevice$.subscribe((deviceId) => {
8710
- if (deviceId) {
8711
- audioElement.setSinkId(deviceId);
8712
- }
8768
+ const audioContext = this.getOrCreateAudioContext();
8769
+ updateSinkId(deviceId, audioContext);
8713
8770
  });
8714
8771
  const volumeSubscription = combineLatest([
8715
8772
  this.speaker.state.volume$,
8716
8773
  participant$.pipe(distinctUntilKeyChanged('audioVolume')),
8717
8774
  ]).subscribe(([volume, p]) => {
8718
- audioElement.volume = p.audioVolume ?? volume;
8775
+ const participantVolume = p.audioVolume ?? volume;
8776
+ audioElement.volume = participantVolume;
8777
+ if (gainNode)
8778
+ gainNode.gain.value = participantVolume;
8719
8779
  });
8720
8780
  audioElement.autoplay = true;
8721
8781
  return () => {
8722
8782
  sinkIdSubscription?.unsubscribe();
8723
8783
  volumeSubscription.unsubscribe();
8724
8784
  updateMediaStreamSubscription.unsubscribe();
8785
+ audioElement.srcObject = null;
8786
+ sourceNode?.disconnect();
8787
+ gainNode?.disconnect();
8725
8788
  };
8726
8789
  };
8790
+ this.getOrCreateAudioContext = () => {
8791
+ if (this.audioContext || !isSafari())
8792
+ return this.audioContext;
8793
+ const context = new AudioContext();
8794
+ if (context.state === 'suspended') {
8795
+ document.addEventListener('click', this.resumeAudioContext);
8796
+ }
8797
+ return (this.audioContext = context);
8798
+ };
8799
+ this.resumeAudioContext = () => {
8800
+ if (this.audioContext?.state === 'suspended') {
8801
+ this.audioContext
8802
+ .resume()
8803
+ .catch((err) => this.logger('warn', `Can't resume audio context`, err))
8804
+ .then(() => {
8805
+ document.removeEventListener('click', this.resumeAudioContext);
8806
+ });
8807
+ }
8808
+ };
8727
8809
  this.callState = callState;
8728
8810
  this.speaker = speaker;
8729
8811
  }
@@ -9506,10 +9588,22 @@ class InputMediaDeviceManager {
9506
9588
  }
9507
9589
  }
9508
9590
  async applySettingsToStream() {
9509
- await withCancellation(this.statusChangeConcurrencyTag, async () => {
9591
+ await withCancellation(this.statusChangeConcurrencyTag, async (signal) => {
9510
9592
  if (this.enabled) {
9511
- await this.muteStream();
9512
- await this.unmuteStream();
9593
+ try {
9594
+ await this.muteStream();
9595
+ this.state.setStatus('disabled');
9596
+ if (signal.aborted) {
9597
+ return;
9598
+ }
9599
+ await this.unmuteStream();
9600
+ this.state.setStatus('enabled');
9601
+ }
9602
+ finally {
9603
+ if (!signal.aborted) {
9604
+ this.state.setPendingStatus(this.state.status);
9605
+ }
9606
+ }
9513
9607
  }
9514
9608
  });
9515
9609
  }
@@ -9575,130 +9669,122 @@ class InputMediaDeviceManager {
9575
9669
  this.logger('debug', 'Starting stream');
9576
9670
  let stream;
9577
9671
  let rootStream;
9578
- try {
9579
- if (this.state.mediaStream &&
9580
- this.getTracks().every((t) => t.readyState === 'live')) {
9581
- stream = this.state.mediaStream;
9582
- this.enableTracks();
9583
- }
9584
- else {
9585
- const defaultConstraints = this.state.defaultConstraints;
9586
- const constraints = {
9587
- ...defaultConstraints,
9588
- deviceId: this.state.selectedDevice
9589
- ? { exact: this.state.selectedDevice }
9590
- : undefined,
9591
- };
9592
- /**
9593
- * Chains two media streams together.
9594
- *
9595
- * In our case, filters MediaStreams are derived from their parent MediaStream.
9596
- * However, once a child filter's track is stopped,
9597
- * the tracks of the parent MediaStream aren't automatically stopped.
9598
- * This leads to a situation where the camera indicator light is still on
9599
- * even though the user stopped publishing video.
9600
- *
9601
- * This function works around this issue by stopping the parent MediaStream's tracks
9602
- * as well once the child filter's tracks are stopped.
9603
- *
9604
- * It works by patching the stop() method of the child filter's tracks to also stop
9605
- * the parent MediaStream's tracks of the same type. Here we assume that
9606
- * the parent MediaStream has only one track of each type.
9607
- *
9608
- * @param parentStream the parent MediaStream. Omit for the root stream.
9609
- */
9610
- const chainWith = (parentStream) => async (filterStream) => {
9611
- if (!parentStream)
9612
- return filterStream;
9613
- // TODO OL: take care of track.enabled property as well
9614
- const parent = await parentStream;
9615
- filterStream.getTracks().forEach((track) => {
9616
- const originalStop = track.stop;
9617
- track.stop = function stop() {
9618
- originalStop.call(track);
9619
- parent.getTracks().forEach((parentTrack) => {
9620
- if (parentTrack.kind === track.kind) {
9621
- parentTrack.stop();
9622
- }
9623
- });
9624
- };
9625
- });
9626
- parent.getTracks().forEach((parentTrack) => {
9627
- // When the parent stream abruptly ends, we propagate the event
9628
- // to the filter stream.
9629
- // This usually happens when the camera/microphone permissions
9630
- // are revoked or when the device is disconnected.
9631
- const handleParentTrackEnded = () => {
9632
- filterStream.getTracks().forEach((track) => {
9633
- if (parentTrack.kind !== track.kind)
9634
- return;
9635
- track.stop();
9636
- track.dispatchEvent(new Event('ended')); // propagate the event
9637
- });
9638
- };
9639
- parentTrack.addEventListener('ended', handleParentTrackEnded);
9640
- this.subscriptions.push(() => {
9641
- parentTrack.removeEventListener('ended', handleParentTrackEnded);
9642
- });
9643
- });
9672
+ if (this.state.mediaStream &&
9673
+ this.getTracks().every((t) => t.readyState === 'live')) {
9674
+ stream = this.state.mediaStream;
9675
+ this.enableTracks();
9676
+ }
9677
+ else {
9678
+ const defaultConstraints = this.state.defaultConstraints;
9679
+ const constraints = {
9680
+ ...defaultConstraints,
9681
+ deviceId: this.state.selectedDevice
9682
+ ? { exact: this.state.selectedDevice }
9683
+ : undefined,
9684
+ };
9685
+ /**
9686
+ * Chains two media streams together.
9687
+ *
9688
+ * In our case, filters MediaStreams are derived from their parent MediaStream.
9689
+ * However, once a child filter's track is stopped,
9690
+ * the tracks of the parent MediaStream aren't automatically stopped.
9691
+ * This leads to a situation where the camera indicator light is still on
9692
+ * even though the user stopped publishing video.
9693
+ *
9694
+ * This function works around this issue by stopping the parent MediaStream's tracks
9695
+ * as well once the child filter's tracks are stopped.
9696
+ *
9697
+ * It works by patching the stop() method of the child filter's tracks to also stop
9698
+ * the parent MediaStream's tracks of the same type. Here we assume that
9699
+ * the parent MediaStream has only one track of each type.
9700
+ *
9701
+ * @param parentStream the parent MediaStream. Omit for the root stream.
9702
+ */
9703
+ const chainWith = (parentStream) => async (filterStream) => {
9704
+ if (!parentStream)
9644
9705
  return filterStream;
9645
- };
9646
- // the rootStream represents the stream coming from the actual device
9647
- // e.g. camera or microphone stream
9648
- rootStream = this.getStream(constraints);
9649
- // we publish the last MediaStream of the chain
9650
- stream = await this.filters.reduce((parent, entry) => parent
9651
- .then((inputStream) => {
9652
- const { stop, output } = entry.start(inputStream);
9653
- entry.stop = stop;
9654
- return output;
9655
- })
9656
- .then(chainWith(parent), (error) => {
9657
- this.logger('warn', 'Filter failed to start and will be ignored', error);
9658
- return parent;
9659
- }), rootStream);
9660
- }
9661
- if (this.call.state.callingState === CallingState.JOINED) {
9662
- await this.publishStream(stream);
9663
- }
9664
- if (this.state.mediaStream !== stream) {
9665
- this.state.setMediaStream(stream, await rootStream);
9666
- const handleTrackEnded = async () => {
9667
- await this.statusChangeSettled();
9668
- if (this.enabled) {
9669
- this.isTrackStoppedDueToTrackEnd = true;
9670
- setTimeout(() => {
9671
- this.isTrackStoppedDueToTrackEnd = false;
9672
- }, 2000);
9673
- await this.disable();
9674
- }
9675
- };
9676
- const createTrackMuteHandler = (muted) => () => {
9677
- if (!isMobile() || this.trackType !== TrackType.VIDEO)
9678
- return;
9679
- this.call.notifyTrackMuteState(muted, this.trackType).catch((err) => {
9680
- this.logger('warn', 'Error while notifying track mute state', err);
9681
- });
9682
- };
9683
- stream.getTracks().forEach((track) => {
9684
- const muteHandler = createTrackMuteHandler(true);
9685
- const unmuteHandler = createTrackMuteHandler(false);
9686
- track.addEventListener('mute', muteHandler);
9687
- track.addEventListener('unmute', unmuteHandler);
9688
- track.addEventListener('ended', handleTrackEnded);
9706
+ // TODO OL: take care of track.enabled property as well
9707
+ const parent = await parentStream;
9708
+ filterStream.getTracks().forEach((track) => {
9709
+ const originalStop = track.stop;
9710
+ track.stop = function stop() {
9711
+ originalStop.call(track);
9712
+ parent.getTracks().forEach((parentTrack) => {
9713
+ if (parentTrack.kind === track.kind) {
9714
+ parentTrack.stop();
9715
+ }
9716
+ });
9717
+ };
9718
+ });
9719
+ parent.getTracks().forEach((parentTrack) => {
9720
+ // When the parent stream abruptly ends, we propagate the event
9721
+ // to the filter stream.
9722
+ // This usually happens when the camera/microphone permissions
9723
+ // are revoked or when the device is disconnected.
9724
+ const handleParentTrackEnded = () => {
9725
+ filterStream.getTracks().forEach((track) => {
9726
+ if (parentTrack.kind !== track.kind)
9727
+ return;
9728
+ track.stop();
9729
+ track.dispatchEvent(new Event('ended')); // propagate the event
9730
+ });
9731
+ };
9732
+ parentTrack.addEventListener('ended', handleParentTrackEnded);
9689
9733
  this.subscriptions.push(() => {
9690
- track.removeEventListener('mute', muteHandler);
9691
- track.removeEventListener('unmute', unmuteHandler);
9692
- track.removeEventListener('ended', handleTrackEnded);
9734
+ parentTrack.removeEventListener('ended', handleParentTrackEnded);
9693
9735
  });
9694
9736
  });
9695
- }
9737
+ return filterStream;
9738
+ };
9739
+ // the rootStream represents the stream coming from the actual device
9740
+ // e.g. camera or microphone stream
9741
+ rootStream = this.getStream(constraints);
9742
+ // we publish the last MediaStream of the chain
9743
+ stream = await this.filters.reduce((parent, entry) => parent
9744
+ .then((inputStream) => {
9745
+ const { stop, output } = entry.start(inputStream);
9746
+ entry.stop = stop;
9747
+ return output;
9748
+ })
9749
+ .then(chainWith(parent), (error) => {
9750
+ this.logger('warn', 'Filter failed to start and will be ignored', error);
9751
+ return parent;
9752
+ }), rootStream);
9696
9753
  }
9697
- catch (err) {
9698
- if (rootStream) {
9699
- disposeOfMediaStream(await rootStream);
9700
- }
9701
- throw err;
9754
+ if (this.call.state.callingState === CallingState.JOINED) {
9755
+ await this.publishStream(stream);
9756
+ }
9757
+ if (this.state.mediaStream !== stream) {
9758
+ this.state.setMediaStream(stream, await rootStream);
9759
+ const handleTrackEnded = async () => {
9760
+ await this.statusChangeSettled();
9761
+ if (this.enabled) {
9762
+ this.isTrackStoppedDueToTrackEnd = true;
9763
+ setTimeout(() => {
9764
+ this.isTrackStoppedDueToTrackEnd = false;
9765
+ }, 2000);
9766
+ await this.disable();
9767
+ }
9768
+ };
9769
+ const createTrackMuteHandler = (muted) => () => {
9770
+ if (!isMobile() || this.trackType !== TrackType.VIDEO)
9771
+ return;
9772
+ this.call.notifyTrackMuteState(muted, this.trackType).catch((err) => {
9773
+ this.logger('warn', 'Error while notifying track mute state', err);
9774
+ });
9775
+ };
9776
+ stream.getTracks().forEach((track) => {
9777
+ const muteHandler = createTrackMuteHandler(true);
9778
+ const unmuteHandler = createTrackMuteHandler(false);
9779
+ track.addEventListener('mute', muteHandler);
9780
+ track.addEventListener('unmute', unmuteHandler);
9781
+ track.addEventListener('ended', handleTrackEnded);
9782
+ this.subscriptions.push(() => {
9783
+ track.removeEventListener('mute', muteHandler);
9784
+ track.removeEventListener('unmute', unmuteHandler);
9785
+ track.removeEventListener('ended', handleTrackEnded);
9786
+ });
9787
+ });
9702
9788
  }
9703
9789
  }
9704
9790
  get mediaDeviceKind() {
@@ -10425,6 +10511,7 @@ class MicrophoneManager extends InputMediaDeviceManager {
10425
10511
  await this.disableNoiseCancellation().catch((err) => {
10426
10512
  this.logger('warn', 'Failed to disable noise cancellation', err);
10427
10513
  });
10514
+ throw e;
10428
10515
  }
10429
10516
  }
10430
10517
  /**
@@ -11061,6 +11148,7 @@ class Call {
11061
11148
  await this.sfuClient?.leaveAndClose(message ?? reason ?? 'user is leaving the call');
11062
11149
  this.sfuClient = undefined;
11063
11150
  this.dynascaleManager.setSfuClient(undefined);
11151
+ await this.dynascaleManager.dispose();
11064
11152
  this.state.setCallingState(CallingState.LEFT);
11065
11153
  this.state.setParticipants([]);
11066
11154
  this.state.dispose();
@@ -13782,7 +13870,7 @@ class StreamClient {
13782
13870
  this.getUserAgent = () => {
13783
13871
  if (!this.cachedUserAgent) {
13784
13872
  const { clientAppIdentifier = {} } = this.options;
13785
- const { sdkName = 'js', sdkVersion = "1.23.2", ...extras } = clientAppIdentifier;
13873
+ const { sdkName = 'js', sdkVersion = "1.23.3", ...extras } = clientAppIdentifier;
13786
13874
  this.cachedUserAgent = [
13787
13875
  `stream-video-${sdkName}-v${sdkVersion}`,
13788
13876
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),