@stream-io/video-client 0.3.15 → 0.3.17

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 (41) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +528 -466
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +529 -465
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +528 -466
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +9 -8
  9. package/dist/src/coordinator/connection/types.d.ts +0 -4
  10. package/dist/src/devices/InputMediaDeviceManager.d.ts +2 -2
  11. package/dist/src/devices/InputMediaDeviceManagerState.d.ts +2 -2
  12. package/dist/src/devices/MicrophoneManager.d.ts +3 -0
  13. package/dist/src/devices/MicrophoneManagerState.d.ts +18 -0
  14. package/dist/src/devices/SpeakerManager.d.ts +0 -1
  15. package/dist/src/devices/__tests__/mocks.d.ts +2 -4
  16. package/dist/src/devices/index.d.ts +2 -0
  17. package/dist/src/events/__tests__/mutes.test.d.ts +1 -0
  18. package/dist/src/events/index.d.ts +1 -0
  19. package/dist/src/events/mutes.d.ts +7 -0
  20. package/dist/src/rtc/Publisher.d.ts +2 -21
  21. package/dist/version.d.ts +1 -1
  22. package/package.json +5 -5
  23. package/src/Call.ts +17 -50
  24. package/src/coordinator/connection/types.ts +0 -4
  25. package/src/devices/CameraManager.ts +1 -1
  26. package/src/devices/InputMediaDeviceManager.ts +7 -5
  27. package/src/devices/InputMediaDeviceManagerState.ts +3 -3
  28. package/src/devices/MicrophoneManager.ts +56 -1
  29. package/src/devices/MicrophoneManagerState.ts +30 -0
  30. package/src/devices/SpeakerManager.ts +0 -2
  31. package/src/devices/__tests__/CameraManager.test.ts +3 -5
  32. package/src/devices/__tests__/InputMediaDeviceManager.test.ts +4 -7
  33. package/src/devices/__tests__/MicrophoneManager.test.ts +77 -6
  34. package/src/devices/__tests__/mocks.ts +14 -5
  35. package/src/devices/index.ts +2 -0
  36. package/src/events/__tests__/mutes.test.ts +133 -0
  37. package/src/events/callEventHandlers.ts +3 -0
  38. package/src/events/index.ts +1 -0
  39. package/src/events/mutes.ts +48 -0
  40. package/src/helpers/sound-detector.ts +7 -1
  41. package/src/rtc/Publisher.ts +2 -28
@@ -2,7 +2,7 @@ import { Call } from '../../Call';
2
2
  import { StreamClient } from '../../coordinator/connection/client';
3
3
  import { CallingState, StreamVideoWriteableStateStore } from '../../store';
4
4
 
5
- import { afterEach, beforeEach, describe, vi, it, expect } from 'vitest';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
6
  import { mockCall, mockVideoDevices, mockVideoStream } from './mocks';
7
7
  import { InputMediaDeviceManager } from '../InputMediaDeviceManager';
8
8
  import { InputMediaDeviceManagerState } from '../InputMediaDeviceManagerState';
@@ -67,8 +67,7 @@ describe('InputMediaDeviceManager.test', () => {
67
67
  });
68
68
 
