@stream-io/video-client 0.3.13 → 0.3.14

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.
@@ -28,6 +28,5 @@ export declare class CameraManager extends InputMediaDeviceManager<CameraManager
28
28
  protected getStream(constraints: MediaTrackConstraints): Promise<MediaStream>;
29
29
  protected publishStream(stream: MediaStream): Promise<void>;
30
30
  protected stopPublishStream(stopTracks: boolean): Promise<void>;
31
- protected muteTracks(): void;
32
- protected unmuteTracks(): void;
31
+ protected getTrack(): MediaStreamTrack | undefined;
33
32
  }
@@ -1,9 +1,12 @@
1
1
  import { Observable } from 'rxjs';
2
2
  import { Call } from '../Call';
3
3
  import { InputMediaDeviceManagerState } from './InputMediaDeviceManagerState';
4
+ import { Logger } from '../coordinator/connection/types';
5
+ import { TrackType } from '../gen/video/sfu/models/models';
4
6
  export declare abstract class InputMediaDeviceManager<T extends InputMediaDeviceManagerState> {
5
7
  protected readonly call: Call;
6
8
  readonly state: T;
9
+ protected readonly trackType: TrackType;
7
10
  /**
8
11
  * @internal
9
12
  */
@@ -12,7 +15,8 @@ export declare abstract class InputMediaDeviceManager<T extends InputMediaDevice
12
15
  * @internal
13
16
  */
14
17
  disablePromise?: Promise<void>;
15
- constructor(call: Call, state: T);
18
+ logger: Logger;
19
+ constructor(call: Call, state: T, trackType: TrackType);
16
20
  /**
17
21
  * Lists the available audio/video devices
18
22
  *
@@ -54,8 +58,11 @@ export declare abstract class InputMediaDeviceManager<T extends InputMediaDevice
54
58
  protected abstract getStream(constraints: MediaTrackConstraints): Promise<MediaStream>;
55
59
  protected abstract publishStream(stream: MediaStream): Promise<void>;
56
60
  protected abstract stopPublishStream(stopTracks: boolean): Promise<void>;
57
- protected abstract muteTracks(): void;
58
- protected abstract unmuteTracks(): void;
61
+ protected abstract getTrack(): undefined | MediaStreamTrack;
59
62
  private muteStream;
63
+ private muteTrack;
64
+ private unmuteTrack;
65
+ private stopTrack;
66
+ private muteLocalStream;
60
67
  private unmuteStream;
61
68
  }
@@ -8,6 +8,5 @@ export declare class MicrophoneManager extends InputMediaDeviceManager<Microphon
8
8
  protected getStream(constraints: MediaTrackConstraints): Promise<MediaStream>;
9
9
  protected publishStream(stream: MediaStream): Promise<void>;
10
10
  protected stopPublishStream(stopTracks: boolean): Promise<void>;
11
- protected muteTracks(): void;
12
- protected unmuteTracks(): void;
11
+ protected getTrack(): MediaStreamTrack | undefined;
13
12
  }
@@ -85,6 +85,12 @@ export declare class Publisher {
85
85
  * @param trackType the track type to check.
86
86
  */
87
87
  isPublishing: (trackType: TrackType) => boolean;
88
+ /**
89
+ * Returns true if the given track type is currently live
90
+ *
91
+ * @param trackType the track type to check.
92
+ */
93
+ isLive: (trackType: TrackType) => boolean;
88
94
  private notifyTrackMuteStateChanged;
89
95
  /**
90
96
  * Stops publishing all tracks and stop all tracks.
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const version = "0.3.13";
1
+ export declare const version = "0.3.14";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
4
  "packageManager": "yarn@3.2.4",
5
5
  "main": "dist/index.cjs.js",
6
6
  "module": "dist/index.es.js",
package/src/Call.ts CHANGED
@@ -277,6 +277,37 @@ export class Call {
277
277
 
278
278
  this.camera = new CameraManager(this);
279
279
  this.microphone = new MicrophoneManager(this);
280
+
281
+ this.state.localParticipant$.subscribe(async (p) => {
282
+ // Mute via device manager
283
+ // If integrator doesn't use device manager, we mute using stopPublish
284
+ if (
285
+ !p?.publishedTracks.includes(TrackType.VIDEO) &&
286
+ this.publisher?.isPublishing(TrackType.VIDEO)
287
+ ) {
288
+ this.logger(
289
+ 'info',
290
+ `Local participant's video track is muted remotely`,
291
+ );
292
+ await this.camera.disable();
293
+ if (this.publisher.isPublishing(TrackType.VIDEO)) {
294
+ this.stopPublish(TrackType.VIDEO);
295
+ }
296
+ }
297
+ if (
298
+ !p?.publishedTracks.includes(TrackType.AUDIO) &&
299
+ this.publisher?.isPublishing(TrackType.AUDIO)
300
+ ) {
301
+ this.logger(
302
+ 'info',
303
+ `Local participant's audio track is muted remotely`,
304
+ );
305
+ await this.microphone.disable();
306
+ if (this.publisher.isPublishing(TrackType.AUDIO)) {
307
+ this.stopPublish(TrackType.AUDIO);
308
+ }
309
+ }
310
+ });
280
311
  this.speaker = new SpeakerManager();
281
312
  }
282
313
 
@@ -309,10 +340,50 @@ export class Call {
309
340
  const hasPermission = this.permissionsContext.hasPermission(
310
341
  permission as OwnCapability,
311
342
  );
312
- if (!hasPermission && this.publisher.isPublishing(trackType)) {
313
- this.stopPublish(trackType).catch((err) => {
314
- this.logger('error', `Error stopping publish ${trackType}`, err);
315
- });
343
+ if (
344
+ !hasPermission &&
345
+ (this.publisher.isPublishing(trackType) ||
346
+ this.publisher.isLive(trackType))
347
+ ) {
348
+ // Stop tracks, then notify device manager
349
+ this.stopPublish(trackType)
350
+ .catch((err) => {
351
+ this.logger(
352
+ 'error',
353
+ `Error stopping publish ${trackType}`,
354
+ err,
355
+ );
356
+ })
357
+ .then(() => {
358
+ if (
359
+ trackType === TrackType.VIDEO &&
360
+ this.camera.state.status === 'enabled'
361
+ ) {
362
+ this.camera
363
+ .disable()
364
+ .catch((err) =>
365
+ this.logger(
366
+ 'error',
367
+ `Error disabling camera after pemission revoked`,
368
+ err,
369
+ ),
370
+ );
371
+ }
372
+ if (
373
+ trackType === TrackType.AUDIO &&
374
+ this.microphone.state.status === 'enabled'
375
+ ) {
376
+ this.microphone
377
+ .disable()
378
+ .catch((err) =>
379
+ this.logger(
380
+ 'error',
381
+ `Error disabling microphone after pemission revoked`,
382
+ err,
383
+ ),
384
+ );
385
+ }
386
+ });
316
387
  }
317
388
  }
318
389
  }),
@@ -1112,7 +1183,10 @@ export class Call {
1112
1183
  * @param stopTrack if `true` the track will be stopped, else it will be just disabled
1113
1184
  */
1114
1185
  stopPublish = async (trackType: TrackType, stopTrack: boolean = true) => {
1115
- this.logger('info', `stopPublish ${TrackType[trackType]}`);
1186
+ this.logger(
1187
+ 'info',
1188
+ `stopPublish ${TrackType[trackType]}, stop tracks: ${stopTrack}`,
1189
+ );
1116
1190
  await this.publisher?.unpublishStream(trackType, stopTrack);
1117
1191
  };
1118
1192
 
@@ -396,5 +396,9 @@ const retryable = async <I extends object, O extends SfuResponseWithError>(
396
396
  retryAttempt < MAX_RETRIES
397
397
  );
398
398
 
399
+ if (rpcCallResult.response.error) {
400
+ throw rpcCallResult.response.error;
401
+ }
402
+
399
403
  return rpcCallResult;
400
404
  };
