@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.
- package/CHANGELOG.md +21 -0
- package/dist/index.browser.es.js +525 -178
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +525 -178
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +525 -178
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +21 -1
- package/dist/src/coordinator/connection/types.d.ts +1 -1
- package/dist/src/devices/DeviceManager.d.ts +6 -0
- package/dist/src/devices/DeviceManagerState.d.ts +17 -0
- package/dist/src/devices/MicrophoneManager.d.ts +11 -0
- package/dist/src/devices/devices.d.ts +5 -0
- package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +0 -27
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +7 -2
- package/dist/src/helpers/DynascaleManager.d.ts +15 -0
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +18 -0
- package/dist/src/helpers/mediaInterruptions.d.ts +14 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +1 -1
- package/dist/src/ringing/reconcileRingState.d.ts +8 -7
- package/dist/src/rtc/BasePeerConnection.d.ts +13 -1
- package/dist/src/rtc/types.d.ts +7 -0
- package/dist/src/stats/SfuStatsReporter.d.ts +1 -13
- package/dist/src/stats/rtc/StatsTracer.d.ts +6 -6
- package/dist/src/stats/rtc/types.d.ts +0 -11
- package/package.json +1 -1
- package/src/Call.ts +110 -9
- package/src/StreamVideoClient.ts +8 -0
- package/src/__tests__/StreamVideoClient.rewatch.test.ts +136 -0
- package/src/coordinator/connection/types.ts +1 -1
- package/src/devices/DeviceManager.ts +26 -1
- package/src/devices/DeviceManagerState.ts +24 -0
- package/src/devices/MicrophoneManager.ts +79 -17
- package/src/devices/SpeakerManager.ts +1 -1
- package/src/devices/__tests__/CameraManager.test.ts +50 -1
- package/src/devices/__tests__/DeviceManager.test.ts +29 -0
- package/src/devices/__tests__/MicrophoneManager.test.ts +235 -0
- package/src/devices/__tests__/SpeakerManager.test.ts +27 -0
- package/src/devices/__tests__/devices.test.ts +10 -14
- package/src/devices/__tests__/mediaStreamTestHelpers.ts +11 -6
- package/src/devices/__tests__/mocks.ts +1 -0
- package/src/devices/devices.ts +7 -1
- package/src/gen/video/sfu/signal_rpc/signal.ts +0 -53
- package/src/helpers/AudioBindingsWatchdog.ts +91 -35
- package/src/helpers/DynascaleManager.ts +86 -11
- package/src/helpers/MediaPlaybackWatchdog.ts +55 -6
- package/src/helpers/__tests__/DynascaleManager.test.ts +253 -13
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +140 -0
- package/src/helpers/__tests__/mediaInterruptions.test.ts +131 -0
- package/src/helpers/mediaInterruptions.ts +37 -0
- package/src/reporting/ClientEventReporter.ts +1 -1
- package/src/ringing/RingStatePoller.ts +1 -1
- package/src/ringing/__tests__/RingStatePoller.test.ts +1 -1
- package/src/ringing/__tests__/reconcileRingState.test.ts +33 -0
- package/src/ringing/reconcileRingState.ts +18 -10
- package/src/rtc/BasePeerConnection.ts +47 -9
- package/src/rtc/__tests__/Call.reconnect.test.ts +474 -0
- package/src/rtc/types.ts +8 -0
- package/src/stats/SfuStatsReporter.ts +19 -93
- package/src/stats/__tests__/SfuStatsReporter.test.ts +2 -18
- package/src/stats/rtc/StatsTracer.ts +6 -6
- 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 {
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
96
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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],
|
package/src/devices/devices.ts
CHANGED
|
@@ -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,
|