@stream-io/video-client 1.61.1 → 1.62.0

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 (62) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/index.browser.es.js +525 -178
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +525 -178
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +525 -178
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +21 -1
  9. package/dist/src/coordinator/connection/types.d.ts +1 -1
  10. package/dist/src/devices/DeviceManager.d.ts +6 -0
  11. package/dist/src/devices/DeviceManagerState.d.ts +17 -0
  12. package/dist/src/devices/MicrophoneManager.d.ts +11 -0
  13. package/dist/src/devices/devices.d.ts +5 -0
  14. package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +0 -27
  15. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +7 -2
  16. package/dist/src/helpers/DynascaleManager.d.ts +15 -0
  17. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +18 -0
  18. package/dist/src/helpers/mediaInterruptions.d.ts +14 -0
  19. package/dist/src/reporting/ClientEventReporter.d.ts +1 -1
  20. package/dist/src/ringing/reconcileRingState.d.ts +8 -7
  21. package/dist/src/rtc/BasePeerConnection.d.ts +13 -1
  22. package/dist/src/rtc/types.d.ts +7 -0
  23. package/dist/src/stats/SfuStatsReporter.d.ts +1 -13
  24. package/dist/src/stats/rtc/StatsTracer.d.ts +6 -6
  25. package/dist/src/stats/rtc/types.d.ts +0 -11
  26. package/package.json +1 -1
  27. package/src/Call.ts +110 -9
  28. package/src/StreamVideoClient.ts +8 -0
  29. package/src/__tests__/StreamVideoClient.rewatch.test.ts +136 -0
  30. package/src/coordinator/connection/types.ts +1 -1
  31. package/src/devices/DeviceManager.ts +26 -1
  32. package/src/devices/DeviceManagerState.ts +24 -0
  33. package/src/devices/MicrophoneManager.ts +79 -17
  34. package/src/devices/SpeakerManager.ts +1 -1
  35. package/src/devices/__tests__/CameraManager.test.ts +50 -1
  36. package/src/devices/__tests__/DeviceManager.test.ts +29 -0
  37. package/src/devices/__tests__/MicrophoneManager.test.ts +235 -0
  38. package/src/devices/__tests__/SpeakerManager.test.ts +27 -0
  39. package/src/devices/__tests__/devices.test.ts +10 -14
  40. package/src/devices/__tests__/mediaStreamTestHelpers.ts +11 -6
  41. package/src/devices/__tests__/mocks.ts +1 -0
  42. package/src/devices/devices.ts +7 -1
  43. package/src/gen/video/sfu/signal_rpc/signal.ts +0 -53
  44. package/src/helpers/AudioBindingsWatchdog.ts +91 -35
  45. package/src/helpers/DynascaleManager.ts +86 -11
  46. package/src/helpers/MediaPlaybackWatchdog.ts +55 -6
  47. package/src/helpers/__tests__/DynascaleManager.test.ts +253 -13
  48. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +140 -0
  49. package/src/helpers/__tests__/mediaInterruptions.test.ts +131 -0
  50. package/src/helpers/mediaInterruptions.ts +37 -0
  51. package/src/reporting/ClientEventReporter.ts +1 -1
  52. package/src/ringing/RingStatePoller.ts +1 -1
  53. package/src/ringing/__tests__/RingStatePoller.test.ts +1 -1
  54. package/src/ringing/__tests__/reconcileRingState.test.ts +33 -0
  55. package/src/ringing/reconcileRingState.ts +18 -10
  56. package/src/rtc/BasePeerConnection.ts +47 -9
  57. package/src/rtc/__tests__/Call.reconnect.test.ts +474 -0
  58. package/src/rtc/types.ts +8 -0
  59. package/src/stats/SfuStatsReporter.ts +19 -93
  60. package/src/stats/__tests__/SfuStatsReporter.test.ts +2 -18
  61. package/src/stats/rtc/StatsTracer.ts +6 -6
  62. package/src/stats/rtc/types.ts +0 -13
