@stream-io/video-client 1.50.0 → 1.52.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 (61) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/index.browser.es.js +597 -70
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +597 -69
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +597 -70
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +1 -0
  9. package/dist/src/devices/CameraManager.d.ts +1 -0
  10. package/dist/src/devices/DeviceManager.d.ts +20 -0
  11. package/dist/src/devices/VirtualDevice.d.ts +59 -0
  12. package/dist/src/devices/devicePersistence.d.ts +1 -1
  13. package/dist/src/devices/index.d.ts +1 -0
  14. package/dist/src/gen/video/sfu/event/events.d.ts +4 -0
  15. package/dist/src/gen/video/sfu/models/models.d.ts +204 -2
  16. package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +9 -1
  17. package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +67 -1
  18. package/dist/src/helpers/participantUtils.d.ts +10 -0
  19. package/dist/src/rtc/BasePeerConnection.d.ts +8 -3
  20. package/dist/src/rtc/Publisher.d.ts +21 -3
  21. package/dist/src/rtc/TransceiverCache.d.ts +5 -1
  22. package/dist/src/rtc/helpers/degradationPreference.d.ts +1 -0
  23. package/dist/src/rtc/types.d.ts +3 -0
  24. package/dist/src/stats/rtc/StatsTracer.d.ts +2 -1
  25. package/dist/src/stats/utils.d.ts +1 -0
  26. package/package.json +14 -14
  27. package/src/Call.ts +27 -12
  28. package/src/devices/CameraManager.ts +9 -2
  29. package/src/devices/DeviceManager.ts +148 -8
  30. package/src/devices/DeviceManagerState.ts +4 -1
  31. package/src/devices/VirtualDevice.ts +69 -0
  32. package/src/devices/__tests__/CameraManager.test.ts +22 -1
  33. package/src/devices/__tests__/DeviceManager.test.ts +124 -2
  34. package/src/devices/__tests__/MicrophoneManager.test.ts +3 -1
  35. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +3 -1
  36. package/src/devices/__tests__/ScreenShareManager.test.ts +3 -1
  37. package/src/devices/__tests__/web-audio.mocks.ts +3 -1
  38. package/src/devices/devicePersistence.ts +2 -1
  39. package/src/devices/index.ts +1 -0
  40. package/src/gen/video/sfu/event/events.ts +10 -0
  41. package/src/gen/video/sfu/models/models.ts +338 -0
  42. package/src/gen/video/sfu/signal_rpc/signal.client.ts +28 -2
  43. package/src/gen/video/sfu/signal_rpc/signal.ts +121 -15
  44. package/src/helpers/__tests__/DynascaleManager.test.ts +8 -7
  45. package/src/helpers/__tests__/browsers.test.ts +4 -4
  46. package/src/helpers/__tests__/participantUtils.test.ts +47 -0
  47. package/src/helpers/client-details.ts +4 -1
  48. package/src/helpers/participantUtils.ts +15 -0
  49. package/src/rtc/BasePeerConnection.ts +22 -4
  50. package/src/rtc/Publisher.ts +140 -41
  51. package/src/rtc/Subscriber.ts +1 -0
  52. package/src/rtc/TransceiverCache.ts +10 -3
  53. package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
  54. package/src/rtc/__tests__/Publisher.test.ts +659 -112
  55. package/src/rtc/__tests__/Subscriber.test.ts +7 -3
  56. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +16 -15
  57. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +33 -1
  58. package/src/rtc/helpers/degradationPreference.ts +18 -0
  59. package/src/rtc/types.ts +3 -0
  60. package/src/stats/rtc/StatsTracer.ts +25 -4
  61. package/src/stats/rtc/__tests__/StatsTracer.test.ts +155 -0
@@ -1,23 +1,24 @@
1
1
  // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
2
2
  // @generated from protobuf file "video/sfu/signal_rpc/signal.proto" (package "stream.video.sfu.signal", syntax proto3)
3
3
  // tslint:disable
4
- import {
5
- AndroidState,
6
- AppleState,
7
- Error,
8
- ICETrickle,
9
- InputDevices,
10
- PeerType,
11
- PerformanceStats,
12
- RTMPIngress,
13
- TrackInfo,
14
- TrackType,
15
- VideoDimension,
16
- WebsocketReconnectStrategy,
17
- } from '../models/models';
4
+ import { ICETrickle } from '../models/models';
18
5
  import { ServiceType } from '@protobuf-ts/runtime-rpc';
