@stream-io/video-client 1.36.0 → 1.36.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.
package/dist/index.es.js CHANGED
@@ -6,9 +6,9 @@ export { AxiosError } from 'axios';
6
6
  import { TwirpFetchTransport, TwirpErrorCode } from '@protobuf-ts/twirp-transport';
7
7
  import * as scopedLogger from '@stream-io/logger';
8
8
  export { LogLevelEnum } from '@stream-io/logger';
9
+ import { parse, write } from 'sdp-transform';
9
10
  import { ReplaySubject, combineLatest, BehaviorSubject, shareReplay, map, distinctUntilChanged, startWith, takeWhile, distinctUntilKeyChanged, fromEventPattern, concatMap, merge, from, fromEvent, tap, debounceTime, pairwise, of } from 'rxjs';
10
11
  import { UAParser } from 'ua-parser-js';
11
- import { parse, write } from 'sdp-transform';
12
12
  import { WorkerTimer } from '@stream-io/worker-timer';
13
13
  import https from 'https';
14
14
 
@@ -3930,19 +3930,158 @@ const retryable = async (rpc, signal) => {
3930
3930
  return result;
3931
3931
  };
3932
3932
 
3933
+ /**
3934
+ * Extracts the mid from the transceiver or the SDP.
3935
+ *
3936
+ * @param transceiver the transceiver.
3937
+ * @param transceiverInitIndex the index of the transceiver in the transceiver's init array.
3938
+ * @param sdp the SDP.
3939
+ */
3940
+ const extractMid = (transceiver, transceiverInitIndex, sdp) => {
3941
+ if (transceiver.mid)
3942
+ return transceiver.mid;
3943
+ if (!sdp)
3944
+ return String(transceiverInitIndex);
3945
+ const track = transceiver.sender.track;
3946
+ const parsedSdp = parse(sdp);
3947
+ const media = parsedSdp.media.find((m) => {
3948
+ return (m.type === track.kind &&
3949
+ // if `msid` is not present, we assume that the track is the first one
3950
+ (m.msid?.includes(track.id) ?? true));
3951
+ });
3952
+ if (typeof media?.mid !== 'undefined')
3953
+ return String(media.mid);
3954
+ if (transceiverInitIndex < 0)
3955
+ return '';
3956
+ return String(transceiverInitIndex);
3957
+ };
3958
+ /**
3959
+ * Enables stereo in the answer SDP based on the offered stereo in the offer SDP.
3960
+ *
3961
+ * @param offerSdp the offer SDP containing the stereo configuration.
3962
+ * @param answerSdp the answer SDP to be modified.
3963
+ */
3964
+ const enableStereo = (offerSdp, answerSdp) => {
3965
+ const offeredStereoMids = new Set();
3966
+ const parsedOfferSdp = parse(offerSdp);
3967
+ for (const media of parsedOfferSdp.media) {
3968
+ if (media.type !== 'audio')
3969
+ continue;
3970
+ const opus = media.rtp.find((r) => r.codec === 'opus');
3971
+ if (!opus)
3972
+ continue;
3973
+ for (const fmtp of media.fmtp) {
3974
+ if (fmtp.payload === opus.payload && fmtp.config.includes('stereo=1')) {
3975
+ offeredStereoMids.add(media.mid);
3976
+ }
3977
+ }
3978
+ }
3979
+ // No stereo offered, return the original answerSdp
3980
+ if (offeredStereoMids.size === 0)
3981
+ return answerSdp;
3982
+ const parsedAnswerSdp = parse(answerSdp);
3983
+ for (const media of parsedAnswerSdp.media) {
3984
+ if (media.type !== 'audio' || !offeredStereoMids.has(media.mid))
3985
+ continue;
3986
+ const opus = media.rtp.find((r) => r.codec === 'opus');
3987
+ if (!opus)
3988
+ continue;
3989
+ for (const fmtp of media.fmtp) {
3990
+ if (fmtp.payload === opus.payload && !fmtp.config.includes('stereo=1')) {
3991
+ fmtp.config += ';stereo=1';
3992
+ }
3993
+ }
3994
+ }
3995
+ return write(parsedAnswerSdp);
3996
+ };
3997
+ /**
3998
+ * Removes all codecs from the SDP except the specified codec.
3999
+ *
4000
+ * @param sdp the SDP to modify.
4001
+ * @param codecMimeTypeToKeep the codec mime type to keep (video/h264 or audio/opus).
4002
+ * @param fmtpProfileToKeep the fmtp profile to keep (e.g. 'profile-level-id=42e01f' or multiple segments like 'profile-level-id=64001f;packetization-mode=1').
4003
+ */
4004
+ const removeCodecsExcept = (sdp, codecMimeTypeToKeep, fmtpProfileToKeep) => {
4005
+ const [kind, codec] = toMimeType(codecMimeTypeToKeep).split('/');
4006
+ if (!kind || !codec)
4007
+ return sdp;
4008
+ const parsed = parse(sdp);
4009
+ for (const media of parsed.media) {
4010
+ if (media.type !== kind)
4011
+ continue;
4012
+ // Build a set of payloads to KEEP: all payloads whose rtp.codec matches codec
4013
+ let payloadsToKeep = new Set();
4014
+ for (const rtp of media.rtp) {
4015
+ if (rtp.codec.toLowerCase() !== codec)
4016
+ continue;
4017
+ payloadsToKeep.add(rtp.payload);
4018
+ }
4019
+ // If a specific fmtp profile is requested, only keep payloads whose fmtp config matches it
4020
+ if (fmtpProfileToKeep) {
4021
+ const filtered = new Set();
4022
+ const required = new Set(fmtpProfileToKeep.split(';'));
4023
+ for (const fmtp of media.fmtp) {
4024
+ if (payloadsToKeep.has(fmtp.payload) &&
4025
+ required.difference(new Set(fmtp.config.split(';'))).size === 0) {
4026
+ filtered.add(fmtp.payload);
4027
+ }
4028
+ }
4029
+ payloadsToKeep = filtered;
4030
+ }
4031
+ // If no payloads to keep AND no fmtpProfile was specified, skip modifications (preserve SDP as-is)
4032
+ if (payloadsToKeep.size === 0 && !fmtpProfileToKeep)
4033
+ continue;
4034
+ // Keep RTX payloads that are associated with kept primary payloads via apt
4035
+ // RTX mappings look like: a=fmtp:<rtxPayload> apt=<primaryPayload>
4036
+ for (const fmtp of media.fmtp) {
4037
+ const matches = /\s*apt\s*=\s*(\d+)\s*/i.exec(fmtp.config);
4038
+ if (!matches)
4039
+ continue;
4040
+ const primaryPayloadApt = Number(matches[1]);
4041
+ if (!payloadsToKeep.has(primaryPayloadApt))
4042
+ continue;
4043
+ payloadsToKeep.add(fmtp.payload);
4044
+ }
4045
+ // Filter rtp, fmtp and rtcpFb entries
4046
+ media.rtp = media.rtp.filter((rtp) => payloadsToKeep.has(rtp.payload));
4047
+ media.fmtp = media.fmtp.filter((fmtp) => payloadsToKeep.has(fmtp.payload));
4048
+ media.rtcpFb = media.rtcpFb?.filter((fb) => typeof fb.payload === 'number' ? payloadsToKeep.has(fb.payload) : true);
4049
+ // Update the m= line payload list to only the kept payloads, preserving original order
4050
+ const payloads = [];
4051
+ for (const id of (media.payloads || '').split(/\s+/)) {
4052
+ const payload = Number(id);
4053
+ if (!payloadsToKeep.has(payload))
4054
+ continue;
4055
+ payloads.push(payload);
4056
+ }
4057
+ media.payloads = payloads.join(' ');
4058
+ }
4059
+ return write(parsed);
4060
+ };
4061
+ /**
4062
+ * Converts the given codec to a mime-type format when necessary.
4063
+ * e.g.: `vp9` -> `video/vp9`
4064
+ */
4065
+ const toMimeType = (codec, kind = 'video') => codec.includes('/') ? codec : `${kind}/${codec}`;
4066
+
3933
4067
  /**
3934
4068
  * Returns a generic SDP for the given direction.
3935
4069
  * We use this SDP to send it as part of our JoinRequest so that the SFU
3936
4070
  * can use it to determine the client's codec capabilities.
3937
4071
  *
3938
4072
  * @param direction the direction of the transceiver.
4073
+ * @param codecToKeep the codec mime type to keep (video/h264 or audio/opus).
4074
+ * @param fmtpProfileToKeep optional fmtp profile to keep.
3939
4075
  */