@@ -17,7 +17,8 @@ import {
17
17
  import { createVideoStreamForDevice } from './mediaStreamTestHelpers';
18
18
  import { TrackType } from '../../gen/video/sfu/models/models';
19
19
  import { CameraManager } from '../CameraManager';
20
- import { of } from 'rxjs';
20
+ import { getVideoDevices } from '../devices';
21
+ import { NEVER, of } from 'rxjs';
21
22
  import { PermissionsContext } from '../../permissions';
22
23
  import { Tracer } from '../../stats';
23
24
  import {
@@ -598,6 +599,54 @@ describe('CameraManager', () => {
598
599
  });
599
600
  }
600
601
  });
602
+
603
+ it('persists the label from the active track when the device list is not enumerated yet', async () => {
604
+ const storageKey = '@test/device-preferences-camera-cold-list';
605
+ const localStorageMock = createLocalStorageMock();
606
+ const originalWindow = globalThis.window;
607
+ Object.defineProperty(globalThis, 'window', {
608
+ configurable: true,
609
+ value: { localStorage: localStorageMock },
610
+ });
611
+
612
+ const device = mockVideoDevices[1];
613
+ vi.mocked(getVideoDevices).mockReturnValue(NEVER);
614
+ vi.mocked(getVideoStream).mockResolvedValue(
615
+ createVideoStreamForDevice(device.deviceId, 'user', device.label),
616
+ );
617
+ vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
618
+ of('granted'),
619
+ );
620
+
621
+ const coldListManager = new CameraManager(call, {
622
+ enabled: true,
623
+ storageKey,
624
+ });
625
+
626
+ try {
627
+ await coldListManager.enable();
628
+ await coldListManager.statusChangeSettled();
629
+
630
+ expect(coldListManager.state.selectedDevice).toBe(device.deviceId);
631
+ const [persisted] = toPreferenceList(
632
+ readPreferences(storageKey).camera,
633
+ );
634
+ expect(persisted).toEqual({
635
+ selectedDeviceId: device.deviceId,
636
+ selectedDeviceLabel: device.label,
637
+ muted: false,
638
+ });
639
+ } finally {
640
+ coldListManager.dispose();
641
+ vi.mocked(getVideoDevices).mockImplementation(() =>
642
+ of(mockVideoDevices),
643
+ );
644
+ Object.defineProperty(globalThis, 'window', {
645
+ configurable: true,
646
+ value: originalWindow,
647
+ });
648
+ }
649
+ });
601
650
  });
602
651
 