19
6
  import { MessageType } from '@protobuf-ts/runtime';
20
-
7
+ import { TrackInfo } from '../models/models';
8
+ import { VideoDimension } from '../models/models';
9
+ import { TrackType } from '../models/models';
10
+ import { PeerType } from '../models/models';
11
+ import { PerformanceStats } from '../models/models';
12
+ import { RTMPIngress } from '../models/models';
13
+ import { AppleState } from '../models/models';
14
+ import { AndroidState } from '../models/models';
15
+ import { InputDevices } from '../models/models';
16
+ import { RemoteOutboundRtp } from '../models/models';
17
+ import { RemoteInboundRtp } from '../models/models';
18
+ import { OutboundRtp } from '../models/models';
19
+ import { InboundRtp } from '../models/models';
20
+ import { WebsocketReconnectStrategy } from '../models/models';
21
+ import { Error } from '../models/models';
21
22
  /**
22
23
  * @generated from protobuf message stream.video.sfu.signal.StartNoiseCancellationRequest
23
24
  */
@@ -93,6 +94,39 @@ export interface Telemetry {
93
94
  oneofKind: undefined;
94
95
  };
95
96
  }
97
+ /**
98
+ * @generated from protobuf message stream.video.sfu.signal.SendMetricsRequest
99
+ */
100
+ export interface SendMetricsRequest {
101
+ /**
102
+ * @generated from protobuf field: string session_id = 1;
103
+ */
104
+ sessionId: string;
105
+ /**
106
+ * @generated from protobuf field: string unified_session_id = 2;
107
+ */
108
+ unifiedSessionId: string;
109
+ /**
110
+ * @generated from protobuf field: repeated stream.video.sfu.models.InboundRtp inbounds = 3;
111
+ */
112
+ inbounds: InboundRtp[];
113
+ /**
114
+ * @generated from protobuf field: repeated stream.video.sfu.models.OutboundRtp outbounds = 4;
115
+ */
116
+ outbounds: OutboundRtp[];
117
+ /**
118
+ * @generated from protobuf field: repeated stream.video.sfu.models.RemoteInboundRtp remote_inbounds = 5;
119
+ */
120
+ remoteInbounds: RemoteInboundRtp[];
121
+ /**
122
+ * @generated from protobuf field: repeated stream.video.sfu.models.RemoteOutboundRtp remote_outbounds = 6;
123
+ */
124
+ remoteOutbounds: RemoteOutboundRtp[];
125
+ }
126
+ /**
127
+ * @generated from protobuf message stream.video.sfu.signal.SendMetricsResponse
128
+ */
129
+ export interface SendMetricsResponse {}
96
130
  /**
97
131
  * @generated from protobuf message stream.video.sfu.signal.SendStatsRequest
98
132
  */
@@ -336,6 +370,10 @@ export interface SendAnswerRequest {
336
370
  * @generated from protobuf field: string session_id = 3;
337
371
  */
338
372
  sessionId: string;
373
+ /**
374
+ * @generated from protobuf field: uint32 negotiation_id = 4;
375
+ */
376
+ negotiationId: number;
339
377
  }
340
378
  /**
341
379
  * @generated from protobuf message stream.video.sfu.signal.SendAnswerResponse
@@ -502,6 +540,62 @@ class Telemetry$Type extends MessageType<Telemetry> {
502
540
  */
503
541
  export const Telemetry = new Telemetry$Type();
504
542
  // @generated message type with reflection information, may provide speed optimized methods