3940
- const getGenericSdp = async (direction) => {
4076
+ const getGenericSdp = async (direction, codecToKeep, fmtpProfileToKeep) => {
3941
4077
  const tempPc = new RTCPeerConnection();
3942
4078
  tempPc.addTransceiver('video', { direction });
3943
4079
  tempPc.addTransceiver('audio', { direction });
3944
4080
  const offer = await tempPc.createOffer();
3945
- const sdp = offer.sdp ?? '';
4081
+ const { sdp: baseSdp = '' } = offer;
4082
+ const sdp = codecToKeep
4083
+ ? removeCodecsExcept(baseSdp, codecToKeep, fmtpProfileToKeep)
4084
+ : baseSdp;
3946
4085
  tempPc.getTransceivers().forEach((t) => {
3947
4086
  t.stop?.();
3948
4087
  });
@@ -5838,7 +5977,7 @@ const getSdkVersion = (sdk) => {
5838
5977
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
5839
5978
  };
5840
5979
 
5841
- const version = "1.36.0";
5980
+ const version = "1.36.1";
5842
5981
  const [major, minor, patch] = version.split('.');
5843
5982
  let sdkInfo = {
5844
5983
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -6759,7 +6898,7 @@ class BasePeerConnection {
6759
6898
  /**
6760
6899
  * Constructs a new `BasePeerConnection` instance.
6761
6900
  */
6762
- constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, tag, enableTracing, iceRestartDelay = 2500, }) {
6901
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, }) {
6763
6902
  this.isIceRestarting = false;
6764
6903
  this.isDisposed = false;
6765
6904
  this.trackIdToTrackType = new Map();
@@ -6788,7 +6927,7 @@ class BasePeerConnection {
6788
6927
  e.error.code === ErrorCode.PARTICIPANT_SIGNAL_LOST
6789
6928
  ? WebsocketReconnectStrategy.FAST
6790
6929
  : WebsocketReconnectStrategy.REJOIN;
6791
- this.onReconnectionNeeded?.(strategy, reason);
6930
+ this.onReconnectionNeeded?.(strategy, reason, this.peerType);
6792
6931
  });
6793
6932
  };
