@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.
@@ -4,7 +4,16 @@
4
4
 
5
5
  import '../../rtc/__tests__/mocks/webrtc.mocks';
6
6
 
7
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
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
- expect(play).toHaveBeenCalled();
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);
@@ -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
  *