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