6794
6933
  /**
@@ -6904,7 +7043,7 @@ class BasePeerConnection {
6904
7043
  }
6905
7044
  // we can't recover from a failed connection state (contrary to ICE)
6906
7045
  if (state === 'failed') {
6907
- this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, 'Connection failed');
7046
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, 'Connection failed', this.peerType);
6908
7047
  return;
6909
7048
  }
6910
7049
  this.handleConnectionStateUpdate(state);
@@ -6980,6 +7119,7 @@ class BasePeerConnection {
6980
7119
  this.state = state;
6981
7120
  this.dispatcher = dispatcher;
6982
7121
  this.iceRestartDelay = iceRestartDelay;
7122
+ this.clientPublishOptions = clientPublishOptions;
6983
7123
  this.onReconnectionNeeded = onReconnectionNeeded;
6984
7124
  this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
6985
7125
  this.pc = this.createPeerConnection(connectionConfig);
@@ -7314,71 +7454,6 @@ const withSimulcastConstraints = (width, height, optimalVideoLayers, useSingleLa
7314
7454
  }));
7315
7455
  };
7316
7456
 
7317
- /**
7318
- * Extracts the mid from the transceiver or the SDP.
7319
- *
7320
- * @param transceiver the transceiver.
7321
- * @param transceiverInitIndex the index of the transceiver in the transceiver's init array.
7322
- * @param sdp the SDP.
7323
- */
7324
- const extractMid = (transceiver, transceiverInitIndex, sdp) => {
7325
- if (transceiver.mid)
7326
- return transceiver.mid;
7327
- if (!sdp)
7328
- return String(transceiverInitIndex);
7329
- const track = transceiver.sender.track;
7330
- const parsedSdp = parse(sdp);
7331
- const media = parsedSdp.media.find((m) => {
7332
- return (m.type === track.kind &&
7333
- // if `msid` is not present, we assume that the track is the first one
7334
- (m.msid?.includes(track.id) ?? true));
7335
- });
7336
- if (typeof media?.mid !== 'undefined')
7337
- return String(media.mid);
7338
- if (transceiverInitIndex < 0)
7339
- return '';
7340
- return String(transceiverInitIndex);
7341
- };
7342
- /**
7343
- * Enables stereo in the answer SDP based on the offered stereo in the offer SDP.
7344
- *
7345
- * @param offerSdp the offer SDP containing the stereo configuration.
7346
- * @param answerSdp the answer SDP to be modified.
7347
- */
7348
- const enableStereo = (offerSdp, answerSdp) => {
7349
- const offeredStereoMids = new Set();
7350
- const parsedOfferSdp = parse(offerSdp);
7351
- for (const media of parsedOfferSdp.media) {
7352
- if (media.type !== 'audio')
7353
- continue;
7354
- const opus = media.rtp.find((r) => r.codec === 'opus');
7355
- if (!opus)
7356
- continue;
7357
- for (const fmtp of media.fmtp) {
7358
- if (fmtp.payload === opus.payload && fmtp.config.includes('stereo=1')) {
7359
- offeredStereoMids.add(media.mid);
7360
- }
7361
- }
7362
- }
7363
- // No stereo offered, return the original answerSdp
7364
- if (offeredStereoMids.size === 0)
7365
- return answerSdp;
7366
- const parsedAnswerSdp = parse(answerSdp);
7367
- for (const media of parsedAnswerSdp.media) {
7368
- if (media.type !== 'audio' || !offeredStereoMids.has(media.mid))
7369
- continue;
7370
- const opus = media.rtp.find((r) => r.codec === 'opus');
7371
- if (!opus)
7372
- continue;
7373
- for (const fmtp of media.fmtp) {
7374
- if (fmtp.payload === opus.payload && !fmtp.config.includes('stereo=1')) {
7375
- fmtp.config += ';stereo=1';
7376
- }
7377
- }
7378
- }
7379
- return write(parsedAnswerSdp);
7380
- };
7381
-
7382
7457
  /**
7383
7458
  * The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
7384
7459
  *
@@ -7388,7 +7463,7 @@ class Publisher extends BasePeerConnection {
7388
7463
  /**
7389
7464
  * Constructs a new `Publisher` instance.
7390
7465
  */
