@stream-io/video-client 1.23.2 → 1.23.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.
- package/CHANGELOG.md +10 -0
- package/dist/index.browser.es.js +228 -140
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +227 -139
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +228 -140
- package/dist/index.es.js.map +1 -1
- package/dist/src/helpers/DynascaleManager.d.ts +7 -0
- package/dist/src/store/CallState.d.ts +11 -1
- package/package.json +3 -3
- package/src/Call.ts +1 -0
- package/src/devices/InputMediaDeviceManager.ts +139 -138
- package/src/devices/MicrophoneManager.ts +1 -0
- package/src/devices/__tests__/InputMediaDeviceManager.test.ts +2 -2
- package/src/helpers/DynascaleManager.ts +94 -27
- package/src/helpers/__tests__/DynascaleManager.test.ts +179 -3
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +32 -0
- package/src/store/CallState.ts +19 -1
|
@@ -4,7 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
import '../../rtc/__tests__/mocks/webrtc.mocks';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
afterAll,
|
|
9
|
+
afterEach,
|
|
10
|
+
beforeEach,
|
|
11
|
+
describe,
|
|
12
|
+
expect,
|
|
13
|
+
it,
|
|
14
|
+
Mock,
|
|
15
|
+
vi,
|
|
16
|
+
} from 'vitest';
|
|
8
17
|
import { DynascaleManager } from '../DynascaleManager';
|
|
9
18
|
import { Call } from '../../Call';
|
|
10
19
|
import { StreamClient } from '../../coordinator/connection/client';
|
|
@@ -89,18 +98,43 @@ describe('DynascaleManager', () => {
|
|
|
89
98
|
let videoElement: globalThis.HTMLVideoElement;
|
|
90
99
|
|
|
91
100
|
beforeEach(() => {
|
|
101
|
+
// Mock global isSafari to false for testing
|
|
102
|
+
globalThis._isSafari = false;
|
|
103
|
+
vi.mock(import('../browsers'), async (importOriginal) => {
|
|
104
|
+
const module = await importOriginal();
|
|
105
|
+
return {
|
|
106
|
+
...module,
|
|
107
|
+
isSafari: () => globalThis._isSafari ?? false,
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
|
|
92
111
|
videoElement = document.createElement('video');
|
|
112
|
+
|
|
113
|
+
// circumvent happy-dom's extensive validation rules
|
|
114
|
+
Object.defineProperties(videoElement, {
|
|
115
|
+
srcObject: { writable: true },
|
|
116
|
+
clientWidth: { writable: true },
|
|
117
|
+
clientHeight: { writable: true },
|
|
118
|
+
});
|
|
119
|
+
|
|
93
120
|
// @ts-expect-error private property
|
|
94
121
|
videoElement.clientWidth = 100;
|
|
95
122
|
// @ts-expect-error private property
|
|
96
123
|
videoElement.clientHeight = 100;
|
|
97
124
|
});
|
|
98
125
|
|
|
126
|
+
afterAll(() => {
|
|
127
|
+
delete globalThis._isSafari;
|
|
128
|
+
vi.resetModules();
|
|
129
|
+
});
|
|
130
|
+
|
|
99
131
|
it('audio: should bind audio element', () => {
|
|
100
132
|
vi.useFakeTimers();
|
|
101
133
|
const audioElement = document.createElement('audio');
|
|
134
|
+
// circumvent happy-dom's MediaStream validation
|
|
135
|
+
Object.defineProperty(audioElement, 'srcObject', { writable: true });
|
|
102
136
|
const play = vi.spyOn(audioElement, 'play').mockResolvedValue();
|
|
103
|
-
audioElement.setSinkId = vi.fn();
|
|
137
|
+
audioElement.setSinkId = vi.fn().mockResolvedValue({});
|
|
104
138
|
|
|
105
139
|
// @ts-expect-error incomplete data
|
|
106
140
|
call.state.updateOrAddParticipant('session-id', {
|
|
@@ -153,9 +187,100 @@ describe('DynascaleManager', () => {
|
|
|
153
187
|
cleanup?.();
|
|
154
188
|
});
|
|
155
189
|
|
|
190
|
+
it('audio: Safari should use AudioContext for audio playback', () => {
|
|
191
|
+
globalThis._isSafari = true;
|
|
192
|
+
|
|
193
|
+
vi.useFakeTimers();
|
|
194
|
+
const audioElement = document.createElement('audio');
|
|
195
|
+
// circumvent happy-dom's MediaStream validation
|
|
196
|
+
Object.defineProperty(audioElement, 'srcObject', { writable: true });
|
|
197
|
+
const play = vi.spyOn(audioElement, 'play').mockResolvedValue();
|
|
198
|
+
audioElement.setSinkId = vi.fn().mockResolvedValue({});
|
|
199
|
+
|
|
200
|
+
// @ts-expect-error incomplete data
|
|
201
|
+
call.state.updateOrAddParticipant('session-id', {
|
|
202
|
+
userId: 'user-id',
|
|
203
|
+
sessionId: 'session-id',
|
|
204
|
+
publishedTracks: [],
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// @ts-expect-error incomplete data
|
|
208
|
+
call.state.updateOrAddParticipant('session-id-local', {
|
|
209
|
+
userId: 'user-id-local',
|
|
210
|
+
sessionId: 'session-id-local',
|
|
211
|
+
isLocalParticipant: true,
|
|
212
|
+
publishedTracks: [],
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const cleanup = dynascaleManager.bindAudioElement(
|
|
216
|
+
audioElement,
|
|
217
|
+
'session-id',
|
|
218
|
+
'audioTrack',
|
|
219
|
+
);
|
|
220
|
+
expect(audioElement.autoplay).toBe(true);
|
|
221
|
+
|
|
222
|
+
const mediaStream = new MediaStream();
|
|
223
|
+
call.state.updateParticipant('session-id', { audioStream: mediaStream });
|
|
224
|
+
|
|
225
|
+
vi.runAllTimers();
|
|
226
|
+
|
|
227
|
+
expect(play).not.toHaveBeenCalled();
|
|
228
|
+
expect(audioElement.srcObject).toBe(mediaStream);
|
|
229
|
+
expect(audioElement.volume).toBe(1);
|
|
230
|
+
expect(audioElement.setSinkId).not.toHaveBeenCalled();
|
|
231
|
+
expect(audioElement.muted).toBe(true);
|
|
232
|
+
|
|
233
|
+
// @ts-expect-error private property
|
|
234
|
+
const audioContext = dynascaleManager.audioContext;
|
|
235
|
+
expect(audioContext).toBeDefined();
|
|
236
|
+
expect(audioContext.resume).toHaveBeenCalled();
|
|
237
|
+
expect(audioContext.state).toBe('running');
|
|
238
|
+
expect(audioContext.createMediaStreamSource).toHaveBeenCalledWith(
|
|
239
|
+
mediaStream,
|
|
240
|
+
);
|
|
241
|
+
expect(audioContext.createGain).toHaveBeenCalled();
|
|
242
|
+
expect(audioContext.resume).toHaveBeenCalled();
|
|
243
|
+
|
|
244
|
+
const sourceNode = (
|
|
245
|
+
audioContext.createMediaStreamSource as Mock<
|
|
246
|
+
AudioContext['createMediaStreamSource']
|
|
247
|
+
>
|
|
248
|
+
).mock.results[0].value;
|
|
249
|
+
|
|
250
|
+
const gainNode = (
|
|
251
|
+
audioContext.createGain as Mock<AudioContext['createGain']>
|
|
252
|
+
).mock.results[0].value;
|
|
253
|
+
|
|
254
|
+
expect(sourceNode.connect).toHaveBeenCalledWith(gainNode);
|
|
255
|
+
expect(gainNode.connect).toHaveBeenCalledWith(audioContext.destination);
|
|
256
|
+
|
|
257
|
+
call.speaker.select('different-device-id');
|
|
258
|
+
expect(audioElement.setSinkId).toHaveBeenCalledWith(
|
|
259
|
+
'different-device-id',
|
|
260
|
+
);
|
|
261
|
+
// @ts-expect-error sinkId isn't available in the TS definition
|
|
262
|
+
expect(audioContext.sinkId).toBe('different-device-id');
|
|
263
|
+
|
|
264
|
+
call.speaker.setVolume(0.5);
|
|
265
|
+
expect(audioElement.volume).toBe(0.5);
|
|
266
|
+
expect(gainNode.gain.value).toBe(0.5);
|
|
267
|
+
|
|
268
|
+
call.speaker.setParticipantVolume('session-id', 0.7);
|
|
269
|
+
expect(audioElement.volume).toBe(0.7);
|
|
270
|
+
expect(gainNode.gain.value).toBe(0.7);
|
|
271
|
+
|
|
272
|
+
call.speaker.setParticipantVolume('session-id', undefined);
|
|
273
|
+
expect(audioElement.volume).toBe(0.5);
|
|
274
|
+
expect(gainNode.gain.value).toBe(0.5);
|
|
275
|
+
|
|
276
|
+
cleanup?.();
|
|
277
|
+
});
|
|
278
|
+
|
|
156
279
|
it('audio: should bind screenShare audio element', () => {
|
|
157
280
|
vi.useFakeTimers();
|
|
158
281
|
const audioElement = document.createElement('audio');
|
|
282
|
+
// circumvent happy-dom's MediaStream validation
|
|
283
|
+
Object.defineProperty(audioElement, 'srcObject', { writable: true });
|
|
159
284
|
const play = vi.spyOn(audioElement, 'play').mockResolvedValue();
|
|
160
285
|
|
|
161
286
|
// @ts-expect-error incomplete data
|
|
@@ -245,6 +370,55 @@ describe('DynascaleManager', () => {
|
|
|
245
370
|
it('video: should play video when track becomes available', () => {
|
|
246
371
|
vi.useFakeTimers();
|
|
247
372
|
|
|
373
|
+
const updateSubscription = vi.spyOn(
|
|
374
|
+
call.state,
|
|
375
|
+
'updateParticipantTracks',
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
// @ts-expect-error incomplete data
|
|
379
|
+
call.state.updateOrAddParticipant('session-id', {
|
|
380
|
+
userId: 'user-id',
|
|
381
|
+
sessionId: 'session-id',
|
|
382
|
+
publishedTracks: [],
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const cleanup = dynascaleManager.bindVideoElement(
|
|
386
|
+
videoElement,
|
|
387
|
+
'session-id',
|
|
388
|
+
'videoTrack',
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
const mediaStream = new MediaStream();
|
|
392
|
+
call.state.updateParticipant('session-id', {
|
|
393
|
+
publishedTracks: [TrackType.VIDEO],
|
|
394
|
+
videoStream: mediaStream,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
vi.runAllTimers();
|
|
398
|
+
|
|
399
|
+
expect(updateSubscription).toHaveBeenCalledWith('videoTrack', {
|
|
400
|
+
'session-id': {
|
|
401
|
+
dimension: {
|
|
402
|
+
width: videoElement.clientWidth,
|
|
403
|
+
height: videoElement.clientHeight,
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
expect(videoElement.srcObject).toBe(mediaStream);
|
|
409
|
+
|
|
410
|
+
expect(cleanup).toBeDefined();
|
|
411
|
+
cleanup?.();
|
|
412
|
+
|
|
413
|
+
expect(updateSubscription).toHaveBeenLastCalledWith('videoTrack', {
|
|
414
|
+
'session-id': { dimension: undefined },
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('video: Safari should force play video when track becomes available', () => {
|
|
419
|
+
globalThis._isSafari = true;
|
|
420
|
+
|
|
421
|
+
vi.useFakeTimers();
|
|
248
422
|
const updateSubscription = vi.spyOn(
|
|
249
423
|
call.state,
|
|
250
424
|
'updateParticipantTracks',
|
|
@@ -280,9 +454,11 @@ describe('DynascaleManager', () => {
|
|
|
280
454
|
},
|
|
281
455
|
},
|
|
282
456
|
});
|
|
283
|
-
|
|
457
|
+
|
|
458
|
+
expect(play).toHaveBeenCalledOnce();
|
|
284
459
|
expect(videoElement.srcObject).toBe(mediaStream);
|
|
285
460
|
|
|
461
|
+
expect(cleanup).toBeDefined();
|
|
286
462
|
cleanup?.();
|
|
287
463
|
|
|
288
464
|
expect(updateSubscription).toHaveBeenLastCalledWith('videoTrack', {
|
|
@@ -89,3 +89,35 @@ const RTCRtpSenderMock = vi.fn((): Partial<typeof RTCRtpSender> => {
|
|
|
89
89
|
};
|
|
90
90
|
});
|
|
91
91
|
vi.stubGlobal('RTCRtpSender', RTCRtpSenderMock);
|
|
92
|
+
|
|
93
|
+
const AudioContextMock = vi.fn((): Partial<AudioContext> => {
|
|
94
|
+
return {
|
|
95
|
+
state: 'suspended',
|
|
96
|
+
sinkId: '',
|
|
97
|
+
// @ts-expect-error - incomplete data
|
|
98
|
+
destination: {},
|
|
99
|
+
createMediaStreamSource: vi.fn(() => {
|
|
100
|
+
return {
|
|
101
|
+
connect: vi.fn((v) => v),
|
|
102
|
+
disconnect: vi.fn(),
|
|
103
|
+
} as unknown as MediaStreamAudioSourceNode;
|
|
104
|
+
}),
|
|
105
|
+
createGain: vi.fn(() => {
|
|
106
|
+
return {
|
|
107
|
+
connect: vi.fn((v) => v),
|
|
108
|
+
disconnect: vi.fn(),
|
|
109
|
+
gain: { value: 1 },
|
|
110
|
+
} as unknown as GainNode;
|
|
111
|
+
}),
|
|
112
|
+
close: vi.fn(async function () {
|
|
113
|
+
this.state = 'closed';
|
|
114
|
+
}),
|
|
115
|
+
resume: vi.fn(async function () {
|
|
116
|
+
this.state = 'running';
|
|
117
|
+
}),
|
|
118
|
+
setSinkId: vi.fn(async function (sinkId: string) {
|
|
119
|
+
this.sinkId = sinkId;
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
vi.stubGlobal('AudioContext', AudioContextMock);
|
package/src/store/CallState.ts
CHANGED
|
@@ -149,7 +149,14 @@ export class CallState {
|
|
|
149
149
|
anonymousParticipantCount$: Observable<number>;
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
|
-
* All participants of the current call (this includes the current user and other participants as well)
|
|
152
|
+
* All participants of the current call (this includes the current user and other participants as well),
|
|
153
|
+
* unsorted. This observable only updates when participants join or leave the call.
|
|
154
|
+
*/
|
|
155
|
+
rawParticipants$: Observable<StreamVideoParticipant[]>;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* All participants of the current call (this includes the current user and other participants as well),
|
|
159
|
+
* sorted according to the current `sortByParticipantsBy` setting
|
|
153
160
|
*/
|
|
154
161
|
participants$: Observable<StreamVideoParticipant[]>;
|
|
155
162
|
|
|
@@ -323,6 +330,10 @@ export class CallState {
|
|
|
323
330
|
*
|
|
324
331
|
*/
|
|
325
332
|
constructor() {
|
|
333
|
+
this.rawParticipants$ = this.participantsSubject
|
|
334
|
+
.asObservable()
|
|
335
|
+
.pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
|
336
|
+
|
|
326
337
|
this.participants$ = this.participantsSubject.asObservable().pipe(
|
|
327
338
|
// maintain stable-sort by mutating the participants stored
|
|
328
339
|
// in the original subject
|
|
@@ -622,6 +633,13 @@ export class CallState {
|
|
|
622
633
|
return this.getCurrentValue(this.participants$);
|
|
623
634
|
}
|
|
624
635
|
|
|
636
|
+
/**
|
|
637
|
+
* The stable list of participants in the current call, unsorted.
|
|
638
|
+
*/
|
|
639
|
+
get rawParticipants() {
|
|
640
|
+
return this.getCurrentValue(this.rawParticipants$);
|
|
641
|
+
}
|
|
642
|
+
|
|
625
643
|
/**
|
|
626
644
|
* Sets the list of participants in the current call.
|
|
627
645
|
*
|