603
652
  afterEach(() => {
@@ -22,6 +22,7 @@ import { firstValueFrom, of } from 'rxjs';
22
22
  import { TrackType } from '../../gen/video/sfu/models/models';
23
23
  import { PermissionsContext } from '../../permissions';
24
24
  import { defaultDeviceId, readPreferences } from '../devicePersistence';
25
+ import { promiseWithResolvers } from '../../helpers/promise';
25
26
 
26
27
  vi.mock('../../reporting/ClientEventReporter', () => ({
27
28
  ClientEventReporter: vi.fn(function () {
@@ -172,6 +173,34 @@ describe('Device Manager', () => {
172
173
  expect(manager.state.status).toBe('disabled');
173
174
  });
174
175
 
176
+ it('clears a startup error when disabling an already disabled device', async () => {
177
+ await manager.disable();
178
+ const error = new Error('Camera failed to start');
179
+ manager.getStream.mockRejectedValueOnce(error);
180
+
181
+ await expect(manager.enable()).rejects.toThrow(error);
182
+ expect(manager.state.status).toBe('disabled');
183
+ expect(manager.state.error).toBe(error);
184
+
185
+ await manager.disable();
186
+
187
+ expect(manager.state.error).toBeUndefined();
188
+ });
189
+
190
+ it('clears a pending startup error when the queued disable completes', async () => {
191
+ const { promise, reject } = promiseWithResolvers<MediaStream>();
192
+ const error = new Error('Camera failed to start');
193
+ manager.getStream.mockReturnValueOnce(promise);
194
+
195
+ const enabling = expect(manager.enable()).rejects.toThrow(error);
196
+ const disabling = manager.disable();
197
+ reject(error);
198
+ await Promise.all([enabling, disabling]);
199
+
200
+ expect(manager.state.status).toBe('disabled');
201
+ expect(manager.state.error).toBeUndefined();
202
+ });
203
+
175
204
  it('toggle device', async () => {
176
205
  vi.spyOn(manager, 'disable');
177
206
  vi.spyOn(manager, 'enable');
@@ -327,6 +327,241 @@ describe('MicrophoneManager', () => {
327
327
  await manager.disable();
328
328
  expect(manager['startSpeakingWhileMutedDetection']).toHaveBeenCalled();
329
329
  });
330
+
331
+ describe('mute hand-off', () => {
332
+ let micTrack: MediaStreamTrack;
333
+ let clonedTrack: MediaStreamTrack;
334
+
335
+ beforeEach(() => {
336
+ vi.stubGlobal(
337
+ 'MediaStream',
338
+ vi.fn(function (this: object, tracks: MediaStreamTrack[]) {
339
+ Object.assign(this, { getTracks: () => tracks });
340
+ }),
341
+ );
342
+ const stream = mockAudioStream();
343
+ [micTrack] = stream.getAudioTracks();
344
+ [clonedTrack] = mockAudioStream().getAudioTracks();
345
+ micTrack.clone = vi.fn(() => clonedTrack);
346
+ vi.mocked(getAudioStream).mockResolvedValueOnce(stream);
347
+ manager.dispose();
348
+ const devicePersistence = { enabled: false, storageKey: '' };
349
+ manager = new MicrophoneManager(call, devicePersistence);
350
+ });
351
+
352
+ afterEach(() => {
353
+ vi.unstubAllGlobals();
354
+ });
355
+
356
+ const enableAndSettle = async () => {
357
+ await manager.enable();
358
+ await sleep(25);
359
+ vi.mocked(micTrack.clone).mockClear();
360
+ vi.mocked(getAudioStream).mockClear();
361
+ vi.mocked(createSoundDetector).mockClear();
362
+ };
363
+
364
+ it('keeps the mic captured by handing a clone to the detector', async () => {
365
+ await enableAndSettle();
366
+ await manager.disable();
367
+ await sleep(25);
368
+
369
+ expect(micTrack.readyState).toBe('ended');
370
+ expect(clonedTrack.readyState).toBe('live');
371
+ expect(createSoundDetector).toHaveBeenCalledTimes(1);
372
+ const [detectorStream] = vi.mocked(createSoundDetector).mock.calls[0];
373
+ expect(detectorStream.getTracks()).toEqual([clonedTrack]);
374
+ expect(getAudioStream).not.toHaveBeenCalled();
375
+ });
376
+
377
+ it('releases the clone after unmute opened the new capture', async () => {
378
+ await enableAndSettle();
379
+ const cleanup = vi.fn(async () => {});
380
+ vi.mocked(createSoundDetector).mockImplementationOnce(() => cleanup);
381
+ await manager.disable();
382
+ await sleep(25);
383
+ await manager.enable();
384
+ await sleep(25);
385
+
386
+ expect(cleanup).toHaveBeenCalledTimes(1);
387
+ expect(getAudioStream).toHaveBeenCalledTimes(1);
388
+ const [captureOrder] =
389
+ vi.mocked(getAudioStream).mock.invocationCallOrder;
390
+ expect(captureOrder).toBeLessThan(cleanup.mock.invocationCallOrder[0]);
391
+ });
392
+
393
+ it('releases the clone and keeps the mic live when muting fails', async () => {
394
+ await enableAndSettle();
395
+ vi.mocked(call.stopPublish).mockRejectedValueOnce(
396
+ new Error('SFU unavailable'),
397
+ );
398
+
399
+ await expect(manager.disable()).rejects.toThrow('SFU unavailable');
400
+ await sleep(25);
401
+
402
+ expect(manager.state.status).toBe('enabled');
403
+ expect(micTrack.readyState).toBe('live');
404
+ expect(clonedTrack.readyState).toBe('ended');
405
+ expect(createSoundDetector).not.toHaveBeenCalled();
406
+ });
407
+
408
+ it('releases the clone and falls back when the detector cannot start', async () => {
409
+ await enableAndSettle();
410
+ vi.mocked(createSoundDetector).mockImplementationOnce(() => {
411
+ throw new Error('AudioContext unavailable');
412
+ });
413
+
414
+ await manager.disable();
415
+ await sleep(25);
416
+
417
+ expect(manager.state.status).toBe('disabled');
418
+ expect(clonedTrack.readyState).toBe('ended');
419
+ expect(getAudioStream).toHaveBeenCalledTimes(1);
420
+ });
421
+
422
+ it('unpublishes without waiting for the detector queue', async () => {
423
+ await enableAndSettle();
424
+ let unblock!: () => void;
425
+ const blocker = new Promise<void>((resolve) => (unblock = resolve));
426
+ withoutConcurrency(
427
+ manager['soundDetectorConcurrencyTag'],
428
+ () => blocker,
429
+ );
430
+
431
+ const muted = manager.disable();
432
+ await sleep(25);
433
+
434
+ expect(call.stopPublish).toHaveBeenCalledWith(TrackType.AUDIO);
435
+ expect(micTrack.readyState).toBe('ended');
436
+ expect(clonedTrack.readyState).toBe('live');
437
+ unblock();
438
+ await muted;
439
+ });
440
+
441
+ it('does not open a capture before muting an ended mic track', async () => {
442
+ await enableAndSettle();
443
+ micTrack.stop();
444
+
445
+ await manager.disable();
446
+ await sleep(25);
447
+
448
+ expect(micTrack.clone).not.toHaveBeenCalled();
449
+ const [unpublished] = vi.mocked(call.stopPublish).mock
450
+ .invocationCallOrder;
451
+ vi.mocked(getAudioStream).mock.invocationCallOrder.forEach((order) => {
452
+ expect(order).toBeGreaterThan(unpublished);
453
+ });
454
+ });
455
+
456
+ it('releases the handed-off track when a detector already runs', async () => {
457
+ await manager['startSpeakingWhileMutedDetection']('device-1');
458
+ await manager['startSpeakingWhileMutedDetection'](
459
+ 'device-1',
460
+ clonedTrack,
461
+ );
462
+
463
+ expect(clonedTrack.readyState).toBe('ended');
464
+ });
465
+
466
+ const blockUnpublish = () => {
467
+ let unblock!: () => void;
468
+ vi.mocked(call.stopPublish).mockImplementationOnce(
469
+ () => new Promise<void>((resolve) => (unblock = resolve)),
470
+ );
471
+ return () => unblock();
472
+ };
473
+
474
+ it('falls back to a new capture when the mic ends while unpublishing', async () => {
475
+ await enableAndSettle();
476
+ const unblock = blockUnpublish();
477
+
478
+ const muted = manager.disable();
479
+ await sleep(25);
480
+ clonedTrack.stop();
481
+ unblock();
482
+ await muted;
483
+ await sleep(25);
484
+
485
+ expect(createSoundDetector).toHaveBeenCalledTimes(1);
486
+ const [detectorStream] = vi.mocked(createSoundDetector).mock.calls[0];
487
+ expect(detectorStream.getTracks()).not.toContain(clonedTrack);
488
+ expect(getAudioStream).toHaveBeenCalledTimes(1);
489
+ });
490
+
491
+ it.each([
492
+ [
493
+ 'the call is left',
494
+ () => call.state.setCallingState(CallingState.LEFT),
495
+ ],
496
+ ['sending audio is revoked', () => call.state.setOwnCapabilities([])],
497
+ [
498
+ 'the notification is disabled',
499
+ () => manager.disableSpeakingWhileMutedNotification(),
500
+ ],
501
+ ])('releases the clone when %s while unpublishing', async (_, change) => {
502
+ await enableAndSettle();
503
+ const unblock = blockUnpublish();
504
+
505
+ const muted = manager.disable();
506
+ await sleep(25);
507
+ await change();
508
+ unblock();
509
+ await muted;
510
+ await sleep(25);
511
+
512
+ expect(clonedTrack.readyState).toBe('ended');
513
+ expect(createSoundDetector).not.toHaveBeenCalled();
514
+ });
515
+
516
+ it('releases the clone when another mic is selected while unpublishing', async () => {
517
+ await enableAndSettle();
518
+ const unblock = blockUnpublish();
519
+
520
+ const muted = manager.disable();
521
+ await sleep(25);
522
+ const selected = manager.select('other-mic');
523
+ unblock();
524
+ await Promise.all([muted, selected]);
525
+ await sleep(25);
526
+
527
+ expect(clonedTrack.readyState).toBe('ended');
528
+ expect(getAudioStream).toHaveBeenCalledWith(
529
+ expect.objectContaining({ deviceId: { exact: 'other-mic' } }),
530
+ expect.any(Tracer),
531
+ );
532
+ });
533
+
534
+ it('keeps the detector when a stale unmute is handled after muting again', async () => {
535
+ await enableAndSettle();
536
+ const cleanup = vi.fn(async () => {});
537
+ vi.mocked(createSoundDetector).mockImplementationOnce(() => cleanup);
538
+ let unblock!: () => void;
539
+ const blocker = new Promise<void>((resolve) => (unblock = resolve));
540
+ withoutConcurrency(
541
+ manager['soundDetectorConcurrencyTag'],
542
+ () => blocker,
543
+ );
544
+
545
+ await manager.disable();
546
+ await manager.enable();
547
+ await manager.disable();
548
+ unblock();
549
+ await sleep(25);
550
+
551
+ expect(manager.state.status).toBe('disabled');
552
+ expect(cleanup).not.toHaveBeenCalled();
553
+ expect(clonedTrack.readyState).toBe('live');
554
+ });
555
+
556
+ it('does not hand off the mic when leaving the call', async () => {
557
+ await enableAndSettle();
558
+ call.state.setCallingState(CallingState.LEFT);
559
+ await manager.disable(true);
560
+
561
+ expect(micTrack.clone).not.toHaveBeenCalled();
562
+ expect(micTrack.readyState).toBe('ended');
563
+ });
564
+ });
330
565
  });
331
566
 
332
567
  describe('Noise Cancellation', () => {
@@ -234,6 +234,33 @@ describe('SpeakerManager.test', () => {
234
234
  expect(persistedManager.state.selectedDevice).toBe('speaker-1');
235
235
  });
236
236
 
237
+ it('does not restore a speaker when output selection is not supported', async () => {
238
+ const persistedManager = createPersistedManager();
239
+ Object.defineProperty(
240
+ persistedManager.state,
241
+ 'isDeviceSelectionSupported',
242
+ { value: false },
243
+ );
244
+ vi.spyOn(persistedManager, 'listDevices').mockReturnValue(
245
+ of([
246
+ {
247
+ deviceId: 'speaker-1',
248
+ kind: 'audiooutput',
249
+ label: 'Speaker 1',
250
+ groupId: 'speaker-group',
251
+ } as MediaDeviceInfo,
252
+ ]),
253
+ );
254
+ persist('speaker-1', 'Speaker 1');
255
+
256
+ const selectSpy = vi.spyOn(persistedManager, 'select');
257
+ // @ts-expect-error - partial data
258
+ await persistedManager.apply({});
259
+
260
+ expect(selectSpy).not.toHaveBeenCalled();
261
+ expect(persistedManager.state.selectedDevice).toBe('');
262
+ });
263
+
237
264
  it('does not select a missing persisted speaker device', async () => {
238
265
  const persistedManager = createPersistedManager();
239
266
  vi.spyOn(persistedManager, 'listDevices').mockReturnValue(
@@ -23,6 +23,7 @@ vi.mock('../BrowserPermission', () => {
23
23
  vi.mock('../../helpers/browsers', () => ({
24
24
  isSafari: vi.fn(),
25
25
  isFirefox: vi.fn(),
26
+ isWebKit: vi.fn(),
26
27
  }));
27
28
 
28
29
  type MediaDevicesMock = MediaDevices &
@@ -66,11 +67,11 @@ describe('devices', () => {
66
67
  vi.resetModules();
67
68
  });
68
69
 
69
- it('checkIfAudioOutputChangeSupported uses audio element when not Safari', async () => {
70
+ it('checkIfAudioOutputChangeSupported uses audio element when not WebKit', async () => {
70
71
  setupMediaDevices();
71
72
  const { checkIfAudioOutputChangeSupported } = await loadDevicesModule();
72
73
  const browsers = await import('../../helpers/browsers');
73
- vi.mocked(browsers.isSafari).mockReturnValue(false);
74
+ vi.mocked(browsers.isWebKit).mockReturnValue(false);
74
75
  const createElementSpy = vi
75
76
  .spyOn(document, 'createElement')
76
77
  .mockReturnValue({ setSinkId: vi.fn() } as unknown as HTMLMediaElement);
@@ -79,21 +80,16 @@ describe('devices', () => {
79
80
  expect(createElementSpy).toHaveBeenCalledWith('audio');
80
81
  });
81
82
 
82
- it('checkIfAudioOutputChangeSupported uses AudioContext in Safari', async () => {
83
+ it('checkIfAudioOutputChangeSupported is false on WebKit', async () => {
83
84
  setupMediaDevices();
84
- class AudioContextStub {}
85
- (
86
- AudioContextStub.prototype as { setSinkId?: () => Promise<void> }
87
- ).setSinkId = vi.fn();
88
- Object.defineProperty(globalThis, 'AudioContext', {
89
- configurable: true,
90
- value: AudioContextStub as typeof AudioContext,
91
- });
92
-
93
85
  const { checkIfAudioOutputChangeSupported } = await loadDevicesModule();
94
86
  const browsers = await import('../../helpers/browsers');
95
- vi.mocked(browsers.isSafari).mockReturnValue(true);
96
- expect(checkIfAudioOutputChangeSupported()).toBe(true);
87
+ vi.mocked(browsers.isWebKit).mockReturnValue(true);
88
+ vi.spyOn(document, 'createElement').mockReturnValue({
89
+ setSinkId: vi.fn(),
90
+ } as unknown as HTMLMediaElement);
91
+
92
+ expect(checkIfAudioOutputChangeSupported()).toBe(false);
97
93
  });
98
94
 
99
95
  it('getAudioDevices prompts and filters devices', async () => {
@@ -10,6 +10,7 @@ const createTrack = (
10
10
  getSettings: () => settings,
11
11
  enabled: true,
12
12
  readyState: 'live',
13
+ clone: () => createTrack(settings, extras),
13
14
  stop: () => {
14
15
  // @ts-expect-error read-only property
15
16
  track.readyState = 'ended';
@@ -43,13 +44,17 @@ export const createAudioStreamForDevice = (
43
44
  export const createVideoStreamForDevice = (
44
45
  deviceId: string,
45
46
  facingMode: 'user' | 'environment' = 'user',
47
+ label = '',
46
48
  ): MediaStream => {
47
- const track = createTrack({
48
- deviceId,
49
- width: 1280,
50
- height: 720,
51
- facingMode,
52
- });
49
+ const track = createTrack(
50
+ {
51
+ deviceId,
52
+ width: 1280,
53
+ height: 720,
54
+ facingMode,
55
+ },
56
+ { label },
57
+ );
53
58
 
54
59
  return {
55
60
  getTracks: () => [track],
@@ -157,6 +157,7 @@ export const mockAudioStream = () => {
157
157
  label: mockAudioDevices[0].label,
158
158
  enabled: true,
159
159
  readyState: 'live',
160
+ clone: () => mockAudioStream().getAudioTracks()[0],
160
161
  stop: () => {
161
162
  track.readyState = 'ended';
162
163
  },
@@ -11,7 +11,7 @@ import {
11
11
  } from 'rxjs';
12
12
  import { BrowserPermission } from './BrowserPermission';
13
13
  import { lazy } from '../helpers/lazy';
14
- import { isFirefox } from '../helpers/browsers';
14
+ import { isFirefox, isWebKit } from '../helpers/browsers';
15
15
  import { dumpStream, Tracer } from '../stats';
16
16
  import { getCurrentValue } from '../store/rxUtils';
17
17
  import { videoLoggerSystem } from '../logger';
@@ -58,9 +58,15 @@ const getDevices = (
58
58
  /**
59
59
  * Tells if the browser supports audio output change on 'audio' elements,
60
60
  * see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId.
61
+ *
62
+ * WebKit (Safari, iOS browsers, WKWebView) is reported as unsupported:
63
+ * setSinkId requires a user gesture, a per-device output can run at a
64
+ * different sample rate than the remote audio mixer (pitched/slowed audio),
65
+ * and audio goes silent when the selected device disconnects.
61
66
  */
62
67
  export const checkIfAudioOutputChangeSupported = () => {
63
68
  if (typeof document === 'undefined') return false;
69
+ if (isWebKit()) return false;
64
70
  const element = document.createElement('audio');
65
71
  return 'setSinkId' in element;
66
72
  };
@@ -12,7 +12,6 @@ import { PerformanceStats } from '../models/models';
12
12
  import { RTMPIngress } from '../models/models';
13
13
  import { AppleState } from '../models/models';
14
14
  import { AndroidState } from '../models/models';
15
- import { InputDevices } from '../models/models';
16
15
  import { RemoteOutboundRtp } from '../models/models';
17
16
  import { RemoteInboundRtp } from '../models/models';
18
17
  import { OutboundRtp } from '../models/models';
@@ -135,14 +134,6 @@ export interface SendStatsRequest {
135
134
  * @generated from protobuf field: string session_id = 1;
136
135
  */
137
136
  sessionId: string;
138
- /**
139
- * @generated from protobuf field: string subscriber_stats = 2;
140
- */
141
- subscriberStats: string;
142
- /**
143
- * @generated from protobuf field: string publisher_stats = 3;
144
- */
145
- publisherStats: string;
146
137
  /**
147
138
  * @generated from protobuf field: string webrtc_version = 4;
148
139
  */
@@ -155,14 +146,6 @@ export interface SendStatsRequest {
155
146
  * @generated from protobuf field: string sdk_version = 6;
156
147
  */
157
148
  sdkVersion: string;
158
- /**
159
- * @generated from protobuf field: stream.video.sfu.models.InputDevices audio_devices = 7;
160
- */
161
- audioDevices?: InputDevices;
162
- /**
163
- * @generated from protobuf field: stream.video.sfu.models.InputDevices video_devices = 8;
164
- */
165
- videoDevices?: InputDevices;
166
149
  /**
167
150
  * @generated from protobuf oneof: device_state
168
151
  */
@@ -192,16 +175,6 @@ export interface SendStatsRequest {
192
175
  * @generated from protobuf field: stream.video.sfu.models.RTMPIngress rtmp = 12;
193
176
  */
194
177
  rtmp?: RTMPIngress;
195
- /**
196
- * @deprecated
197
- * @generated from protobuf field: string subscriber_rtc_stats = 13 [deprecated = true];
198
- */
199
- subscriberRtcStats: string;
200
- /**
201
- * @deprecated
202
- * @generated from protobuf field: string publisher_rtc_stats = 14 [deprecated = true];
203
- */
204
- publisherRtcStats: string;
205
178
  /**
206
179
  * @generated from protobuf field: string rtc_stats = 15;
207
180
  */
@@ -600,18 +573,6 @@ class SendStatsRequest$Type extends MessageType<SendStatsRequest> {
600
573
  constructor() {
601
574
  super('stream.video.sfu.signal.SendStatsRequest', [
602
575
  { no: 1, name: 'session_id', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
603
- {
604
- no: 2,
605
- name: 'subscriber_stats',
606
- kind: 'scalar',
607
- T: 9 /*ScalarType.STRING*/,
608
- },
609
- {
610
- no: 3,
611
- name: 'publisher_stats',
612
- kind: 'scalar',
613
- T: 9 /*ScalarType.STRING*/,
614
- },
615
576
  {
616
577
  no: 4,
617
578
  name: 'webrtc_version',
@@ -625,8 +586,6 @@ class SendStatsRequest$Type extends MessageType<SendStatsRequest> {
625
586
  kind: 'scalar',
626
587
  T: 9 /*ScalarType.STRING*/,
627
588
  },
628
- { no: 7, name: 'audio_devices', kind: 'message', T: () => InputDevices },
629
- { no: 8, name: 'video_devices', kind: 'message', T: () => InputDevices },
630
589
  {
631
590
  no: 9,
632
591
  name: 'android',
@@ -643,18 +602,6 @@ class SendStatsRequest$Type extends MessageType<SendStatsRequest> {
643
602
  },
644
603
  { no: 11, name: 'telemetry', kind: 'message', T: () => Telemetry },
645
604
  { no: 12, name: 'rtmp', kind: 'message', T: () => RTMPIngress },
646
- {
647
- no: 13,
648
- name: 'subscriber_rtc_stats',
649
- kind: 'scalar',
650
- T: 9 /*ScalarType.STRING*/,
651
- },
652
- {
653
- no: 14,
654
- name: 'publisher_rtc_stats',
655
- kind: 'scalar',
656
- T: 9 /*ScalarType.STRING*/,
657
- },
658
605
  { no: 15, name: 'rtc_stats', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
659
606
  {
660
607
  no: 16,