69
69
  it('enable device - after joined to call', async () => {
70
- // @ts-expect-error
71
- manager['call'].state.callingState = CallingState.JOINED;
70
+ manager['call'].state.setCallingState(CallingState.JOINED);
72
71
 
73
72
  await manager.enable();
74
73
 
@@ -93,8 +92,7 @@ describe('InputMediaDeviceManager.test', () => {
93
92
  });
94
93
 
95
94
  it('disable device - after joined to call', async () => {
96
- // @ts-expect-error
97
- manager['call'].state.callingState = CallingState.JOINED;
95
+ manager['call'].state.setCallingState(CallingState.JOINED);
98
96
  await manager.enable();
99
97
 
100
98
  await manager.disable();
@@ -136,8 +134,7 @@ describe('InputMediaDeviceManager.test', () => {
136
134
  });
137
135
 
138
136
  it('select device when status is enabled and in call', async () => {
139
- // @ts-expect-error
140
- manager['call'].state.callingState = CallingState.JOINED;
137
+ manager['call'].state.setCallingState(CallingState.JOINED);
141
138
  await manager.enable();
142
139
 
143
140
  const deviceId = mockVideoDevices[1].deviceId;
@@ -2,12 +2,17 @@ import { Call } from '../../Call';
2
2
  import { StreamClient } from '../../coordinator/connection/client';
3
3
  import { CallingState, StreamVideoWriteableStateStore } from '../../store';
4
4
 
5
- import { afterEach, beforeEach, describe, vi, it, expect } from 'vitest';
6
- import { mockCall, mockAudioDevices, mockAudioStream } from './mocks';
5
+ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
6
+ import { mockAudioDevices, mockAudioStream, mockCall } from './mocks';
7
7
  import { getAudioStream } from '../devices';
8
8
  import { TrackType } from '../../gen/video/sfu/models/models';
9
9
  import { MicrophoneManager } from '../MicrophoneManager';
10
10
  import { of } from 'rxjs';
11
+ import {
12
+ createSoundDetector,
13
+ SoundStateChangeHandler,
14
+ } from '../../helpers/sound-detector';
15
+ import { OwnCapability } from '../../gen/coordinator';
11
16
 
12
17
  vi.mock('../devices.ts', () => {
13
18
  console.log('MOCKING devices API');
@@ -20,6 +25,13 @@ vi.mock('../devices.ts', () => {
20
25
  };
21
26
  });
22
27
 
28
+ vi.mock('../../helpers/sound-detector.ts', () => {
29
+ console.log('MOCKING sound detector');
30
+ return {
31
+ createSoundDetector: vi.fn(),
32
+ };
33
+ });
34
+
23
35
  vi.mock('../../Call.ts', () => {
24
36
  console.log('MOCKING Call');
25
37
  return {
@@ -64,8 +76,7 @@ describe('MicrophoneManager', () => {
64
76
  });
65
77
 
66
78
  it('publish stream', async () => {
67
- // @ts-expect-error
68
- manager['call'].state.callingState = CallingState.JOINED;
79
+ manager['call'].state.setCallingState(CallingState.JOINED);
69
80
 
70
81
  await manager.enable();
71
82
 
@@ -75,8 +86,7 @@ describe('MicrophoneManager', () => {
75
86
  });
76
87
 
77
88
  it('stop publish stream', async () => {
78
- // @ts-expect-error
79
- manager['call'].state.callingState = CallingState.JOINED;
89
+ manager['call'].state.setCallingState(CallingState.JOINED);
80
90
  await manager.enable();
81
91
 
82
92
  await manager.disable();
@@ -97,6 +107,67 @@ describe('MicrophoneManager', () => {
97
107
  expect(manager.state.mediaStream!.getAudioTracks()[0].enabled).toBe(false);
98
108
  });
99
109
 
110
+ it(`should start sound detection if mic is disabled`, async () => {
111
+ await manager.enable();
112
+ // @ts-expect-error
113
+ vi.spyOn(manager, 'startSpeakingWhileMutedDetection');
114
+ await manager.disable();
115
+
116
+ expect(manager['startSpeakingWhileMutedDetection']).toHaveBeenCalled();
117
+ });
118
+
119
+ it(`should stop sound detection if mic is enabled`, async () => {
120
+ manager.state.setSpeakingWhileMuted(true);
121
+ manager['soundDetectorCleanup'] = () => {};
122
+
123
+ await manager.enable();
124
+
125
+ expect(manager.state.speakingWhileMuted).toBe(false);
126
+ });
127
+
128
+ it('should update speaking while muted state', async () => {
129
+ const mock = createSoundDetector as Mock;
130
+ let handler: SoundStateChangeHandler;
131
+ mock.mockImplementation((_: MediaStream, h: SoundStateChangeHandler) => {
132
+ handler = h;
133
+ });
134
+ await manager['startSpeakingWhileMutedDetection']();
135
+
136
+ expect(manager.state.speakingWhileMuted).toBe(false);
137
+
138
+ handler!({ isSoundDetected: true, audioLevel: 2 });
139
+
140
+ expect(manager.state.speakingWhileMuted).toBe(true);
141
+
142
+ handler!({ isSoundDetected: false, audioLevel: 0 });
143
+
144
+ expect(manager.state.speakingWhileMuted).toBe(false);
145
+ });
146
+
147
+ it('should stop speaking while muted notifications if user loses permission to send audio', async () => {
148
+ await manager.enable();
149
+ await manager.disable();
150
+
151
+ // @ts-expect-error
152
+ vi.spyOn(manager, 'stopSpeakingWhileMutedDetection');
153
+ manager['call'].state.setOwnCapabilities([]);
154
+
155
+ expect(manager['stopSpeakingWhileMutedDetection']).toHaveBeenCalled();
156
+ });
157
+
158
+ it('should start speaking while muted notifications if user gains permission to send audio', async () => {
159
+ await manager.enable();
160
+ await manager.disable();
161
+
162
+ manager['call'].state.setOwnCapabilities([]);
163
+
164
+ // @ts-expect-error
165
+ vi.spyOn(manager, 'stopSpeakingWhileMutedDetection');
166
+ manager['call'].state.setOwnCapabilities([OwnCapability.SEND_AUDIO]);
167
+
168
+ expect(manager['stopSpeakingWhileMutedDetection']).toHaveBeenCalled();
169
+ });
170
+
100
171
  afterEach(() => {
101
172
  vi.clearAllMocks();
102
173
  vi.resetModules();
@@ -1,5 +1,6 @@
1
1
  import { vi } from 'vitest';
2
- import { CallingState } from '../../store';
2
+ import { CallingState, CallState } from '../../store';
3
+ import { OwnCapability } from '../../gen/coordinator';
3
4
 
4
5
  export const mockVideoDevices = [
5
6
  {
@@ -63,10 +64,14 @@ export const mockAudioDevices = [
63
64
  ] as MediaDeviceInfo[];
64
65
 
65
66
  export const mockCall = () => {
67
+ const callState = new CallState();
68
+ callState.setCallingState(CallingState.JOINED);
69
+ callState.setOwnCapabilities([
70
+ OwnCapability.SEND_AUDIO,
71
+ OwnCapability.SEND_VIDEO,
72
+ ]);
66
73
  return {
67
- state: {
68
- callingState: CallingState.IDLE,
69
- },
74
+ state: callState,
70
75
  publishVideoStream: vi.fn(),
71
76
  publishAudioStream: vi.fn(),
72
77
  stopPublish: vi.fn(),
@@ -79,6 +84,10 @@ export const mockAudioStream = () => {
79
84
  deviceId: mockAudioDevices[0].deviceId,
80
85
  }),
81
86
  enabled: true,
87
+ readyState: 'live',
88
+ stop: () => {
89
+ track.readyState = 'ended';
90
+ },
82
91
  };
83
92
  return {
84
93
  getAudioTracks: () => [track],
@@ -95,7 +104,7 @@ export const mockVideoStream = () => {
95
104
  enabled: true,
96
105
  readyState: 'live',
97
106
  stop: () => {
98
- track.readyState = 'eneded';
107
+ track.readyState = 'ended';
99
108
  },
100
109
  };
101
110
  return {
@@ -5,3 +5,5 @@ export * from './CameraManager';
5
5
  export * from './CameraManagerState';
6
6
  export * from './MicrophoneManager';
7
7
  export * from './MicrophoneManagerState';
8
+ export * from './SpeakerManager';
9
+ export * from './SpeakerState';
@@ -0,0 +1,133 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { Call } from '../../Call';
3
+ import {
4
+ TrackType,
5
+ TrackUnpublishReason,
6
+ } from '../../gen/video/sfu/models/models';
7
+ import { SfuEvent } from '../../gen/video/sfu/event/events';
8
+ import { StreamClient } from '../../coordinator/connection/client';
9
+ import { handleRemoteSoftMute } from '../mutes';
10
+ import { SfuEventListener } from '../../rtc';
11
+
12
+ describe('mutes', () => {
13
+ describe('soft mute', () => {
14
+ let handler: SfuEventListener;
15
+ let call: Call;
16
+
17
+ beforeEach(() => {
18
+ // @ts-expect-error
19
+ call = new Call({
20
+ type: 'test',
21
+ id: 'test',
22
+ streamClient: new StreamClient('api-key'),
23
+ });
24
+ // disable all event handlers
25
+ call['dispatcher'].offAll();
26
+
27
+ // @ts-expect-error partial data
28
+ call.publisher = vi.fn();
29
+ // @ts-expect-error partial data
30
+ call.publisher.isPublishing = vi.fn().mockReturnValue(true);
31
+
32
+ vi.spyOn(call, 'stopPublish').mockResolvedValue(undefined);
33
+ vi.spyOn(call.camera, 'disable').mockResolvedValue(undefined);
34
+ vi.spyOn(call.microphone, 'disable').mockResolvedValue(undefined);
35
+
36
+ // @ts-ignore
37
+ call.on = (event: string, h) => {
38
+ if (event === 'trackUnpublished') {
39
+ // @ts-ignore
40
+ handler = h;
41
+ }
42
+ };
43
+
44
+ handleRemoteSoftMute(call);
45
+
46
+ // @ts-expect-error partial data
47
+ call.state.updateOrAddParticipant('session-id', {
48
+ userId: 'user-id',
49
+ sessionId: 'session-id',
50
+ isLocalParticipant: true,
51
+ publishedTracks: [
52
+ TrackType.VIDEO,
53
+ TrackType.AUDIO,
54
+ TrackType.SCREEN_SHARE,
55
+ TrackType.SCREEN_SHARE_AUDIO,
56
+ ],
57
+ });
58
+ });
59
+
60
+ it('should automatically mute only when cause is moderation', async () => {
61
+ const event: SfuEvent = {
62
+ eventPayload: {
63
+ oneofKind: 'trackUnpublished',
64
+ trackUnpublished: {
65
+ cause: TrackUnpublishReason.PERMISSION_REVOKED,
66
+ type: TrackType.VIDEO,
67
+ sessionId: 'session-id',
68
+ userId: 'user-id',
69
+ },
70
+ },
71
+ };
72
+
73
+ await handler!(event);
74
+ expect(call.camera.disable).not.toHaveBeenCalled();
75
+ expect(call.stopPublish).not.toHaveBeenCalledWith(TrackType.VIDEO);
76
+ });
77
+
78
+ it('should handle remote soft video mute', async () => {
79
+ const event: SfuEvent = {
80
+ eventPayload: {
81
+ oneofKind: 'trackUnpublished',
82
+ trackUnpublished: {
83
+ cause: TrackUnpublishReason.MODERATION,
84
+ type: TrackType.VIDEO,
85
+ sessionId: 'session-id',
86
+ userId: 'user-id',
87
+ },
88
+ },
89
+ };
90
+
91
+ await handler!(event);
92
+ expect(call.camera.disable).toHaveBeenCalled();
93
+ expect(call.stopPublish).toHaveBeenCalledWith(TrackType.VIDEO);
94
+ });
95
+
96
+ it('should handle remote soft audio mute', async () => {
97
+ const event: SfuEvent = {
98
+ eventPayload: {
99
+ oneofKind: 'trackUnpublished',
100
+ trackUnpublished: {
101
+ cause: TrackUnpublishReason.MODERATION,
102
+ type: TrackType.AUDIO,
103
+ sessionId: 'session-id',
104
+ userId: 'user-id',
105
+ },
106
+ },
107
+ };
108
+
109
+ await handler!(event);
110
+ expect(call.microphone.disable).toHaveBeenCalled();
111
+ expect(call.stopPublish).toHaveBeenCalledWith(TrackType.AUDIO);
112
+ });
113
+
114
+ it('should handle remote soft screenshare mute', async () => {
115
+ const event: SfuEvent = {
116
+ eventPayload: {
117
+ oneofKind: 'trackUnpublished',
118
+ trackUnpublished: {
119
+ cause: TrackUnpublishReason.MODERATION,
120
+ type: TrackType.SCREEN_SHARE,
121
+ sessionId: 'session-id',
122
+ userId: 'user-id',
123
+ },
124
+ },
125
+ };
126
+
127
+ await handler!(event);
128
+ expect(call.camera.disable).not.toHaveBeenCalled();
129
+ expect(call.microphone.disable).not.toHaveBeenCalled();
130
+ expect(call.stopPublish).toHaveBeenCalledWith(TrackType.SCREEN_SHARE);
131
+ });
132
+ });
133
+ });
@@ -2,6 +2,7 @@ import { Call } from '../Call';
2
2
  import { Dispatcher } from '../rtc';
3
3
  import { CallState } from '../store';
4
4
  import {
5
+ handleRemoteSoftMute,
5
6
  watchAudioLevelChanged,
6
7
  watchCallAccepted,
7
8
  watchCallEnded,
@@ -61,6 +62,8 @@ export const registerEventHandlers = (
61
62
 
62
63
  call.on('callGrantsUpdated', watchCallGrantsUpdated(state)),
63
64
  call.on('pinsUpdated', watchPinsUpdated(state)),
65
+
66
+ handleRemoteSoftMute(call),
64
67
  ];
65
68
 
66
69
  if (call.ringing) {
@@ -1,5 +1,6 @@
1
1
  export * from './call';
2
2
  export * from './call-permissions';
3
3
  export * from './internal';
4
+ export * from './mutes';
4
5
  export * from './participant';
5
6
  export * from './speaker';
@@ -0,0 +1,48 @@
1
+ import { Call } from '../Call';
2
+ import {
3
+ TrackType,
4
+ TrackUnpublishReason,
5
+ } from '../gen/video/sfu/models/models';
6
+
7
+ /**
8
+ * An event handler that handles soft mutes.
9
+ *
10
+ * @param call the call.
11
+ */
12
+ export const handleRemoteSoftMute = (call: Call) => {
13
+ return call.on('trackUnpublished', async (event) => {
14
+ if (event.eventPayload.oneofKind !== 'trackUnpublished') return;
15
+ const {
16
+ trackUnpublished: { cause, type, sessionId },
17
+ } = event.eventPayload;
18
+ const { localParticipant } = call.state;
19
+ if (
20
+ cause === TrackUnpublishReason.MODERATION &&
21
+ sessionId === localParticipant?.sessionId
22
+ ) {
23
+ const logger = call.logger;
24
+ logger(
25
+ 'info',
26
+ `Local participant's ${TrackType[type]} track is muted remotely`,
27
+ );
28
+ try {
29
+ if (type === TrackType.VIDEO) {
30
+ await call.camera.disable();
31
+ } else if (type === TrackType.AUDIO) {
32
+ await call.microphone.disable();
33
+ } else {
34
+ logger(
35
+ 'warn',
36
+ 'Unsupported track type to soft mute',
37
+ TrackType[type],
38
+ );
39
+ }
40
+ if (call.publisher?.isPublishing(type)) {
41
+ await call.stopPublish(type);
42
+ }
43
+ } catch (error) {
44
+ logger('error', 'Failed to stop publishing', error);
45
+ }
46
+ }
47
+ });
48
+ };
@@ -82,7 +82,13 @@ export const createSoundDetector = (
82
82
  ? 100
83
83
  : Math.round((averagedDataValue / audioLevelThreshold) * 100);
84
84
 
85
- onSoundDetectedStateChanged({ isSoundDetected, audioLevel: percentage });
85
+ // When the track is disabled, it takes time for the buffer to empty
86
+ // This check will ensure that we don't send anything if the track is disabled
87
+ if (audioStream.getAudioTracks()[0]?.enabled) {
88
+ onSoundDetectedStateChanged({ isSoundDetected, audioLevel: percentage });
89
+ } else {
90
+ onSoundDetectedStateChanged({ isSoundDetected: false, audioLevel: 0 });
91
+ }
86
92
  }, detectionFrequencyInMs);
87
93
 
88
94
  return async function stop() {
@@ -29,14 +29,13 @@ import { getOSInfo } from '../client-details';
29
29
 
30
30
  const logger: Logger = getLogger(['Publisher']);
31
31
 
32
- export type PublisherOpts = {
32
+ export type PublisherConstructorOpts = {
33
33
  sfuClient: StreamSfuClient;
34
34
  state: CallState;
35
35
  dispatcher: Dispatcher;
36
36
  connectionConfig?: RTCConfiguration;
37
37
  isDtxEnabled: boolean;
38
38
  isRedEnabled: boolean;
39
- preferredVideoCodec?: string;
40
39
  iceRestartDelay?: number;
41
40
  };
42
41
 
@@ -86,18 +85,8 @@ export class Publisher {
86
85
  [TrackType.UNSPECIFIED]: undefined,
87
86
  };
88
87
 
89
- /**
90
- * A map keeping track of track types that were published to the SFU.
91
- * This map shouldn't be cleared when unpublishing a track, as it is used
92
- * to determine whether a track was published before.
93
- *
94
- * @private
95
- */
96
- private readonly trackTypePublishHistory = new Map<TrackType, boolean>();
97
-
98
88
  private readonly isDtxEnabled: boolean;
99
89
  private readonly isRedEnabled: boolean;
100
- private readonly preferredVideoCodec?: string;
101
90
 
102
91
  private readonly unsubscribeOnIceRestart: () => void;
103
92
 
@@ -118,7 +107,6 @@ export class Publisher {
118
107
  * @param dispatcher the dispatcher to use.
119
108
  * @param isDtxEnabled whether DTX is enabled.
120
109
  * @param isRedEnabled whether RED is enabled.
121
- * @param preferredVideoCodec the preferred video codec.
122
110
  * @param iceRestartDelay the delay in milliseconds to wait before restarting ICE once connection goes to `disconnected` state.
123
111
  */
124
112
  constructor({
@@ -128,15 +116,13 @@ export class Publisher {
128
116
  state,
129
117
  isDtxEnabled,
130
118
  isRedEnabled,
131
- preferredVideoCodec,
132
119
  iceRestartDelay = 2500,
133
- }: PublisherOpts) {
120
+ }: PublisherConstructorOpts) {
134
121
  this.pc = this.createPeerConnection(connectionConfig);
135
122
  this.sfuClient = sfuClient;
136
123
  this.state = state;
137
124
  this.isDtxEnabled = isDtxEnabled;
138
125
  this.isRedEnabled = isRedEnabled;
139
- this.preferredVideoCodec = preferredVideoCodec;
140
126
  this.iceRestartDelay = iceRestartDelay;
141
127
 
142
128
  this.unsubscribeOnIceRestart = dispatcher.on(
@@ -278,7 +264,6 @@ export class Publisher {
278
264
  logger('debug', `Added ${TrackType[trackType]} transceiver`);
279
265
  this.transceiverInitOrder.push(trackType);
280
266
  this.transceiverRegistry[trackType] = transceiver;
281
- this.trackTypePublishHistory.set(trackType, true);
282
267
 
283
268
  if ('setCodecPreferences' in transceiver && codecPreferences) {
284
269
  logger(
@@ -362,17 +347,6 @@ export class Publisher {
362
347
  return false;
363
348
  };
364
349
 
365
- /**
366
- * Returns true if the given track type was ever published to the SFU.
367
- * Contrary to `isPublishing`, this method returns true if a certain
368
- * track type was published before, even if it is currently unpublished.
369
- *
370
- * @param trackType the track type to check.
371
- */
372
- hasEverPublished = (trackType: TrackType): boolean => {
373
- return this.trackTypePublishHistory.get(trackType) ?? false;
374
- };
375
-
376
350
  /**
377
351
  * Returns true if the given track type is currently live
378
352
  *