@stream-io/video-client 0.3.2 → 0.3.3

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.
@@ -217,8 +217,9 @@ export declare class Call {
217
217
  *
218
218
  *
219
219
  * @param trackType the track type to stop publishing.
220
+ * @param stopTrack if `true` the track will be stopped, else it will be just disabled
220
221
  */
221
- stopPublish: (trackType: TrackType) => Promise<void>;
222
+ stopPublish: (trackType: TrackType, stopTrack?: boolean) => Promise<void>;
222
223
  /**
223
224
  * Update track subscription configuration for one or more participants.
224
225
  * You have to create a subscription for each participant for all the different kinds of tracks you want to receive.
@@ -19,13 +19,7 @@ export declare class CameraManager extends InputMediaDeviceManager<CameraManager
19
19
  protected getDevices(): Observable<MediaDeviceInfo[]>;
20
20
  protected getStream(constraints: MediaTrackConstraints): Promise<MediaStream>;
21
21
  protected publishStream(stream: MediaStream): Promise<void>;
22
- protected stopPublishStream(): Promise<void>;
23
- /**
24
- * Disables the video tracks of the camera
25
- */
26
- pause(): void;
27
- /**
28
- * (Re)enables the video tracks of the camera
29
- */
30
- resume(): void;
22
+ protected stopPublishStream(stopTracks: boolean): Promise<void>;
23
+ protected muteTracks(): void;
24
+ protected unmuteTracks(): void;
31
25
  }
@@ -19,11 +19,17 @@ export declare abstract class InputMediaDeviceManager<T extends InputMediaDevice
19
19
  enable(): Promise<void>;
20
20
  /**
21
21
  * Stops camera/microphone
22
+ *
22
23
  * @returns
23
24
  */
24
25
  disable(): Promise<void>;
26
+ /**
27
+ * If status was previously enabled, it will reenable the device.
28
+ */
29
+ resume(): Promise<void>;
25
30
  /**
26
31
  * If current device statis is disabled, it will enable the device, else it will disable it.
32
+ *
27
33
  * @returns
28
34
  */
29
35
  toggle(): Promise<void>;
@@ -36,12 +42,12 @@ export declare abstract class InputMediaDeviceManager<T extends InputMediaDevice
36
42
  */
37
43
  select(deviceId: string | undefined): Promise<void>;
38
44
  protected applySettingsToStream(): Promise<void>;
39
- abstract pause(): void;
40
- abstract resume(): void;
41
45
  protected abstract getDevices(): Observable<MediaDeviceInfo[]>;
42
46
  protected abstract getStream(constraints: MediaTrackConstraints): Promise<MediaStream>;
43
47
  protected abstract publishStream(stream: MediaStream): Promise<void>;
44
- protected abstract stopPublishStream(): Promise<void>;
45
- private stopStream;
46
- private startStream;
48
+ protected abstract stopPublishStream(stopTracks: boolean): Promise<void>;
49
+ protected abstract muteTracks(): void;
50
+ protected abstract unmuteTracks(): void;
51
+ private muteStream;
52
+ private unmuteStream;
47
53
  }
@@ -2,9 +2,14 @@ import { BehaviorSubject, Observable } from 'rxjs';
2
2
  import { RxUtils } from '../store';
3
3
  export type InputDeviceStatus = 'enabled' | 'disabled' | undefined;