7391
- constructor({ publishOptions, ...baseOptions }) {
7466
+ constructor(baseOptions, publishOptions) {
7392
7467
  super(PeerType.PUBLISHER_UNSPECIFIED, baseOptions);
7393
7468
  this.transceiverCache = new TransceiverCache();
7394
7469
  this.clonedTracks = new Set();
@@ -7647,7 +7722,11 @@ class Publisher extends BasePeerConnection {
7647
7722
  try {
7648
7723
  this.isIceRestarting = options?.iceRestart ?? false;
7649
7724
  await this.pc.setLocalDescription(offer);
7650
- const { sdp = '' } = offer;
7725
+ const { sdp: baseSdp = '' } = offer;
7726
+ const { dangerouslyForceCodec, fmtpLine } = this.clientPublishOptions || {};
7727
+ const sdp = dangerouslyForceCodec
7728
+ ? removeCodecsExcept(baseSdp, dangerouslyForceCodec, fmtpLine)
7729
+ : baseSdp;
7651
7730
  const { response } = await this.sfuClient.setPublisher({ sdp, tracks });
7652
7731
  if (response.error)
7653
7732
  throw new NegotiationError(response.error);
@@ -7877,6 +7956,10 @@ class Subscriber extends BasePeerConnection {
7877
7956
  const answer = await this.pc.createAnswer();
7878
7957
  if (answer.sdp) {
7879
7958
  answer.sdp = enableStereo(subscriberOffer.sdp, answer.sdp);
7959
+ const { dangerouslyForceCodec, subscriberFmtpLine } = this.clientPublishOptions || {};
7960
+ if (dangerouslyForceCodec) {
7961
+ answer.sdp = removeCodecsExcept(answer.sdp, dangerouslyForceCodec, subscriberFmtpLine);
7962
+ }
7880
7963
  }
7881
7964
  await this.pc.setLocalDescription(answer);
7882
7965
  await this.sfuClient.sendAnswer({
@@ -12217,9 +12300,10 @@ class Call {
12217
12300
  // prepare a generic SDP and send it to the SFU.
12218
12301
  // these are throw-away SDPs that the SFU will use to determine
12219
12302
  // the capabilities of the client (codec support, etc.)
12303
+ const { dangerouslyForceCodec, fmtpLine, subscriberFmtpLine } = this.clientPublishOptions || {};
12220
12304
  const [subscriberSdp, publisherSdp] = await Promise.all([
12221
- getGenericSdp('recvonly'),
12222
- getGenericSdp('sendonly'),
12305
+ getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
12306
+ getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
12223
12307
  ]);
12224
12308
  const isReconnecting = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
12225
12309
  const reconnectDetails = isReconnecting
@@ -12411,20 +12495,22 @@ class Call {
12411
12495
  if (closePreviousInstances && this.subscriber) {
12412
12496
  this.subscriber.dispose();
12413
12497
  }
12414
- this.subscriber = new Subscriber({
12498
+ const basePeerConnectionOptions = {
12415
12499
  sfuClient,
12416
12500
  dispatcher: this.dispatcher,
12417
12501
  state: this.state,
12418
12502
  connectionConfig,
12419
12503
  tag: sfuClient.tag,
12420
12504
  enableTracing,
12421
- onReconnectionNeeded: (kind, reason) => {
12505
+ clientPublishOptions: this.clientPublishOptions,
12506
+ onReconnectionNeeded: (kind, reason, peerType) => {
12422
12507
  this.reconnect(kind, reason).catch((err) => {
12423
- const message = `[Reconnect] Error reconnecting after a subscriber error: ${reason}`;
12508
+ const message = `[Reconnect] Error reconnecting, after a ${PeerType[peerType]} error: ${reason}`;
12424
12509
  this.logger.warn(message, err);
12425
12510
  });
12426
12511
  },
12427
- });
12512
+ };
12513
+ this.subscriber = new Subscriber(basePeerConnectionOptions);
12428
12514
  // anonymous users can't publish anything hence, there is no need
12429
12515
  // to create Publisher Peer Connection for them
12430
12516
  const isAnonymous = this.streamClient.user?.type === 'anonymous';
@@ -12432,21 +12518,7 @@ class Call {
12432
12518
  if (closePreviousInstances && this.publisher) {
12433
12519
  this.publisher.dispose();
12434
12520
  }
12435
- this.publisher = new Publisher({
12436
- sfuClient,
12437
- dispatcher: this.dispatcher,
12438
- state: this.state,
12439
- connectionConfig,
12440
- publishOptions,
12441
- tag: sfuClient.tag,
12442
- enableTracing,
12443
- onReconnectionNeeded: (kind, reason) => {
12444
- this.reconnect(kind, reason).catch((err) => {
12445
- const message = `[Reconnect] Error reconnecting after a publisher error: ${reason}`;
12446
- this.logger.warn(message, err);
12447
- });
12448
- },
12449
- });
12521
+ this.publisher = new Publisher(basePeerConnectionOptions, publishOptions);
12450
12522
  }
12451
12523
  this.statsReporter?.stop();
12452
12524
  if (this.statsReportingIntervalInMs > 0) {
@@ -14806,7 +14878,7 @@ class StreamClient {
14806
14878
  this.getUserAgent = () => {
14807
14879
  if (!this.cachedUserAgent) {
14808
14880
  const { clientAppIdentifier = {} } = this.options;
14809
- const { sdkName = 'js', sdkVersion = "1.36.0", ...extras } = clientAppIdentifier;
14881
+ const { sdkName = 'js', sdkVersion = "1.36.1", ...extras } = clientAppIdentifier;
14810
14882
  this.cachedUserAgent = [
14811
14883
  `stream-video-${sdkName}-v${sdkVersion}`,
14812
14884
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),