543
+ class SendMetricsRequest$Type extends MessageType<SendMetricsRequest> {
544
+ constructor() {
545
+ super('stream.video.sfu.signal.SendMetricsRequest', [
546
+ { no: 1, name: 'session_id', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
547
+ {
548
+ no: 2,
549
+ name: 'unified_session_id',
550
+ kind: 'scalar',
551
+ T: 9 /*ScalarType.STRING*/,
552
+ },
553
+ {
554
+ no: 3,
555
+ name: 'inbounds',
556
+ kind: 'message',
557
+ repeat: 2 /*RepeatType.UNPACKED*/,
558
+ T: () => InboundRtp,
559
+ },
560
+ {
561
+ no: 4,
562
+ name: 'outbounds',
563
+ kind: 'message',
564
+ repeat: 2 /*RepeatType.UNPACKED*/,
565
+ T: () => OutboundRtp,
566
+ },
567
+ {
568
+ no: 5,
569
+ name: 'remote_inbounds',
570
+ kind: 'message',
571
+ repeat: 2 /*RepeatType.UNPACKED*/,
572
+ T: () => RemoteInboundRtp,
573
+ },
574
+ {
575
+ no: 6,
576
+ name: 'remote_outbounds',
577
+ kind: 'message',
578
+ repeat: 2 /*RepeatType.UNPACKED*/,
579
+ T: () => RemoteOutboundRtp,
580
+ },
581
+ ]);
582
+ }
583
+ }
584
+ /**
585
+ * @generated MessageType for protobuf message stream.video.sfu.signal.SendMetricsRequest
586
+ */
587
+ export const SendMetricsRequest = new SendMetricsRequest$Type();
588
+ // @generated message type with reflection information, may provide speed optimized methods
589
+ class SendMetricsResponse$Type extends MessageType<SendMetricsResponse> {
590
+ constructor() {
591
+ super('stream.video.sfu.signal.SendMetricsResponse', []);
592
+ }
593
+ }
594
+ /**
595
+ * @generated MessageType for protobuf message stream.video.sfu.signal.SendMetricsResponse
596
+ */
597
+ export const SendMetricsResponse = new SendMetricsResponse$Type();
598
+ // @generated message type with reflection information, may provide speed optimized methods
505
599
  class SendStatsRequest$Type extends MessageType<SendStatsRequest> {
506
600
  constructor() {
507
601
  super('stream.video.sfu.signal.SendStatsRequest', [
@@ -776,6 +870,12 @@ class SendAnswerRequest$Type extends MessageType<SendAnswerRequest> {
776
870
  },
777
871
  { no: 2, name: 'sdp', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
778
872
  { no: 3, name: 'session_id', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
873
+ {
874
+ no: 4,
875
+ name: 'negotiation_id',
876
+ kind: 'scalar',
877
+ T: 13 /*ScalarType.UINT32*/,
878
+ },
779
879
  ]);
780
880
  }
781
881
  }
@@ -885,6 +985,12 @@ export const SignalServer = new ServiceType(
885
985
  I: SendStatsRequest,
886
986
  O: SendStatsResponse,
887
987
  },
988
+ {
989
+ name: 'SendMetrics',
990
+ options: {},
991
+ I: SendMetricsRequest,
992
+ O: SendMetricsResponse,
993
+ },
888
994
  {
889
995
  name: 'StartNoiseCancellation',
890
996
  options: {},
@@ -23,6 +23,14 @@ import { VisibilityState } from '../../types';
23
23
  import { noopComparator } from '../../sorting';
24
24
  import { TrackType } from '../../gen/video/sfu/models/models';
25
25
 
26
+ vi.mock(import('../browsers'), async (importOriginal) => {
27
+ const module = await importOriginal();
28
+ return {
29
+ ...module,
30
+ isSafari: () => globalThis._isSafari ?? false,
31
+ };
32
+ });
33
+
26
34
  describe('DynascaleManager', () => {
27
35
  let dynascaleManager: DynascaleManager;
28
36
  let call: Call;
@@ -50,13 +58,6 @@ describe('DynascaleManager', () => {
50
58
  beforeEach(() => {
51
59
  // Mock global isSafari to false for testing
52
60
  globalThis._isSafari = false;
53
- vi.mock(import('../browsers'), async (importOriginal) => {
54
- const module = await importOriginal();
55
- return {
56
- ...module,
57
- isSafari: () => globalThis._isSafari ?? false,
58
- };
59
- });
60
61
 
61
62
  dynascaleManager.setUseWebAudio(false);
62
63
 
@@ -9,6 +9,10 @@ import {
9
9
  import { getClientDetails } from '../client-details';
10
10
  import { ClientDetails } from '../../gen/video/sfu/models/models';
11
11
 
12
+ vi.mock('../client-details', () => ({
13
+ getClientDetails: vi.fn(),
14
+ }));
15
+
12
16
  describe('browsers', () => {
13
17
  beforeEach(() => {
14
18
  Object.defineProperty(globalThis, 'navigator', {
@@ -156,10 +160,6 @@ describe('browsers', () => {
156
160
  });
157
161
 
158
162
  describe('isSupportedBrowser', () => {
159
- vi.mock('../client-details', () => ({
160
- getClientDetails: vi.fn(),
161
- }));
162
-
163
163
  it('should return false if browser is undefined', async () => {
164
164
  vi.mocked(getClientDetails).mockResolvedValue({
165
165
  browser: undefined,
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  hasAudio,
4
+ hasInterruptedTrack,
4
5
  hasPausedTrack,
5
6
  hasScreenShare,
6
7
  hasScreenShareAudio,
@@ -120,6 +121,52 @@ describe('participantUtils', () => {
120
121
  });
121
122
  });
122
123
 
124
+ describe('hasInterruptedTrack', () => {
125
+ it('returns true when the track is both interrupted and published', () => {
126
+ const participant = createMockParticipant({
127
+ publishedTracks: [TrackType.AUDIO],
128
+ interruptedTracks: [TrackType.AUDIO],
129
+ });
130
+ expect(hasInterruptedTrack(participant, TrackType.AUDIO)).toBe(true);
131
+ });
132
+
133
+ it('returns false when the track is interrupted but no longer published', () => {
134
+ const participant = createMockParticipant({
135
+ publishedTracks: [],
136
+ interruptedTracks: [TrackType.AUDIO],
137
+ });
138
+ expect(hasInterruptedTrack(participant, TrackType.AUDIO)).toBe(false);
139
+ });
140
+
141
+ it('returns false when the track is published but not interrupted', () => {
142
+ const participant = createMockParticipant({
143
+ publishedTracks: [TrackType.AUDIO],
144
+ interruptedTracks: [],
145
+ });
146
+ expect(hasInterruptedTrack(participant, TrackType.AUDIO)).toBe(false);
147
+ });
148
+
149
+ it('returns false when interruptedTracks is undefined', () => {
150
+ const participant = createMockParticipant({
151
+ publishedTracks: [TrackType.AUDIO],
152
+ interruptedTracks: undefined,
153
+ });
154
+ expect(hasInterruptedTrack(participant, TrackType.AUDIO)).toBe(false);
155
+ });
156
+
157
+ it('checks each track type independently', () => {
158
+ const participant = createMockParticipant({
159
+ publishedTracks: [TrackType.AUDIO, TrackType.VIDEO],
160
+ interruptedTracks: [TrackType.VIDEO],
161
+ });
162
+ expect(hasInterruptedTrack(participant, TrackType.AUDIO)).toBe(false);
163
+ expect(hasInterruptedTrack(participant, TrackType.VIDEO)).toBe(true);
164
+ expect(hasInterruptedTrack(participant, TrackType.SCREEN_SHARE)).toBe(
165
+ false,
166
+ );
167
+ });
168
+ });
169
+
123
170
  describe('hasPausedTrack', () => {
124
171
  it('should return true when participant has paused VIDEO track', () => {
125
172
  const participant = createMockParticipant({
@@ -137,6 +137,7 @@ export const getClientDetails = async (): Promise<ClientDetails> => {
137
137
  sdk: sdkInfo,
138
138
  os: osInfo,
139
139
  device: deviceInfo,
140
+ webrtcVersion: webRtcInfo?.version || '',
140
141
  };
141
142
  }
142
143
 
@@ -173,11 +174,12 @@ export const getClientDetails = async (): Promise<ClientDetails> => {
173
174
  const uaBrowser = userAgentData?.fullVersionList?.find(
174
175
  (v) => !v.brand.includes('Chromium') && !v.brand.match(/[()\-./:;=?_]/g),
175
176
  );
177
+ const browserVersion = uaBrowser?.version || browser.version || '';
176
178
  return {
177
179
  sdk: sdkInfo,
178
180
  browser: {
179
181
  name: uaBrowser?.brand || browser.name || navigator.userAgent,
180
- version: uaBrowser?.version || browser.version || '',
182
+ version: browserVersion,
181
183
  },
182
184
  os: {
183
185
  name: userAgentData?.platform || os.name || '',
@@ -190,5 +192,6 @@ export const getClientDetails = async (): Promise<ClientDetails> => {
190
192
  .join(' '),
191
193
  version: '',
192
194
  },
195
+ webrtcVersion: browserVersion,
193
196
  };
194
197
  };
@@ -41,6 +41,21 @@ export const hasScreenShareAudio = (p: StreamVideoParticipant): boolean =>
41
41
  export const isPinned = (p: StreamVideoParticipant): boolean =>
42
42
  !!p.pin && (p.pin.isLocalPin || p.pin.pinnedAt > 0);
43
43
 
44
+ /**
45
+ * Check if a participant has a track that is currently interrupted: the
46
+ * participant intends to publish it (it is in `publishedTracks`) but no
47
+ * media is flowing right now (it is in `interruptedTracks`).
48
+ *
49
+ * @param p the participant to check.
50
+ * @param trackType the track type to check.
51
+ */
52
+ export const hasInterruptedTrack = (
53
+ p: StreamVideoParticipant,
54
+ trackType: TrackType,
55
+ ): boolean =>
56
+ !!p.interruptedTracks?.includes(trackType) &&
57
+ p.publishedTracks.includes(trackType);
58
+
44
59
  /**
45
60
  * Check if a participant has a paused track of the specified type.
46
61
  *
@@ -42,7 +42,7 @@ export abstract class BasePeerConnection {
42
42
  private iceRestartTimeout?: NodeJS.Timeout;
43
43
  private preConnectStuckTimeout?: NodeJS.Timeout;
44
44
  protected isIceRestarting = false;
45
- private isDisposed = false;
45
+ protected isDisposed = false;
46
46
 
47
47
  protected trackIdToTrackType = new Map<string, TrackType>();
48
48
 
@@ -69,6 +69,7 @@ export abstract class BasePeerConnection {
69
69
  enableTracing,
70
70
  clientPublishOptions,
71
71
  iceRestartDelay = 2500,
72
+ statsTimestampDriftThresholdMs = 0,
72
73
  }: BasePeerConnectionOpts,
73
74
  ) {
74
75
  this.peerType = peerType;
@@ -85,7 +86,12 @@ export abstract class BasePeerConnection {
85
86
  { tags: [tag] },
86
87
  );
87
88
  this.pc = this.createPeerConnection(connectionConfig);
88
- this.stats = new StatsTracer(this.pc, peerType, this.trackIdToTrackType);
89
+ this.stats = new StatsTracer(
90
+ this.pc,
91
+ peerType,
92
+ this.trackIdToTrackType,
93
+ statsTimestampDriftThresholdMs,
94
+ );
89
95
  if (enableTracing) {
90
96
  this.tracer = new Tracer(
91
97
  `${tag}-${peerType === PeerType.SUBSCRIBER ? 'sub' : 'pub'}`,
@@ -115,7 +121,7 @@ export abstract class BasePeerConnection {
115
121
  /**
116
122
  * Disposes the `RTCPeerConnection` instance.
117
123
  */
118
- dispose() {
124
+ async dispose(): Promise<void> {
119
125
  clearTimeout(this.iceRestartTimeout);
120
126
  this.iceRestartTimeout = undefined;
121
127
  clearTimeout(this.preConnectStuckTimeout);
@@ -141,6 +147,10 @@ export abstract class BasePeerConnection {
141
147
  this.onIceConnectionStateChange,
142
148
  );
143
149
  pc.removeEventListener('icegatheringstatechange', this.onIceGatherChange);
150
+ pc.removeEventListener(
151
+ 'connectionstatechange',
152
+ this.onConnectionStateChange,
153
+ );
144
154
  this.unsubscribeIceTrickle?.();
145
155
  this.subscriptions.forEach((unsubscribe) => unsubscribe());
146
156
  this.subscriptions = [];
@@ -183,7 +193,7 @@ export abstract class BasePeerConnection {
183
193
  const getTag = () => this.tag;
184
194
  this.subscriptions.push(
185
195
  this.dispatcher.on(event, getTag, (e) => {
186
- const lockKey = `pc.${this.lock}.${event}`;
196
+ const lockKey = this.eventLockKey(event);
187
197
  withoutConcurrency(lockKey, async () => fn(e)).catch((err) => {
188
198
  if (this.isDisposed) return;
189
199
  this.logger.warn(`Error handling ${event}`, err);
@@ -192,6 +202,14 @@ export abstract class BasePeerConnection {
192
202
  );
193
203
  };
194
204
 
205
+ /**
206
+ * Returns the per-event `withoutConcurrency` tag used to serialize the
207
+ * dispatcher handler for `event` on this peer connection.
208
+ */
209
+ protected eventLockKey = (event: keyof AllSfuEvents): string => {
210
+ return `pc.${this.lock}.${event}`;
211
+ };
212
+
195
213
  /**
196
214
  * Appends the trickled ICE candidates to the `RTCPeerConnection`.
197
215
  */
@@ -20,11 +20,15 @@ import {
20
20
  toVideoLayers,
21
21
  } from './layers';
22
22
  import { isSvcCodec } from './codecs';
23
- import { toRTCDegradationPreference } from './helpers/degradationPreference';
23
+ import {
24
+ fromRTCDegradationPreference,
25
+ toRTCDegradationPreference,
26
+ } from './helpers/degradationPreference';
24
27
  import { isAudioTrackType } from './helpers/tracks';
25
28
  import { extractMid, removeCodecsExcept, setStartBitrate } from './helpers/sdp';
26
29
  import { withoutConcurrency } from '../helpers/concurrency';
27
30
  import { isReactNative } from '../helpers/platforms';
31
+ import { isFirefox } from '../helpers/browsers';
28
32
 
29
33
  /**
30
34
  * The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
@@ -53,7 +57,16 @@ export class Publisher extends BasePeerConnection {
53
57
 
54
58
  this.on('changePublishQuality', async (event) => {
55
59
  for (const videoSender of event.videoSenders) {
56
- await this.changePublishQuality(videoSender);
60
+ // if not publishing, update the encodingConfigCache and don't modify the state.
61
+ // we'll apply this config on the next publish/unmute.
62
+ const { trackType, publishOptionId } = videoSender;
63
+ const bundle = this.transceiverCache.getBy(publishOptionId, trackType);
64
+ if (bundle) {
65
+ this.transceiverCache.update(bundle.publishOption, { videoSender });
66
+ }
67
+ if (isFirefox() && !this.isPublishing(trackType)) continue;
68
+
69
+ await this.changePublishQuality(videoSender, bundle);
57
70
  }
58
71
  });
59
72
 
@@ -66,9 +79,13 @@ export class Publisher extends BasePeerConnection {
66
79
  /**
67
80
  * Disposes this Publisher instance.
68
81
  */
69
- dispose() {
70
- super.dispose();
71
- this.stopAllTracks();
82
+ async dispose(): Promise<void> {
83
+ await super.dispose();
84
+ try {
85
+ await this.stopAllTracks();
86
+ } catch (err) {
87
+ this.logger.warn('Failed to stop tracks during dispose', err);
88
+ }
72
89
  this.clonedTracks.clear();
73
90
  }
74
91
 
@@ -98,17 +115,12 @@ export class Publisher extends BasePeerConnection {
98
115
  // appear in the SDP in multiple transceivers
99
116
  const trackToPublish = this.cloneTrack(track);
100
117
 
101
- const { transceiver } = this.transceiverCache.get(publishOption) || {};
102
- if (!transceiver) {
118
+ const bundle = this.transceiverCache.get(publishOption);
119
+ if (!bundle) {
103
120
  await this.addTransceiver(trackToPublish, publishOption, options);
104
121
  } else {
105
- const previousTrack = transceiver.sender.track;
106
- await this.updateTransceiver(
107
- transceiver,
108
- trackToPublish,
109
- trackType,
110
- options,
111
- );
122
+ const previousTrack = bundle.transceiver.sender.track;
123
+ await this.updateTransceiver(bundle, trackToPublish, options);
112
124
  if (!isReactNative()) {
113
125
  this.stopTrack(previousTrack);
114
126
  }
@@ -153,15 +165,22 @@ export class Publisher extends BasePeerConnection {
153
165
  * Updates the transceiver with the given track and track type.
154
166
  */
155
167
  private updateTransceiver = async (
156
- transceiver: RTCRtpTransceiver,
168
+ bundle: PublishBundle,
157
169
  track: MediaStreamTrack | null,
158
- trackType: TrackType,
159
170
  options: TrackPublishOptions = {},
160
171
  ) => {
172
+ const { transceiver, publishOption } = bundle;
173
+ const trackType = publishOption.trackType;
161
174
  const sender = transceiver.sender;
162
175
  if (sender.track) this.trackIdToTrackType.delete(sender.track.id);
163
176
  await sender.replaceTrack(track);
164
- if (track) this.trackIdToTrackType.set(track.id, trackType);
177
+ if (track) {
178
+ this.trackIdToTrackType.set(track.id, trackType);
179
+ if (isFirefox() && bundle.videoSender) {
180
+ // restore the encoding config from the cache, if any
181
+ await this.changePublishQuality(bundle.videoSender, bundle);
182
+ }
183
+ }
165
184
  if (isAudioTrackType(trackType)) {
166
185
  await this.updateAudioPublishOptions(trackType, options);
167
186
  }
@@ -230,7 +249,7 @@ export class Publisher extends BasePeerConnection {
230
249
  if (hasPublishOption) continue;
231
250
  // it is safe to stop the track here, it is a clone
232
251
  this.stopTrack(transceiver.sender.track);
233
- await this.updateTransceiver(transceiver, null, publishOption.trackType);
252
+ await this.updateTransceiver(item, null);
234
253
  }
235
254
  };
236
255
 
@@ -286,39 +305,50 @@ export class Publisher extends BasePeerConnection {
286
305
  /**
287
306
  * Stops the cloned track that is being published to the SFU.
288
307
  */
289
- stopTracks = (...trackTypes: TrackType[]) => {
290
- for (const item of this.transceiverCache.items()) {
291
- const { publishOption, transceiver } = item;
292
- if (!trackTypes.includes(publishOption.trackType)) continue;
293
- this.stopTrack(transceiver.sender.track);
294
- }
308
+ stopTracks = async (...trackTypes: TrackType[]) => {
309
+ return withoutConcurrency(
310
+ this.eventLockKey('changePublishQuality'),
311
+ async () => {
312
+ for (const item of this.transceiverCache.items()) {
313
+ const { publishOption, transceiver } = item;
314
+ if (!trackTypes.includes(publishOption.trackType)) continue;
315
+ const track = transceiver.sender.track;
316
+ await this.silenceSenderOnFirefox(item);
317
+ this.stopTrack(track);
318
+ }
319
+ },
320
+ );
295
321
  };
296
322
 
297
323
  /**
298
324
  * Stops all the cloned tracks that are being published to the SFU.
299
325
  */
300
- stopAllTracks = () => {
301
- for (const { transceiver } of this.transceiverCache.items()) {
302
- this.stopTrack(transceiver.sender.track);
303
- }
304
- for (const track of this.clonedTracks) {
305
- this.stopTrack(track);
306
- }
326
+ stopAllTracks = async () => {
327
+ return withoutConcurrency(
328
+ this.eventLockKey('changePublishQuality'),
329
+ async () => {
330
+ for (const item of this.transceiverCache.items()) {
331
+ const track = item.transceiver.sender.track;
332
+ await this.silenceSenderOnFirefox(item);
333
+ this.stopTrack(track);
334
+ }
335
+ for (const track of this.clonedTracks) {
336
+ this.stopTrack(track);
337
+ }
338
+ },
339
+ );
307
340
  };
308
341
 
309
- private changePublishQuality = async (videoSender: VideoSender) => {
310
- const { trackType, layers, publishOptionId } = videoSender;
311
- const enabledLayers = layers.filter((l) => l.active);
342
+ private changePublishQuality = async (
343
+ videoSender: VideoSender,
344
+ bundle: PublishBundle | undefined,
345
+ ) => {
346
+ const enabledLayers = videoSender.layers.filter((l) => l.active);
312
347
 
313
348
  const tag = 'Update publish quality:';
314
349
  this.logger.info(`${tag} requested layers by SFU:`, enabledLayers);
315
350
 
316
- const transceiverId = this.transceiverCache.find(
317
- (t) =>
318
- t.publishOption.id === publishOptionId &&
319
- t.publishOption.trackType === trackType,
320
- );
321
- const sender = transceiverId?.transceiver.sender;
351
+ const sender = bundle?.transceiver.sender;
322
352
  if (!sender) {
323
353
  return this.logger.warn(`${tag} no video sender found.`);
324
354
  }
@@ -328,7 +358,7 @@ export class Publisher extends BasePeerConnection {
328
358
  return this.logger.warn(`${tag} there are no encodings set.`);
329
359
  }
330
360
 
331
- const codecInUse = transceiverId?.publishOption.codec?.name;
361
+ const codecInUse = bundle?.publishOption.codec?.name;
332
362
  const usesSvcCodec = codecInUse && isSvcCodec(codecInUse);
333
363
 
334
364
  let changed = false;
@@ -560,4 +590,73 @@ export class Publisher extends BasePeerConnection {
560
590
  track.stop();
561
591
  this.clonedTracks.delete(track);
562
592
  };
593
+
594
+ /**
595
+ * Silences a Firefox sender on the wire during unpublish.
596
+ *
597
+ * Firefox keeps emitting RTP after track.stop(), but the right lever
598
+ * differs by track type:
599
+ * - audio: `replaceTrack(null)` is the only reliable silencer;
600
+ * `setParameters({encodings:[...active:false]})` does NOT stop
601
+ * the Opus encoder.
602
+ * - video: `setParameters({encodings:[...active:false]})` pauses
603
+ * the encoder; `replaceTrack(null)` does NOT reliably stop the
604
+ * video encoder. The prior active=true configuration is captured
605
+ * onto `bundle.videoSender` so `updateTransceiver` can restore
606
+ * it on the next publish.
607
+ *
608
+ * No-op on non-Firefox browsers and during teardown.
609
+ */
610
+ private silenceSenderOnFirefox = async (bundle: PublishBundle) => {
611
+ if (this.isDisposed || !isFirefox()) return;
612
+ const { transceiver, publishOption } = bundle;
613
+ if (isAudioTrackType(publishOption.trackType)) {
614
+ await transceiver.sender.replaceTrack(null).catch((err) => {
615
+ this.logger.warn('Failed to clear audio sender track', err);
616
+ });
617
+ return;
618
+ }
619
+ await this.disableAllEncodings(bundle);
620
+ };
621
+
622
+ private disableAllEncodings = async (bundle: PublishBundle) => {
623
+ const { transceiver, publishOption } = bundle;
624
+ const sender = transceiver.sender;
625
+ const params = sender.getParameters();
626
+ if (!params.encodings || params.encodings.length === 0) return;
627
+
628
+ if (!bundle.videoSender) {
629
+ this.transceiverCache.update(publishOption, {
630
+ videoSender: {
631
+ trackType: publishOption.trackType,
632
+ publishOptionId: publishOption.id,
633
+ codec: publishOption.codec,
634
+ degradationPreference: fromRTCDegradationPreference(
635
+ params.degradationPreference,
636
+ ),
637
+ layers: params.encodings.map((e) => ({
638
+ name: e.rid ?? 'q',
639
+ active: e.active ?? true,
640
+ maxBitrate: e.maxBitrate ?? 0,
641
+ scaleResolutionDownBy: e.scaleResolutionDownBy ?? 0,
642
+ maxFramerate: e.maxFramerate ?? 0,
643
+ // @ts-expect-error scalabilityMode is not in the typedefs yet
644
+ scalabilityMode: e.scalabilityMode ?? '',
645
+ })),
646
+ },
647
+ });
648
+ }
649
+
650
+ let changed = false;
651
+ for (const encoding of params.encodings) {
652
+ if (encoding.active !== false) {
653
+ encoding.active = false;
654
+ changed = true;
655
+ }
656
+ }
657
+ if (!changed) return;
658
+ await sender.setParameters(params).catch((err) => {
659
+ this.logger.error('Failed to disable video sender encodings:', err);
660
+ });
661
+ };
563
662
  }
@@ -196,6 +196,7 @@ export class Subscriber extends BasePeerConnection {
196
196
  await this.sfuClient.sendAnswer({
197
197
  peerType: PeerType.SUBSCRIBER,
198
198
  sdp: answer.sdp || '',
199
+ negotiationId: subscriberOffer.negotiationId,
199
200
  });
200
201
 
201
202
  this.isIceRestarting = false;