@stream-io/video-client 1.61.2 → 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 +12 -0
- package/dist/index.browser.es.js +247 -29
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +247 -29
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +247 -29
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +21 -1
- 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/rtc/BasePeerConnection.d.ts +13 -1
- package/dist/src/rtc/types.d.ts +7 -0
- package/package.json +1 -1
- package/src/Call.ts +110 -6
- package/src/devices/DeviceManager.ts +6 -0
- 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__/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 +1 -0
- package/src/devices/__tests__/mocks.ts +1 -0
- package/src/devices/devices.ts +7 -1
- package/src/helpers/DynascaleManager.ts +2 -2
- package/src/helpers/__tests__/DynascaleManager.test.ts +6 -4
- package/src/rtc/BasePeerConnection.ts +47 -9
- package/src/rtc/__tests__/Call.reconnect.test.ts +474 -0
- package/src/rtc/types.ts +8 -0
|
@@ -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 () => {
|
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
|
};
|
|
@@ -323,7 +323,7 @@ export class DynascaleManager {
|
|
|
323
323
|
deviceId: string,
|
|
324
324
|
audioContext: AudioContext | undefined,
|
|
325
325
|
) => {
|
|
326
|
-
if (!deviceId) return;
|
|
326
|
+
if (!deviceId || !this.speaker.state.isDeviceSelectionSupported) return;
|
|
327
327
|
if ('setSinkId' in audioElement) {
|
|
328
328
|
audioElement.setSinkId(deviceId).catch((e) => {
|
|
329
329
|
this.logger.warn(`Can't to set AudioElement sinkId`, e);
|
|
@@ -430,7 +430,7 @@ export class DynascaleManager {
|
|
|
430
430
|
});
|
|
431
431
|
});
|
|
432
432
|
|
|
433
|
-
const sinkIdSubscription = !
|
|
433
|
+
const sinkIdSubscription = !this.speaker.state.isDeviceSelectionSupported
|
|
434
434
|
? null
|
|
435
435
|
: this.speaker.state.selectedDevice$.subscribe((deviceId) => {
|
|
436
436
|
const audioContext = this.getOrCreateAudioContext();
|
|
@@ -29,6 +29,7 @@ vi.mock(import('../browsers'), async (importOriginal) => {
|
|
|
29
29
|
return {
|
|
30
30
|
...module,
|
|
31
31
|
isSafari: () => globalThis._isSafari ?? false,
|
|
32
|
+
isWebKit: () => false,
|
|
32
33
|
};
|
|
33
34
|
});
|
|
34
35
|
|
|
@@ -191,6 +192,9 @@ describe('DynascaleManager', () => {
|
|
|
191
192
|
it('audio: Safari should use AudioContext for audio playback', () => {
|
|
192
193
|
globalThis._isSafari = true;
|
|
193
194
|
dynascaleManager.setUseWebAudio(true); // enabled by default on Safari
|
|
195
|
+
Object.defineProperty(call.speaker.state, 'isDeviceSelectionSupported', {
|
|
196
|
+
value: false,
|
|
197
|
+
});
|
|
194
198
|
|
|
195
199
|
vi.useFakeTimers();
|
|
196
200
|
const audioElement = document.createElement('audio');
|
|
@@ -257,11 +261,9 @@ describe('DynascaleManager', () => {
|
|
|
257
261
|
expect(gainNode.connect).toHaveBeenCalledWith(audioContext.destination);
|
|
258
262
|
|
|
259
263
|
call.speaker.select('different-device-id');
|
|
260
|
-
expect(audioElement.setSinkId).
|
|
261
|
-
'different-device-id',
|
|
262
|
-
);
|
|
264
|
+
expect(audioElement.setSinkId).not.toHaveBeenCalled();
|
|
263
265
|
// @ts-expect-error sinkId isn't available in the TS definition
|
|
264
|
-
expect(audioContext.sinkId).toBe('different-device-id');
|
|
266
|
+
expect(audioContext.sinkId).not.toBe('different-device-id');
|
|
265
267
|
|
|
266
268
|
call.speaker.setVolume(0.5);
|
|
267
269
|
expect(audioElement.volume).toBe(0.5);
|
|
@@ -19,6 +19,7 @@ import { toJSON } from './helpers/iceCandiates';
|
|
|
19
19
|
import {
|
|
20
20
|
BasePeerConnectionOpts,
|
|
21
21
|
OnIceConnected,
|
|
22
|
+
OnNoLocalCandidates,
|
|
22
23
|
OnPeerConnectionStateChange,
|
|
23
24
|
OnReconnectionNeeded,
|
|
24
25
|
OnRemoteTrackUnmute,
|
|
@@ -43,10 +44,12 @@ export abstract class BasePeerConnection {
|
|
|
43
44
|
|
|
44
45
|
protected onReconnectionNeeded?: OnReconnectionNeeded;
|
|
45
46
|
private onIceConnected?: OnIceConnected;
|
|
47
|
+
private onNoLocalCandidates?: OnNoLocalCandidates;
|
|
46
48
|
private onPeerConnectionStateChange?: OnPeerConnectionStateChange;
|
|
47
49
|
protected onRemoteTrackUnmute?: OnRemoteTrackUnmute;
|
|
48
50
|
private readonly iceRestartDelay: number;
|
|
49
51
|
private iceHasEverConnected = false;
|
|
52
|
+
private localCandidatesGathered = 0;
|
|
50
53
|
private iceRestartTimeout?: NodeJS.Timeout;
|
|
51
54
|
private preConnectStuckTimeout?: NodeJS.Timeout;
|
|
52
55
|
protected isIceRestarting = false;
|
|
@@ -73,6 +76,7 @@ export abstract class BasePeerConnection {
|
|
|
73
76
|
dispatcher,
|
|
74
77
|
onReconnectionNeeded,
|
|
75
78
|
onIceConnected,
|
|
79
|
+
onNoLocalCandidates,
|
|
76
80
|
onPeerConnectionStateChange,
|
|
77
81
|
onRemoteTrackUnmute,
|
|
78
82
|
tag,
|
|
@@ -93,6 +97,7 @@ export abstract class BasePeerConnection {
|
|
|
93
97
|
this.tag = tag;
|
|
94
98
|
this.onReconnectionNeeded = onReconnectionNeeded;
|
|
95
99
|
this.onIceConnected = onIceConnected;
|
|
100
|
+
this.onNoLocalCandidates = onNoLocalCandidates;
|
|
96
101
|
this.onPeerConnectionStateChange = onPeerConnectionStateChange;
|
|
97
102
|
this.onRemoteTrackUnmute = onRemoteTrackUnmute;
|
|
98
103
|
this.logger = videoLoggerSystem.getLogger(
|
|
@@ -148,6 +153,7 @@ export abstract class BasePeerConnection {
|
|
|
148
153
|
this.preConnectStuckTimeout = undefined;
|
|
149
154
|
this.onReconnectionNeeded = undefined;
|
|
150
155
|
this.onIceConnected = undefined;
|
|
156
|
+
this.onNoLocalCandidates = undefined;
|
|
151
157
|
this.onPeerConnectionStateChange = undefined;
|
|
152
158
|
this.onRemoteTrackUnmute = undefined;
|
|
153
159
|
this.isDisposed = true;
|
|
@@ -285,6 +291,20 @@ export abstract class BasePeerConnection {
|
|
|
285
291
|
return this.trackIdToTrackType.get(trackId);
|
|
286
292
|
};
|
|
287
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Returns `true` once ICE has reached `connected`/`completed` at least
|
|
296
|
+
* once during the lifetime of this peer connection.
|
|
297
|
+
*/
|
|
298
|
+
hasIceEverConnected = () => this.iceHasEverConnected;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Returns `true` when the latest ICE gathering round completed without
|
|
302
|
+
* producing a single local candidate.
|
|
303
|
+
*/
|
|
304
|
+
hasNoLocalCandidates = () =>
|
|
305
|
+
this.pc.iceGatheringState === 'complete' &&
|
|
306
|
+
this.localCandidatesGathered === 0;
|
|
307
|
+
|
|
288
308
|
/**
|
|
289
309
|
* Checks if the `RTCPeerConnection` is healthy.
|
|
290
310
|
* It checks the ICE connection state and the peer connection state.
|
|
@@ -311,6 +331,7 @@ export abstract class BasePeerConnection {
|
|
|
311
331
|
this.logger.debug('null ice candidate');
|
|
312
332
|
return;
|
|
313
333
|
}
|
|
334
|
+
if (candidate.candidate) this.localCandidatesGathered++;
|
|
314
335
|
|
|
315
336
|
const iceCandidate = toJSON(candidate);
|
|
316
337
|
this.sfuClient
|
|
@@ -389,6 +410,20 @@ export abstract class BasePeerConnection {
|
|
|
389
410
|
private handleConnectionStateUpdate = (
|
|
390
411
|
state: RTCIceConnectionState | RTCPeerConnectionState,
|
|
391
412
|
) => {
|
|
413
|
+
// Fire `onIceConnected` exactly once per peer-connection lifetime, the
|
|
414
|
+
// first time ICE reaches `connected`/`completed` end-to-end. Recorded
|
|
415
|
+
// ahead of the guards below so a connection established while offline,
|
|
416
|
+
// reconnecting or restarting ICE is not missed. Used by `Call` to reset
|
|
417
|
+
// the unsupported-network failure counter only after WebRTC has actually
|
|
418
|
+
// recovered, not merely on SFU join.
|
|
419
|
+
if (
|
|
420
|
+
(state === 'connected' || state === 'completed') &&
|
|
421
|
+
!this.iceHasEverConnected
|
|
422
|
+
) {
|
|
423
|
+
this.iceHasEverConnected = true;
|
|
424
|
+
this.onIceConnected?.(this.peerType);
|
|
425
|
+
}
|
|
426
|
+
|
|
392
427
|
const { callingState } = this.state;
|
|
393
428
|
if (callingState === CallingState.OFFLINE) return;
|
|
394
429
|
if (callingState === CallingState.RECONNECTING) return;
|
|
@@ -466,14 +501,6 @@ export abstract class BasePeerConnection {
|
|
|
466
501
|
|
|
467
502
|
case 'connected':
|
|
468
503
|
case 'completed':
|
|
469
|
-
// Fire `onIceConnected` exactly once per peer-connection lifetime —
|
|
470
|
-
// the first time ICE reaches `connected`/`completed` end-to-end.
|
|
471
|
-
// Used by `Call` to reset the unsupported-network failure counter
|
|
472
|
-
// only after WebRTC has actually recovered, not merely on SFU join.
|
|
473
|
-
if (!this.iceHasEverConnected) {
|
|
474
|
-
this.iceHasEverConnected = true;
|
|
475
|
-
this.onIceConnected?.(this.peerType);
|
|
476
|
-
}
|
|
477
504
|
// clear any scheduled restartICE since the connection is healthy
|
|
478
505
|
if (this.iceRestartTimeout) {
|
|
479
506
|
this.logger.info('connected connection, canceling restartICE');
|
|
@@ -502,7 +529,18 @@ export abstract class BasePeerConnection {
|
|
|
502
529
|
* Handles the ICE gathering state change event.
|
|
503
530
|
*/
|
|
504
531
|
private onIceGatherChange = () => {
|
|
505
|
-
|
|
532
|
+
const state = this.pc.iceGatheringState;
|
|
533
|
+
this.logger.debug(`ICE Gathering State`, state);
|
|
534
|
+
if (state === 'gathering') {
|
|
535
|
+
this.localCandidatesGathered = 0;
|
|
536
|
+
} else if (
|
|
537
|
+
state === 'complete' &&
|
|
538
|
+
this.localCandidatesGathered === 0 &&
|
|
539
|
+
!this.iceHasEverConnected
|
|
540
|
+
) {
|
|
541
|
+
this.logger.info('ICE gathering completed without local candidates');
|
|
542
|
+
this.onNoLocalCandidates?.(this.peerType);
|
|
543
|
+
}
|
|
506
544
|
};
|
|
507
545
|
|
|
508
546
|
/**
|