4
4
  export declare abstract class InputMediaDeviceManagerState {
5
+ readonly disableMode: 'stop-tracks' | 'disable-tracks';
5
6
  protected statusSubject: BehaviorSubject<InputDeviceStatus>;
6
7
  protected mediaStreamSubject: BehaviorSubject<MediaStream | undefined>;
7
8
  protected selectedDeviceSubject: BehaviorSubject<string | undefined>;
9
+ /**
10
+ * @internal
11
+ */
12
+ prevStatus: InputDeviceStatus;
8
13
  /**
9
14
  * An Observable that emits the current media stream, or `undefined` if the device is currently disabled.
10
15
  *
@@ -18,7 +23,7 @@ export declare abstract class InputMediaDeviceManagerState {
18
23
  * An Observable that emits the device status
19
24
  */
20
25
  status$: Observable<InputDeviceStatus>;
21
- constructor();
26
+ constructor(disableMode?: 'stop-tracks' | 'disable-tracks');
22
27
  /**
23
28
  * The device status
24
29
  */
@@ -7,13 +7,7 @@ export declare class MicrophoneManager extends InputMediaDeviceManager<Microphon
7
7
  protected getDevices(): Observable<MediaDeviceInfo[]>;
8
8
  protected getStream(constraints: MediaTrackConstraints): Promise<MediaStream>;
9
9
  protected publishStream(stream: MediaStream): Promise<void>;
10
- protected stopPublishStream(): Promise<void>;
11
- /**
12
- * Disables the audio tracks of the microphone
13
- */
14
- pause(): void;
15
- /**
16
- * (Re)enables the audio tracks of the microphone
17
- */
18
- resume(): void;
10
+ protected stopPublishStream(stopTracks: boolean): Promise<void>;
11
+ protected muteTracks(): void;
12
+ protected unmuteTracks(): void;
19
13
  }
@@ -1,4 +1,5 @@
1
1
  import { InputMediaDeviceManagerState } from './InputMediaDeviceManagerState';
2
2
  export declare class MicrophoneManagerState extends InputMediaDeviceManagerState {
3
+ constructor();
3
4
  protected getDeviceIdFromStream(stream: MediaStream): string | undefined;
4
5
  }