@@ -5,7 +5,7 @@ const HINT_URL = `https://hint.stream-io-video.com/`;
5
5
 
6
6
  export const getLocationHint = async (
7
7
  hintUrl: string = HINT_URL,
8
- timeout: number = 1500,
8
+ timeout: number = 2000,
9
9
  ) => {
10
10
  const abortController = new AbortController();
11
11
  const timeoutId = setTimeout(() => abortController.abort(), timeout);
@@ -18,7 +18,7 @@ export const getLocationHint = async (
18
18
  logger('debug', `Location header: ${awsPop}`);
19
19
  return awsPop.substring(0, 3); // AMS1-P2 -> AMS
20
20
  } catch (e) {
21
- logger('error', `Failed to get location hint from ${HINT_URL}`, e);
21
+ logger('warn', `Failed to get location hint from ${HINT_URL}`, e);
22
22
  return 'ERR';
23
23
  } finally {
24
24
  clearTimeout(timeoutId);
@@ -12,7 +12,7 @@ export class CameraManager extends InputMediaDeviceManager<CameraManagerState> {
12
12
  };
13
13
 
14
14
  constructor(call: Call) {
15
- super(call, new CameraManagerState());
15
+ super(call, new CameraManagerState(), TrackType.VIDEO);
16
16
  }
17
17
 
18
18
  /**
@@ -59,6 +59,10 @@ export class CameraManager extends InputMediaDeviceManager<CameraManagerState> {
59
59
  height !== this.targetResolution.height
60
60
  )
61
61
  await this.applySettingsToStream();
62
+ this.logger(
63
+ 'debug',
64
+ `${width}x${height} target resolution applied to media stream`,
65
+ );
62
66
  }
63
67
  }
64
68
 
@@ -85,12 +89,7 @@ export class CameraManager extends InputMediaDeviceManager<CameraManagerState> {
85
89
  return this.call.stopPublish(TrackType.VIDEO, stopTracks);
86
90
  }
87
91
 
88
- protected muteTracks(): void {
89
- this.state.mediaStream
90
- ?.getVideoTracks()
91
- .forEach((t) => (t.enabled = false));
92
- }
93
- protected unmuteTracks(): void {
94
- this.state.mediaStream?.getVideoTracks().forEach((t) => (t.enabled = true));
92
+ protected getTrack() {
93
+ return this.state.mediaStream?.getVideoTracks()[0];
95
94
  }
96
95
  }
@@ -2,8 +2,10 @@ import { Observable } from 'rxjs';
2
2
  import { Call } from '../Call';
3
3
  import { CallingState } from '../store';
4
4
  import { InputMediaDeviceManagerState } from './InputMediaDeviceManagerState';
5
- import { disposeOfMediaStream } from './devices';
6
5
  import { isReactNative } from '../helpers/platforms';
6
+ import { Logger } from '../coordinator/connection/types';
7
+ import { getLogger } from '../logger';
8
+ import { TrackType } from '../gen/video/sfu/models/models';
7
9
 
8
10
  export abstract class InputMediaDeviceManager<
9
11
  T extends InputMediaDeviceManagerState,
@@ -16,7 +18,15 @@ export abstract class InputMediaDeviceManager<
16
18
  * @internal
17
19
  */
18
20
  disablePromise?: Promise<void>;
19
- constructor(protected readonly call: Call, public readonly state: T) {}
21
+ logger: Logger;
22
+
23
+ constructor(
24
+ protected readonly call: Call,
25
+ public readonly state: T,
26
+ protected readonly trackType: TrackType,
27
+ ) {
28
+ this.logger = getLogger([`${TrackType[trackType].toLowerCase()} manager`]);
29
+ }
20
30
 
21
31
  /**
22
32
  * Lists the available audio/video devices
@@ -129,32 +139,68 @@ export abstract class InputMediaDeviceManager<
129
139
 
130
140
  protected abstract stopPublishStream(stopTracks: boolean): Promise<void>;
131
141
 
132
- protected abstract muteTracks(): void;
133
-
134
- protected abstract unmuteTracks(): void;
142
+ protected abstract getTrack(): undefined | MediaStreamTrack;
135
143
 
136
144
  private async muteStream(stopTracks: boolean = true) {
137
145
  if (!this.state.mediaStream) {
138
146
  return;
139
147
  }
148
+ this.logger('debug', `${stopTracks ? 'Stopping' : 'Disabling'} stream`);
140
149
  if (this.call.state.callingState === CallingState.JOINED) {
141
150
  await this.stopPublishStream(stopTracks);
142
- } else if (this.state.mediaStream) {
143
- stopTracks
144
- ? disposeOfMediaStream(this.state.mediaStream)
145
- : this.muteTracks();
146
151
  }
147
- if (stopTracks) {
152
+ this.muteLocalStream(stopTracks);
153
+ if (this.getTrack()?.readyState === 'ended') {
154
+ // @ts-expect-error release() is present in react-native-webrtc and must be called to dispose the stream
155
+ if (typeof this.state.mediaStream.release === 'function') {
156
+ // @ts-expect-error
157
+ this.state.mediaStream.release();
158
+ }
148
159
  this.state.setMediaStream(undefined);
149
160
  }
150
161
  }
151
162
 
163
+ private muteTrack() {
164
+ const track = this.getTrack();
165
+ if (!track || !track.enabled) {
166
+ return;
167
+ }
168
+ track.enabled = false;
169
+ }
170
+
171
+ private unmuteTrack() {
172
+ const track = this.getTrack();
173
+ if (!track || track.enabled) {
174
+ return;
175
+ }
176
+ track.enabled = true;
177
+ }
178
+
179
+ private stopTrack() {
180
+ const track = this.getTrack();
181
+ if (!track || track.readyState === 'ended') {
182
+ return;
183
+ }
184
+ track.stop();
185
+ }
186
+
187
+ private muteLocalStream(stopTracks: boolean) {
188
+ if (!this.state.mediaStream) {
189
+ return;
190
+ }
191
+ stopTracks ? this.stopTrack() : this.muteTrack();
192
+ }
193
+
152
194
  private async unmuteStream() {
195
+ this.logger('debug', 'Starting stream');
153
196
  let stream: MediaStream;
154
- if (this.state.mediaStream) {
197
+ if (this.state.mediaStream && this.getTrack()?.readyState === 'live') {
155
198
  stream = this.state.mediaStream;
156
- this.unmuteTracks();
199
+ this.unmuteTrack();
157
200
  } else {
201
+ if (this.state.mediaStream) {
202
+ this.stopTrack();
203
+ }
158
204
  const constraints = { deviceId: this.state.selectedDevice };
159
205
  stream = await this.getStream(constraints);
160
206
  }
@@ -7,7 +7,7 @@ import { TrackType } from '../gen/video/sfu/models/models';
7
7
 
8
8
  export class MicrophoneManager extends InputMediaDeviceManager<MicrophoneManagerState> {
9
9
  constructor(call: Call) {
10
- super(call, new MicrophoneManagerState());
10
+ super(call, new MicrophoneManagerState(), TrackType.AUDIO);
11
11
  }
12
12
 
13
13
  protected getDevices(): Observable<MediaDeviceInfo[]> {
@@ -25,12 +25,7 @@ export class MicrophoneManager extends InputMediaDeviceManager<MicrophoneManager
25
25
  return this.call.stopPublish(TrackType.AUDIO, stopTracks);
26
26
  }
27
27
 
28
- protected muteTracks(): void {
29
- this.state.mediaStream
30
- ?.getAudioTracks()
31
- .forEach((t) => (t.enabled = false));
32
- }
33
- protected unmuteTracks(): void {
34
- this.state.mediaStream?.getAudioTracks().forEach((t) => (t.enabled = true));
28
+ protected getTrack() {
29
+ return this.state.mediaStream?.getAudioTracks()[0];
35
30
  }
36
31
  }
@@ -7,14 +7,7 @@ import { mockCall, mockVideoDevices, mockVideoStream } from './mocks';
7
7
  import { InputMediaDeviceManager } from '../InputMediaDeviceManager';
8
8
  import { InputMediaDeviceManagerState } from '../InputMediaDeviceManagerState';
9
9
  import { of } from 'rxjs';
10
- import { disposeOfMediaStream } from '../devices';
11
-
12
- vi.mock('../devices.ts', () => {
13
- console.log('MOCKING devices');
14
- return {
15
- disposeOfMediaStream: vi.fn(),
16
- };
17
- });
10
+ import { TrackType } from '../../gen/video/sfu/models/models';
18
11
 
19
12
  vi.mock('../../Call.ts', () => {
20
13
  console.log('MOCKING Call');
@@ -32,11 +25,10 @@ class TestInputMediaDeviceManager extends InputMediaDeviceManager<TestInputMedia
32
25
  public getStream = vi.fn(() => Promise.resolve(mockVideoStream()));
33
26
  public publishStream = vi.fn();
34
27
  public stopPublishStream = vi.fn();
35
- public muteTracks = vi.fn();
36
- public unmuteTracks = vi.fn();
28
+ public getTrack = () => this.state.mediaStream!.getVideoTracks()[0];
37
29
 
38
30
  constructor(call: Call) {
39
- super(call, new TestInputMediaDeviceManagerState());
31
+ super(call, new TestInputMediaDeviceManagerState(), TrackType.VIDEO);
40
32
  }
41
33
  }
42
34
 
@@ -135,11 +127,12 @@ describe('InputMediaDeviceManager.test', () => {
135
127
  it('select device when status is enabled', async () => {
136
128
  await manager.enable();
137
129
  const prevStream = manager.state.mediaStream;
130
+ vi.spyOn(prevStream!.getVideoTracks()[0], 'stop');
138
131
 
139
132
  const deviceId = mockVideoDevices[1].deviceId;
140
133
  await manager.select(deviceId);
141
134
 
142
- expect(disposeOfMediaStream).toHaveBeenCalledWith(prevStream);
135
+ expect(prevStream!.getVideoTracks()[0].stop).toHaveBeenCalledWith();
143
136
  });
144
137
 
145
138
  it('select device when status is enabled and in call', async () => {
@@ -93,6 +93,10 @@ export const mockVideoStream = () => {
93
93
  height: 720,
94
94
  }),
95
95
  enabled: true,
96
+ readyState: 'live',
97
+ stop: () => {
98
+ track.readyState = 'eneded';
99
+ },
96
100
  };
97
101
  return {
98
102
  getVideoTracks: () => [track],
@@ -53,6 +53,7 @@ describe('DynascaleManager', () => {
53
53
  call.state.updateOrAddParticipant('session-id', {
54
54
  userId: 'user-id',
55
55
  sessionId: 'session-id',
56
+ publishedTracks: [],
56
57
  });
57
58
 
58
59
  const element = document.createElement('div');
@@ -113,6 +114,7 @@ describe('DynascaleManager', () => {
113
114
  call.state.updateOrAddParticipant('session-id', {
114
115
  userId: 'user-id',
115
116
  sessionId: 'session-id',
117
+ publishedTracks: [],
116
118
  });
117
119
 
118
120
  // @ts-ignore
@@ -120,6 +122,7 @@ describe('DynascaleManager', () => {
120
122
  userId: 'user-id-local',
121
123
  sessionId: 'session-id-local',
122
124
  isLocalParticipant: true,
125
+ publishedTracks: [],
123
126
  });
124
127
 
125
128
  const cleanup = dynascaleManager.bindAudioElement(
@@ -253,6 +253,9 @@ export class Publisher {
253
253
  // by an external factor as permission revokes, device disconnected, etc.
254
254
  // keep in mind that `track.stop()` doesn't trigger this event.
255
255
  track.addEventListener('ended', handleTrackEnded);
256
+ if (!track.enabled) {
257
+ track.enabled = true;
258
+ }
256
259
 
257
260
  transceiver = this.pc.addTransceiver(track, {
258
261
  direction: 'sendonly',
@@ -310,17 +313,24 @@ export class Publisher {
310
313
  if (
311
314
  transceiver &&
312
315
  transceiver.sender.track &&
313
- transceiver.sender.track.readyState === 'live'
316
+ (stopTrack
317
+ ? transceiver.sender.track.readyState === 'live'
318
+ : transceiver.sender.track.enabled)
314
319
  ) {
315
320
  stopTrack
316
321
  ? transceiver.sender.track.stop()
317
322
  : (transceiver.sender.track.enabled = false);
318
- return this.notifyTrackMuteStateChanged(
319
- undefined,
320
- transceiver.sender.track,
321
- trackType,
322
- true,
323
- );
323
+ // We don't need to notify SFU if unpublishing in response to remote soft mute
324
+ if (!this.state.localParticipant?.publishedTracks.includes(trackType)) {
325
+ return;
326
+ } else {
327
+ return this.notifyTrackMuteStateChanged(
328
+ undefined,
329
+ transceiver.sender.track,
330
+ trackType,
331
+ true,
332
+ );
333
+ }
324
334
  }
325
335
  };
326
336
 
@@ -330,6 +340,24 @@ export class Publisher {
330
340
  * @param trackType the track type to check.
331
341
  */
332
342
  isPublishing = (trackType: TrackType): boolean => {
343
+ const transceiverForTrackType = this.transceiverRegistry[trackType];
344
+ if (transceiverForTrackType && transceiverForTrackType.sender) {
345
+ const sender = transceiverForTrackType.sender;
346
+ return (
347
+ !!sender.track &&
348
+ sender.track.readyState === 'live' &&
349
+ sender.track.enabled
350
+ );
351
+ }
352
+ return false;
353
+ };
354
+
355
+ /**
356
+ * Returns true if the given track type is currently live
357
+ *
358
+ * @param trackType the track type to check.
359
+ */
360
+ isLive = (trackType: TrackType): boolean => {
333
361
  const transceiverForTrackType = this.transceiverRegistry[trackType];
334
362
  if (transceiverForTrackType && transceiverForTrackType.sender) {
335
363
  const sender = transceiverForTrackType.sender;
@@ -180,6 +180,7 @@ describe('Publisher', () => {
180
180
 
181
181
  expect(state.localParticipant?.videoDeviceId).toEqual('test-device-id');
182
182
  expect(state.localParticipant?.publishedTracks).toContain(TrackType.VIDEO);
183
+ expect(track.enabled).toBe(true);
183
184
  expect(state.localParticipant?.videoStream).toEqual(mediaStream);
184
185
  expect(transceiver.setCodecPreferences).toHaveBeenCalled();
185
186
  expect(sfuClient.updateMuteState).toHaveBeenCalledWith(
package/src/rtc/codecs.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { isReactNative } from '../helpers/platforms';
2
- import { removeCodec, setPreferredCodec } from '../helpers/sdp-munging';
3
1
  import { getLogger } from '../logger';
4
2
 
5
3
  export const getPreferredCodecs = (