@@ -76,8 +76,9 @@ export declare class Publisher {
76
76
  * Stops publishing the given track type to the SFU, if it is currently being published.
77
77
  * Underlying track will be stopped and removed from the publisher.
78
78
  * @param trackType the track type to unpublish.
79
+ * @param stopTrack specifies whether track should be stopped or just disabled
79
80
  */
80
- unpublishStream: (trackType: TrackType) => Promise<void>;
81
+ unpublishStream: (trackType: TrackType, stopTrack: boolean) => Promise<void>;
81
82
  /**
82
83
  * Returns true if the given track type is currently being published to the SFU.
83
84
  *
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const version = "0.3.2";
1
+ export declare const version = "0.3.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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
@@ -1092,10 +1092,11 @@ export class Call {
1092
1092
  *
1093
1093
  *
1094
1094
  * @param trackType the track type to stop publishing.
1095
+ * @param stopTrack if `true` the track will be stopped, else it will be just disabled
1095
1096
  */
1096
- stopPublish = async (trackType: TrackType) => {
1097
+ stopPublish = async (trackType: TrackType, stopTrack: boolean = true) => {
1097
1098
  this.logger('info', `stopPublish ${TrackType[trackType]}`);
1098
- await this.publisher?.unpublishStream(trackType);
1099
+ await this.publisher?.unpublishStream(trackType, stopTrack);
1099
1100
  };
1100
1101
 
1101
1102
  /**
@@ -49,25 +49,16 @@ export class CameraManager extends InputMediaDeviceManager<CameraManagerState> {
49
49
  protected publishStream(stream: MediaStream): Promise<void> {
50
50
  return this.call.publishVideoStream(stream);
51
51
  }
52
- protected stopPublishStream(): Promise<void> {
53
- return this.call.stopPublish(TrackType.VIDEO);
52
+ protected stopPublishStream(stopTracks: boolean): Promise<void> {
53
+ return this.call.stopPublish(TrackType.VIDEO, stopTracks);
54
54
  }
55
55
 
56
- /**
57
- * Disables the video tracks of the camera
58
- */
59
- pause() {
60
- this.state.mediaStream?.getVideoTracks().forEach((track) => {
61
- track.enabled = false;
62
- });
56
+ protected muteTracks(): void {
57
+ this.state.mediaStream
58
+ ?.getVideoTracks()
59
+ .forEach((t) => (t.enabled = false));
63
60
  }
64
-
65
- /**
66
- * (Re)enables the video tracks of the camera
67
- */
68
- resume() {
69
- this.state.mediaStream?.getVideoTracks().forEach((track) => {
70
- track.enabled = true;
71
- });
61
+ protected unmuteTracks(): void {
62
+ this.state.mediaStream?.getVideoTracks().forEach((t) => (t.enabled = true));
72
63
  }
73
64
  }
@@ -15,7 +15,7 @@ export class CameraManagerState extends InputMediaDeviceManagerState {
15
15
  direction$: Observable<CameraDirection>;
16
16
 
17
17
  constructor() {
18
- super();
18
+ super('stop-tracks');
19
19
  this.direction$ = this.directionSubject
20
20
  .asObservable()
21
21
  .pipe(distinctUntilChanged());
@@ -28,24 +28,39 @@ export abstract class InputMediaDeviceManager<
28
28
  if (this.state.status === 'enabled') {
29
29
  return;
30
30
  }
31
- await this.startStream();
31
+ await this.unmuteStream();
32
32
  this.state.setStatus('enabled');
33
33
  }
34
34
 
35
35
  /**
36
36
  * Stops camera/microphone
37
+ *
37
38
  * @returns
38
39
  */
39
40
  async disable() {
40
41
  if (this.state.status === 'disabled') {
41
42
  return;
42
43
  }
43
- await this.stopStream();
44
+ this.state.prevStatus = this.state.status;
45
+ await this.muteStream(this.state.disableMode === 'stop-tracks');
44
46
  this.state.setStatus('disabled');
45
47
  }
46
48
 
49
+ /**
50
+ * If status was previously enabled, it will reenable the device.
51
+ */
52
+ async resume() {
53
+ if (
54
+ this.state.prevStatus === 'enabled' &&
55
+ this.state.status === 'disabled'
56
+ ) {
57
+ this.enable();
58
+ }
59
+ }
60
+
47
61
  /**
48
62
  * If current device statis is disabled, it will enable the device, else it will disable it.
63
+ *
49
64
  * @returns
50
65
  */
51
66
  async toggle() {
@@ -76,15 +91,11 @@ export abstract class InputMediaDeviceManager<
76
91
 
77
92
  protected async applySettingsToStream() {
78
93
  if (this.state.status === 'enabled') {
79
- await this.stopStream();
80
- await this.startStream();
94
+ await this.muteStream();
95
+ await this.unmuteStream();
81
96
  }
82
97
  }
83
98
 
84
- abstract pause(): void;
85
-
86
- abstract resume(): void;
87
-
88
99
  protected abstract getDevices(): Observable<MediaDeviceInfo[]>;
89
100
 
90
101
  protected abstract getStream(
@@ -93,26 +104,37 @@ export abstract class InputMediaDeviceManager<
93
104
 
94
105
  protected abstract publishStream(stream: MediaStream): Promise<void>;
95
106
 
96
- protected abstract stopPublishStream(): Promise<void>;
107
+ protected abstract stopPublishStream(stopTracks: boolean): Promise<void>;
108
+
109
+ protected abstract muteTracks(): void;
110
+
111
+ protected abstract unmuteTracks(): void;
97
112
 
98
- private async stopStream() {
113
+ private async muteStream(stopTracks: boolean = true) {
99
114
  if (!this.state.mediaStream) {
100
115
  return;
101
116
  }
102
117
  if (this.call.state.callingState === CallingState.JOINED) {
103
- await this.stopPublishStream();
118
+ await this.stopPublishStream(stopTracks);
104
119
  } else if (this.state.mediaStream) {
105
- disposeOfMediaStream(this.state.mediaStream);
120
+ stopTracks
121
+ ? disposeOfMediaStream(this.state.mediaStream)
122
+ : this.muteTracks();
123
+ }
124
+ if (stopTracks) {
125
+ this.state.setMediaStream(undefined);
106
126
  }
107
- this.state.setMediaStream(undefined);
108
127
  }
109
128
 
110
- private async startStream() {
129
+ private async unmuteStream() {
130
+ let stream: MediaStream;
111
131
  if (this.state.mediaStream) {
112
- return;
132
+ stream = this.state.mediaStream;
133
+ this.unmuteTracks();
134
+ } else {
135
+ const constraints = { deviceId: this.state.selectedDevice };
136
+ stream = await this.getStream(constraints);
113
137
  }
114
- const constraints = { deviceId: this.state.selectedDevice };
115
- const stream = await this.getStream(constraints);
116
138
  if (this.call.state.callingState === CallingState.JOINED) {
117
139
  await this.publishStream(stream);
118
140
  }
@@ -11,6 +11,10 @@ export abstract class InputMediaDeviceManagerState {
11
11
  protected selectedDeviceSubject = new BehaviorSubject<string | undefined>(
12
12
  undefined,
13
13
  );
14
+ /**
15
+ * @internal
16
+ */
17
+ prevStatus: InputDeviceStatus;
14
18
 
15
19
  /**
16
20
  * An Observable that emits the current media stream, or `undefined` if the device is currently disabled.
@@ -28,12 +32,18 @@ export abstract class InputMediaDeviceManagerState {
28
32
  */
29
33
  status$: Observable<InputDeviceStatus>;
30
34
 
31
- constructor() {
35
+ constructor(
36
+ public readonly disableMode:
37
+ | 'stop-tracks'
38
+ | 'disable-tracks' = 'stop-tracks',
39
+ ) {
32
40
  this.mediaStream$ = this.mediaStreamSubject.asObservable();
33
41
  this.selectedDevice$ = this.selectedDeviceSubject
34
42
  .asObservable()
35
43
  .pipe(distinctUntilChanged());
36
- this.status$ = this.statusSubject.asObservable();
44
+ this.status$ = this.statusSubject
45
+ .asObservable()
46
+ .pipe(distinctUntilChanged());
37
47
  }
38
48
 
39
49
  /**
@@ -21,25 +21,16 @@ export class MicrophoneManager extends InputMediaDeviceManager<MicrophoneManager
21
21
  protected publishStream(stream: MediaStream): Promise<void> {
22
22
  return this.call.publishAudioStream(stream);
23
23
  }
24
- protected stopPublishStream(): Promise<void> {
25
- return this.call.stopPublish(TrackType.AUDIO);
24
+ protected stopPublishStream(stopTracks: boolean): Promise<void> {
25
+ return this.call.stopPublish(TrackType.AUDIO, stopTracks);
26
26
  }
27
27
 
28
- /**
29
- * Disables the audio tracks of the microphone
30
- */
31
- pause() {
32
- this.state.mediaStream?.getAudioTracks().forEach((track) => {
33
- track.enabled = false;
34
- });
28
+ protected muteTracks(): void {
29
+ this.state.mediaStream
30
+ ?.getAudioTracks()
31
+ .forEach((t) => (t.enabled = false));
35
32
  }
36
-
37
- /**
38
- * (Re)enables the audio tracks of the microphone
39
- */
40
- resume() {
41
- this.state.mediaStream?.getAudioTracks().forEach((track) => {
42
- track.enabled = true;
43
- });
33
+ protected unmuteTracks(): void {
34
+ this.state.mediaStream?.getAudioTracks().forEach((t) => (t.enabled = true));
44
35
  }
45
36
  }
@@ -1,6 +1,10 @@
1
1
  import { InputMediaDeviceManagerState } from './InputMediaDeviceManagerState';
2
2
 
3
3
  export class MicrophoneManagerState extends InputMediaDeviceManagerState {
4
+ constructor() {
5
+ super('disable-tracks');
6
+ }
7
+
4
8
  protected getDeviceIdFromStream(stream: MediaStream): string | undefined {
5
9
  return stream.getAudioTracks()[0]?.getSettings().deviceId as
6
10
  | string
@@ -8,7 +8,6 @@ import { getVideoStream } from '../devices';
8
8
  import { TrackType } from '../../gen/video/sfu/models/models';
9
9
  import { CameraManager } from '../CameraManager';
10
10
  import { of } from 'rxjs';
11
- import { CallSettingsResponse } from '../../gen/coordinator';
12
11
 
13
12
  vi.mock('../devices.ts', () => {
14
13
  console.log('MOCKING devices API');
@@ -83,7 +82,10 @@ describe('CameraManager', () => {
83
82
 
84
83
  await manager.disable();
85
84
 
86
- expect(manager['call'].stopPublish).toHaveBeenCalledWith(TrackType.VIDEO);
85
+ expect(manager['call'].stopPublish).toHaveBeenCalledWith(
86
+ TrackType.VIDEO,
87
+ true,
88
+ );
87
89
  });
88
90
 
89
91
  it('flip', async () => {
@@ -131,18 +133,6 @@ describe('CameraManager', () => {
131
133
  });
132
134
  });
133
135
 
134
- it('should pause and resume tracks', async () => {
135
- await manager.enable();
136
-
137
- manager.pause();
138
-
139
- expect(manager.state.mediaStream?.getVideoTracks()[0].enabled).toBe(false);
140
-
141
- manager.resume();
142
-
143
- expect(manager.state.mediaStream?.getVideoTracks()[0].enabled).toBe(true);
144
- });
145
-
146
136
  afterEach(() => {
147
137
  vi.clearAllMocks();
148
138
  vi.resetModules();
@@ -7,6 +7,14 @@ 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
18
 
11
19
  vi.mock('../../Call.ts', () => {
12
20
  console.log('MOCKING Call');
@@ -24,8 +32,8 @@ class TestInputMediaDeviceManager extends InputMediaDeviceManager<TestInputMedia
24
32
  public getStream = vi.fn(() => Promise.resolve(mockVideoStream()));
25
33
  public publishStream = vi.fn();
26
34
  public stopPublishStream = vi.fn();
27
- public pause = vi.fn();
28
- public resume = vi.fn();
35
+ public muteTracks = vi.fn();
36
+ public unmuteTracks = vi.fn();
29
37
 
30
38
  constructor(call: Call) {
31
39
  super(call, new TestInputMediaDeviceManagerState());
@@ -92,14 +100,14 @@ describe('InputMediaDeviceManager.test', () => {
92
100
  expect(manager.state.status).toBe('disabled');
93
101
  });
94
102
 
95
- it('disable camera - after joined to call', async () => {
103
+ it('disable device - after joined to call', async () => {
96
104
  // @ts-expect-error
97
105
  manager['call'].state.callingState = CallingState.JOINED;
98
106
  await manager.enable();
99
107
 
100
108
  await manager.disable();
101
109
 
102
- expect(manager.stopPublishStream).toHaveBeenCalledWith();
110
+ expect(manager.stopPublishStream).toHaveBeenCalledWith(true);
103
111
  });
104
112
 
105
113
  it('toggle device', async () => {
@@ -124,6 +132,16 @@ describe('InputMediaDeviceManager.test', () => {
124
132
  expect(manager.publishStream).not.toHaveBeenCalled();
125
133
  });
126
134
 
135
+ it('select device when status is enabled', async () => {
136
+ await manager.enable();
137
+ const prevStream = manager.state.mediaStream;
138
+
139
+ const deviceId = mockVideoDevices[1].deviceId;
140
+ await manager.select(deviceId);
141
+
142
+ expect(disposeOfMediaStream).toHaveBeenCalledWith(prevStream);
143
+ });
144
+
127
145
  it('select device when status is enabled and in call', async () => {
128
146
  // @ts-expect-error
129
147
  manager['call'].state.callingState = CallingState.JOINED;
@@ -132,7 +150,7 @@ describe('InputMediaDeviceManager.test', () => {
132
150
  const deviceId = mockVideoDevices[1].deviceId;
133
151
  await manager.select(deviceId);
134
152
 
135
- expect(manager.stopPublishStream).toHaveBeenCalledWith();
153
+ expect(manager.stopPublishStream).toHaveBeenCalledWith(true);
136
154
  expect(manager.getStream).toHaveBeenCalledWith({
137
155
  deviceId,
138
156
  });
@@ -152,6 +170,31 @@ describe('InputMediaDeviceManager.test', () => {
152
170
  expect(spy.mock.calls.length).toBe(1);
153
171
  });
154
172
 
173
+ it('should resume previously enabled state', async () => {
174
+ vi.spyOn(manager, 'enable');
175
+
176
+ await manager.enable();
177
+
178
+ expect(manager.enable).toHaveBeenCalledTimes(1);
179
+
180
+ await manager.disable();
181
+ await manager.resume();
182
+
183
+ expect(manager.enable).toHaveBeenCalledTimes(2);
184
+ });
185
+
186
+ it(`shouldn't resume if previous state is disabled`, async () => {
187
+ vi.spyOn(manager, 'enable');
188
+
189
+ await manager.disable();
190
+
191
+ expect(manager.enable).not.toHaveBeenCalled();
192
+
193
+ await manager.resume();
194
+
195
+ expect(manager.enable).not.toHaveBeenCalled();
196
+ });
197
+
155
198
  afterEach(() => {
156
199
  vi.clearAllMocks();
157
200
  vi.resetModules();
@@ -81,19 +81,20 @@ describe('MicrophoneManager', () => {
81
81
 
82
82
  await manager.disable();
83
83
 
84
- expect(manager['call'].stopPublish).toHaveBeenCalledWith(TrackType.AUDIO);
84
+ expect(manager['call'].stopPublish).toHaveBeenCalledWith(
85
+ TrackType.AUDIO,
86
+ false,
87
+ );
85
88
  });
86
89
 
87
- it('should pause and resume tracks', async () => {
90
+ it('disable-enable mic should set track.enabled', async () => {
88
91
  await manager.enable();
89
92
 
90
- manager.pause();
91
-
92
- expect(manager.state.mediaStream?.getAudioTracks()[0].enabled).toBe(false);
93
+ expect(manager.state.mediaStream!.getAudioTracks()[0].enabled).toBe(true);
93
94
 
94
- manager.resume();
95
+ await manager.disable();
95
96
 
96
- expect(manager.state.mediaStream?.getAudioTracks()[0].enabled).toBe(true);
97
+ expect(manager.state.mediaStream!.getAudioTracks()[0].enabled).toBe(false);
97
98
  });
98
99
 
99
100
  afterEach(() => {
@@ -283,6 +283,9 @@ export class Publisher {
283
283
  previousTrack.removeEventListener('ended', handleTrackEnded);
284
284
  track.addEventListener('ended', handleTrackEnded);
285
285
  }
286
+ if (!track.enabled) {
287
+ track.enabled = true;
288
+ }
286
289
  await transceiver.sender.replaceTrack(track);
287
290
  }
288
291
 
@@ -298,8 +301,9 @@ export class Publisher {
298
301
  * Stops publishing the given track type to the SFU, if it is currently being published.
299
302
  * Underlying track will be stopped and removed from the publisher.
300
303
  * @param trackType the track type to unpublish.
304
+ * @param stopTrack specifies whether track should be stopped or just disabled
301
305
  */
302
- unpublishStream = async (trackType: TrackType) => {
306
+ unpublishStream = async (trackType: TrackType, stopTrack: boolean) => {
303
307
  const transceiver = this.pc
304
308
  .getTransceivers()
305
309
  .find((t) => t === this.transceiverRegistry[trackType] && t.sender.track);
@@ -308,7 +312,9 @@ export class Publisher {
308
312
  transceiver.sender.track &&
309
313
  transceiver.sender.track.readyState === 'live'
310
314
  ) {
311
- transceiver.sender.track.stop();
315
+ stopTrack
316
+ ? transceiver.sender.track.stop()
317
+ : (transceiver.sender.track.enabled = false);
312
318
  return this.notifyTrackMuteStateChanged(
313
319
  undefined,
314
320
  transceiver.sender.track,
@@ -139,7 +139,7 @@ describe('Publisher', () => {
139
139
  expect(state.localParticipant?.videoDeviceId).toEqual('test-device-id-2');
140
140
 
141
141
  // stop publishing
142
- await publisher.unpublishStream(TrackType.VIDEO);
142
+ await publisher.unpublishStream(TrackType.VIDEO, true);
143
143
  expect(newTrack.stop).toHaveBeenCalled();
144
144
  expect(state.localParticipant?.publishedTracks).not.toContain(
145
145
  TrackType.VIDEO,
@@ -147,6 +147,72 @@ describe('Publisher', () => {
147
147
  expect(state.localParticipant?.videoDeviceId).toEqual('test-device-id-2');
148
148
  });
149
149
 
150
+ it('can publish and un-pubish with just enabling and disabling tracks', async () => {
151
+ const mediaStream = new MediaStream();
152
+ const track = new MediaStreamTrack();
153
+ mediaStream.addTrack(track);
154
+
155
+ state.setParticipants([
156
+ // @ts-ignore
157
+ {
158
+ isLocalParticipant: true,
159
+ userId: 'test-user-id',
160
+ sessionId: sessionId,
161
+ publishedTracks: [],
162
+ },
163
+ ]);
164
+
165
+ vi.spyOn(track, 'getSettings').mockReturnValue({
166
+ width: 640,
167
+ height: 480,
168
+ deviceId: 'test-device-id',
169
+ });
170
+
171
+ const transceiver = new RTCRtpTransceiver();
172
+ vi.spyOn(transceiver.sender, 'track', 'get').mockReturnValue(track);
173
+ vi.spyOn(publisher['pc'], 'addTransceiver').mockReturnValue(transceiver);
174
+ vi.spyOn(publisher['pc'], 'getTransceivers').mockReturnValue([transceiver]);
175
+
176
+ sfuClient.updateMuteState = vi.fn();
177
+
178
+ // initial publish
179
+ await publisher.publishStream(mediaStream, track, TrackType.VIDEO);
180
+
181
+ expect(state.localParticipant?.videoDeviceId).toEqual('test-device-id');
182
+ expect(state.localParticipant?.publishedTracks).toContain(TrackType.VIDEO);
183
+ expect(state.localParticipant?.videoStream).toEqual(mediaStream);
184
+ expect(transceiver.setCodecPreferences).toHaveBeenCalled();
185
+ expect(sfuClient.updateMuteState).toHaveBeenCalledWith(
186
+ TrackType.VIDEO,
187
+ false,
188
+ );
189
+
190
+ expect(track.addEventListener).toHaveBeenCalledWith(
191
+ 'ended',
192
+ expect.any(Function),
193
+ );
194
+
195
+ // stop publishing
196
+ await publisher.unpublishStream(TrackType.VIDEO, false);
197
+ expect(track.stop).not.toHaveBeenCalled();
198
+ expect(track.enabled).toBe(false);
199
+ expect(state.localParticipant?.publishedTracks).not.toContain(
200
+ TrackType.VIDEO,
201
+ );
202
+ expect(state.localParticipant?.videoStream).toBeUndefined();
203
+
204
+ const addEventListenerSpy = vi.spyOn(track, 'addEventListener');
205
+ const removeEventListenerSpy = vi.spyOn(track, 'removeEventListener');
206
+
207
+ // start publish again
208
+ await publisher.publishStream(mediaStream, track, TrackType.VIDEO);
209
+
210
+ expect(track.enabled).toBe(true);
211
+ // republishing the same stream should use the previously registered event handlers
212
+ expect(removeEventListenerSpy).not.toHaveBeenCalled();
213
+ expect(addEventListenerSpy).not.toHaveBeenCalled();
214
+ });
215
+
150
216
  describe('Publisher migration', () => {
151
217
  it('should update the sfuClient and peer connection configuration', async () => {
152
218
  const newSfuClient = new StreamSfuClient({