@stream-io/video-client 1.48.0 → 1.50.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 +25 -0
- package/dist/index.browser.es.js +1497 -677
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1497 -677
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1497 -677
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +77 -4
- package/dist/src/StreamSfuClient.d.ts +8 -1
- package/dist/src/coordinator/connection/client.d.ts +1 -1
- package/dist/src/coordinator/connection/connection.d.ts +31 -25
- package/dist/src/coordinator/connection/types.d.ts +14 -0
- package/dist/src/coordinator/connection/utils.d.ts +1 -0
- package/dist/src/devices/DeviceManager.d.ts +3 -0
- package/dist/src/devices/DeviceManagerState.d.ts +13 -1
- package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
- package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
- package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
- package/dist/src/helpers/DynascaleManager.d.ts +8 -86
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
- package/dist/src/helpers/SlidingWindowRateLimiter.d.ts +28 -0
- package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
- package/dist/src/helpers/ViewportTracker.d.ts +11 -17
- package/dist/src/helpers/browsers.d.ts +13 -0
- package/dist/src/helpers/concurrency.d.ts +6 -4
- package/dist/src/rtc/BasePeerConnection.d.ts +11 -2
- package/dist/src/rtc/Publisher.d.ts +17 -0
- package/dist/src/rtc/Subscriber.d.ts +1 -0
- package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
- package/dist/src/rtc/index.d.ts +1 -0
- package/dist/src/rtc/types.d.ts +33 -1
- package/dist/src/stats/rtc/types.d.ts +1 -1
- package/dist/src/store/rxUtils.d.ts +9 -0
- package/dist/src/types.d.ts +18 -0
- package/package.json +2 -2
- package/src/Call.ts +268 -40
- package/src/StreamSfuClient.ts +75 -12
- package/src/__tests__/Call.lifecycle.test.ts +67 -0
- package/src/__tests__/Call.publishing.test.ts +103 -0
- package/src/__tests__/StreamSfuClient.test.ts +275 -0
- package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
- package/src/coordinator/connection/client.ts +1 -1
- package/src/coordinator/connection/connection.ts +149 -96
- package/src/coordinator/connection/types.ts +15 -0
- package/src/coordinator/connection/utils.ts +15 -0
- package/src/devices/DeviceManager.ts +92 -32
- package/src/devices/DeviceManagerState.ts +20 -1
- package/src/devices/__tests__/DeviceManager.test.ts +283 -0
- package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
- package/src/devices/__tests__/mocks.ts +2 -0
- package/src/devices/devices.ts +2 -1
- package/src/gen/video/sfu/event/events.ts +15 -0
- package/src/gen/video/sfu/models/models.ts +44 -0
- package/src/helpers/AudioBindingsWatchdog.ts +10 -7
- package/src/helpers/BlockedAudioTracker.ts +74 -0
- package/src/helpers/DynascaleManager.ts +46 -337
- package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
- package/src/helpers/SlidingWindowRateLimiter.ts +49 -0
- package/src/helpers/TrackSubscriptionManager.ts +243 -0
- package/src/helpers/ViewportTracker.ts +74 -19
- package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
- package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
- package/src/helpers/__tests__/SlidingWindowRateLimiter.test.ts +43 -0
- package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
- package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
- package/src/helpers/__tests__/browsers.test.ts +85 -1
- package/src/helpers/browsers.ts +24 -0
- package/src/helpers/concurrency.ts +9 -10
- package/src/rpc/retryable.ts +0 -1
- package/src/rtc/BasePeerConnection.ts +96 -6
- package/src/rtc/Publisher.ts +49 -2
- package/src/rtc/Subscriber.ts +42 -14
- package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
- package/src/rtc/__tests__/Publisher.test.ts +332 -10
- package/src/rtc/__tests__/Subscriber.test.ts +202 -1
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
- package/src/rtc/helpers/degradationPreference.ts +22 -0
- package/src/rtc/index.ts +1 -0
- package/src/rtc/types.ts +38 -1
- package/src/stats/rtc/types.ts +1 -0
- package/src/store/__tests__/rxUtils.test.ts +276 -0
- package/src/store/rxUtils.ts +19 -0
- package/src/types.ts +19 -0
|
@@ -19,6 +19,9 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
19
19
|
protected mediaStreamSubject = new BehaviorSubject<MediaStream | undefined>(
|
|
20
20
|
undefined,
|
|
21
21
|
);
|
|
22
|
+
protected rootMediaStreamSubject = new BehaviorSubject<
|
|
23
|
+
MediaStream | undefined
|
|
24
|
+
>(undefined);
|
|
22
25
|
protected selectedDeviceSubject = new BehaviorSubject<string | undefined>(
|
|
23
26
|
undefined,
|
|
24
27
|
);
|
|
@@ -33,10 +36,16 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
38
|
* An Observable that emits the current media stream, or `undefined` if the device is currently disabled.
|
|
36
|
-
*
|
|
37
39
|
*/
|
|
38
40
|
mediaStream$ = this.mediaStreamSubject.asObservable();
|
|
39
41
|
|
|
42
|
+
/**
|
|
43
|
+
* An Observable that emits the raw device media stream (before any filters are applied),
|
|
44
|
+
* or `undefined` if the device is currently disabled. When no filters are active, this
|
|
45
|
+
* emits the same stream as `mediaStream$`.
|
|
46
|
+
*/
|
|
47
|
+
rootMediaStream$ = this.rootMediaStreamSubject.asObservable();
|
|
48
|
+
|
|
40
49
|
/**
|
|
41
50
|
* An Observable that emits the currently selected device
|
|
42
51
|
*/
|
|
@@ -134,6 +143,15 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
134
143
|
return RxUtils.getCurrentValue(this.mediaStream$);
|
|
135
144
|
}
|
|
136
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The raw device media stream (before any filters are applied), or `undefined`
|
|
148
|
+
* if the device is currently disabled. When no filters are active, this is the
|
|
149
|
+
* same as `mediaStream`.
|
|
150
|
+
*/
|
|
151
|
+
get rootMediaStream() {
|
|
152
|
+
return RxUtils.getCurrentValue(this.rootMediaStream$);
|
|
153
|
+
}
|
|
154
|
+
|
|
137
155
|
/**
|
|
138
156
|
* @internal
|
|
139
157
|
* @param status
|
|
@@ -163,6 +181,7 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
163
181
|
rootStream: MediaStream | undefined,
|
|
164
182
|
) {
|
|
165
183
|
RxUtils.setCurrentValue(this.mediaStreamSubject, stream);
|
|
184
|
+
RxUtils.setCurrentValue(this.rootMediaStreamSubject, rootStream);
|
|
166
185
|
if (rootStream) {
|
|
167
186
|
this.setDevice(this.getDeviceIdFromStream(rootStream));
|
|
168
187
|
}
|
|
@@ -395,6 +395,289 @@ describe('Device Manager', () => {
|
|
|
395
395
|
vi.useRealTimers();
|
|
396
396
|
});
|
|
397
397
|
|
|
398
|
+
describe('interruptedTracks (hardware mute/unmute events)', () => {
|
|
399
|
+
const localSessionId = 'local-session-id';
|
|
400
|
+
|
|
401
|
+
beforeEach(() => {
|
|
402
|
+
manager['call'].state.setParticipants([
|
|
403
|
+
{
|
|
404
|
+
sessionId: localSessionId,
|
|
405
|
+
userId: 'local-user',
|
|
406
|
+
isLocalParticipant: true,
|
|
407
|
+
publishedTracks: [],
|
|
408
|
+
} as any,
|
|
409
|
+
]);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const fireOn = async (track: MockTrack, event: 'mute' | 'unmute') => {
|
|
413
|
+
const handler = track.eventHandlers[event] as Function;
|
|
414
|
+
await handler();
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const currentTrack = () =>
|
|
418
|
+
manager.state.mediaStream?.getTracks()[0] as MockTrack;
|
|
419
|
+
|
|
420
|
+
const isInterrupted = () =>
|
|
421
|
+
!!manager['call'].state.localParticipant?.interruptedTracks?.includes(
|
|
422
|
+
TrackType.VIDEO,
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
it('adds the track type on a mute event without touching status', async () => {
|
|
426
|
+
await manager.enable();
|
|
427
|
+
expect(manager.state.status).toBe('enabled');
|
|
428
|
+
expect(isInterrupted()).toBe(false);
|
|
429
|
+
|
|
430
|
+
await fireOn(currentTrack(), 'mute');
|
|
431
|
+
|
|
432
|
+
expect(isInterrupted()).toBe(true);
|
|
433
|
+
expect(manager.state.status).toBe('enabled');
|
|
434
|
+
expect(manager.state.optimisticStatus).toBe('enabled');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('removes the track type on the matching unmute event', async () => {
|
|
438
|
+
await manager.enable();
|
|
439
|
+
const track = currentTrack();
|
|
440
|
+
await fireOn(track, 'mute');
|
|
441
|
+
expect(isInterrupted()).toBe(true);
|
|
442
|
+
|
|
443
|
+
await fireOn(track, 'unmute');
|
|
444
|
+
|
|
445
|
+
expect(isInterrupted()).toBe(false);
|
|
446
|
+
expect(manager.state.status).toBe('enabled');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('notifies the SFU for video track mute/unmute events', async () => {
|
|
450
|
+
await manager.enable();
|
|
451
|
+
const track = currentTrack();
|
|
452
|
+
|
|
453
|
+
await fireOn(track, 'mute');
|
|
454
|
+
expect(manager['call'].notifyTrackMuteState).toHaveBeenCalledWith(
|
|
455
|
+
true,
|
|
456
|
+
TrackType.VIDEO,
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
await fireOn(track, 'unmute');
|
|
460
|
+
expect(manager['call'].notifyTrackMuteState).toHaveBeenCalledWith(
|
|
461
|
+
false,
|
|
462
|
+
TrackType.VIDEO,
|
|
463
|
+
);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('emits localParticipant$ transitions to subscribers', async () => {
|
|
467
|
+
const observed: boolean[] = [];
|
|
468
|
+
const subscription = manager['call'].state.localParticipant$.subscribe(
|
|
469
|
+
(p) => observed.push(!!p?.interruptedTracks?.includes(TrackType.VIDEO)),
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
await manager.enable();
|
|
473
|
+
const track = currentTrack();
|
|
474
|
+
await fireOn(track, 'mute');
|
|
475
|
+
await fireOn(track, 'unmute');
|
|
476
|
+
|
|
477
|
+
expect(observed).toContain(true);
|
|
478
|
+
expect(observed[observed.length - 1]).toBe(false);
|
|
479
|
+
subscription.unsubscribe();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('reacquires a fresh stream when the device is replaced mid-interruption', async () => {
|
|
483
|
+
vi.useFakeTimers();
|
|
484
|
+
emitDeviceIds(mockVideoDevices);
|
|
485
|
+
|
|
486
|
+
await manager.enable();
|
|
487
|
+
const device = mockVideoDevices[0];
|
|
488
|
+
await manager.select(device.deviceId);
|
|
489
|
+
await fireOn(currentTrack(), 'mute');
|
|
490
|
+
expect(isInterrupted()).toBe(true);
|
|
491
|
+
expect(manager.state.status).toBe('enabled');
|
|
492
|
+
|
|
493
|
+
manager.getStream.mockClear();
|
|
494
|
+
|
|
495
|
+
emitDeviceIds([
|
|
496
|
+
{ ...device, groupId: device.groupId + 'new' },
|
|
497
|
+
...mockVideoDevices.slice(1),
|
|
498
|
+
]);
|
|
499
|
+
|
|
500
|
+
await vi.runAllTimersAsync();
|
|
501
|
+
|
|
502
|
+
// Status stays 'enabled' so the replacement flows through
|
|
503
|
+
// applySettingsToStream, which forces a fresh getStream call.
|
|
504
|
+
expect(manager.getStream).toHaveBeenCalled();
|
|
505
|
+
expect(manager.state.status).toBe('enabled');
|
|
506
|
+
vi.useRealTimers();
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('leaves manager.enabled === true so capability cleanup can still disable it', async () => {
|
|
510
|
+
await manager.enable();
|
|
511
|
+
await fireOn(currentTrack(), 'mute');
|
|
512
|
+
|
|
513
|
+
// `enabled` continues to reflect requested-publishing intent. Code
|
|
514
|
+
// that revokes SEND_AUDIO / SEND_VIDEO at the Call layer iterates
|
|
515
|
+
// managers whose `enabled` is true; if system-muted hid that bit,
|
|
516
|
+
// the cleanup would skip a still-published track.
|
|
517
|
+
expect(manager.enabled).toBe(true);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('clears interruptedTracks when the user toggles the device off and back on', async () => {
|
|
521
|
+
await manager.enable();
|
|
522
|
+
await fireOn(currentTrack(), 'mute');
|
|
523
|
+
expect(isInterrupted()).toBe(true);
|
|
524
|
+
|
|
525
|
+
await manager.disable();
|
|
526
|
+
// Stream is cleared on disable; the prior hardware-mute signal
|
|
527
|
+
// belonged to the now-gone track.
|
|
528
|
+
expect(isInterrupted()).toBe(false);
|
|
529
|
+
|
|
530
|
+
await manager.enable();
|
|
531
|
+
// Re-acquired stream is fresh; the stale flag must not carry over.
|
|
532
|
+
expect(isInterrupted()).toBe(false);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('clears interruptedTracks when select() swaps to a different device', async () => {
|
|
536
|
+
await manager.enable();
|
|
537
|
+
await fireOn(currentTrack(), 'mute');
|
|
538
|
+
expect(isInterrupted()).toBe(true);
|
|
539
|
+
|
|
540
|
+
await manager.select(mockVideoDevices[1].deviceId);
|
|
541
|
+
|
|
542
|
+
expect(isInterrupted()).toBe(false);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('removes mute/unmute listeners from the prior track when select() swaps the stream', async () => {
|
|
546
|
+
await manager.enable();
|
|
547
|
+
const oldTrack = currentTrack();
|
|
548
|
+
expect(oldTrack.eventHandlers['mute']).toBeDefined();
|
|
549
|
+
expect(oldTrack.eventHandlers['unmute']).toBeDefined();
|
|
550
|
+
|
|
551
|
+
await manager.select(mockVideoDevices[1].deviceId);
|
|
552
|
+
|
|
553
|
+
// Listeners on the prior track are torn down so a delayed
|
|
554
|
+
// mute/unmute event cannot clobber the fresh stream's state.
|
|
555
|
+
expect(oldTrack.eventHandlers['mute']).toBeUndefined();
|
|
556
|
+
expect(oldTrack.eventHandlers['unmute']).toBeUndefined();
|
|
557
|
+
expect(oldTrack.eventHandlers['ended']).toBeUndefined();
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('removes mute/unmute listeners when the stream is cleared on disable', async () => {
|
|
561
|
+
await manager.enable();
|
|
562
|
+
const oldTrack = currentTrack();
|
|
563
|
+
expect(oldTrack.eventHandlers['mute']).toBeDefined();
|
|
564
|
+
|
|
565
|
+
await manager.disable();
|
|
566
|
+
|
|
567
|
+
expect(oldTrack.eventHandlers['mute']).toBeUndefined();
|
|
568
|
+
expect(oldTrack.eventHandlers['unmute']).toBeUndefined();
|
|
569
|
+
expect(oldTrack.eventHandlers['ended']).toBeUndefined();
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
describe('WebKit refreshTrack on unmute (encoder stall workaround)', () => {
|
|
573
|
+
const SAFARI_UA =
|
|
574
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
|
|
575
|
+
const IOS_WKWEBVIEW_UA =
|
|
576
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148';
|
|
577
|
+
const CHROME_UA =
|
|
578
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
579
|
+
|
|
580
|
+
const originalUserAgentDescriptor = Object.getOwnPropertyDescriptor(
|
|
581
|
+
window.navigator,
|
|
582
|
+
'userAgent',
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
const setUserAgent = (ua: string) => {
|
|
586
|
+
Object.defineProperty(window.navigator, 'userAgent', {
|
|
587
|
+
configurable: true,
|
|
588
|
+
get: () => ua,
|
|
589
|
+
});
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
afterEach(() => {
|
|
593
|
+
if (originalUserAgentDescriptor) {
|
|
594
|
+
Object.defineProperty(
|
|
595
|
+
window.navigator,
|
|
596
|
+
'userAgent',
|
|
597
|
+
originalUserAgentDescriptor,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('calls refreshPublishedTrack on Safari unmute', async () => {
|
|
603
|
+
setUserAgent(SAFARI_UA);
|
|
604
|
+
await manager.enable();
|
|
605
|
+
const track = currentTrack();
|
|
606
|
+
await fireOn(track, 'mute');
|
|
607
|
+
|
|
608
|
+
await fireOn(track, 'unmute');
|
|
609
|
+
|
|
610
|
+
expect(manager['call'].refreshPublishedTrack).toHaveBeenCalledWith(
|
|
611
|
+
TrackType.VIDEO,
|
|
612
|
+
);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('calls refreshPublishedTrack on a bare iOS WKWebView (no Safari token)', async () => {
|
|
616
|
+
setUserAgent(IOS_WKWEBVIEW_UA);
|
|
617
|
+
await manager.enable();
|
|
618
|
+
const track = currentTrack();
|
|
619
|
+
await fireOn(track, 'mute');
|
|
620
|
+
|
|
621
|
+
await fireOn(track, 'unmute');
|
|
622
|
+
|
|
623
|
+
expect(manager['call'].refreshPublishedTrack).toHaveBeenCalledWith(
|
|
624
|
+
TrackType.VIDEO,
|
|
625
|
+
);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('does not call refreshPublishedTrack on Chrome unmute', async () => {
|
|
629
|
+
setUserAgent(CHROME_UA);
|
|
630
|
+
await manager.enable();
|
|
631
|
+
const track = currentTrack();
|
|
632
|
+
await fireOn(track, 'mute');
|
|
633
|
+
|
|
634
|
+
await fireOn(track, 'unmute');
|
|
635
|
+
|
|
636
|
+
expect(manager['call'].refreshPublishedTrack).not.toHaveBeenCalled();
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it('does not call refreshPublishedTrack on the mute leg', async () => {
|
|
640
|
+
setUserAgent(SAFARI_UA);
|
|
641
|
+
await manager.enable();
|
|
642
|
+
const track = currentTrack();
|
|
643
|
+
|
|
644
|
+
await fireOn(track, 'mute');
|
|
645
|
+
|
|
646
|
+
expect(manager['call'].refreshPublishedTrack).not.toHaveBeenCalled();
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('skips refreshPublishedTrack while the page is hidden', async () => {
|
|
650
|
+
setUserAgent(SAFARI_UA);
|
|
651
|
+
const visibilityDescriptor = Object.getOwnPropertyDescriptor(
|
|
652
|
+
document,
|
|
653
|
+
'visibilityState',
|
|
654
|
+
);
|
|
655
|
+
Object.defineProperty(document, 'visibilityState', {
|
|
656
|
+
configurable: true,
|
|
657
|
+
get: () => 'hidden',
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
try {
|
|
661
|
+
await manager.enable();
|
|
662
|
+
const track = currentTrack();
|
|
663
|
+
await fireOn(track, 'mute');
|
|
664
|
+
|
|
665
|
+
await fireOn(track, 'unmute');
|
|
666
|
+
|
|
667
|
+
expect(manager['call'].refreshPublishedTrack).not.toHaveBeenCalled();
|
|
668
|
+
} finally {
|
|
669
|
+
if (visibilityDescriptor) {
|
|
670
|
+
Object.defineProperty(
|
|
671
|
+
document,
|
|
672
|
+
'visibilityState',
|
|
673
|
+
visibilityDescriptor,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
398
681
|
describe('persistPreference', () => {
|
|
399
682
|
it('stores selected device and muted state', () => {
|
|
400
683
|
const persistenceEnabledManager = new TestInputMediaDeviceManager(
|
|
@@ -34,7 +34,6 @@ describe('ScreenShareManager', () => {
|
|
|
34
34
|
let manager: ScreenShareManager;
|
|
35
35
|
|
|
36
36
|
beforeEach(() => {
|
|
37
|
-
const devicePersistence = { enabled: false, storageKey: '' };
|
|
38
37
|
manager = new ScreenShareManager(
|
|
39
38
|
new Call({
|
|
40
39
|
id: '',
|
|
@@ -42,7 +41,6 @@ describe('ScreenShareManager', () => {
|
|
|
42
41
|
streamClient: new StreamClient('abc123'),
|
|
43
42
|
clientStore: new StreamVideoWriteableStateStore(),
|
|
44
43
|
}),
|
|
45
|
-
devicePersistence,
|
|
46
44
|
);
|
|
47
45
|
});
|
|
48
46
|
|
|
@@ -104,6 +104,8 @@ export const mockCall = (): Partial<Call> => {
|
|
|
104
104
|
}),
|
|
105
105
|
notifyNoiseCancellationStarting: vi.fn().mockResolvedValue(undefined),
|
|
106
106
|
notifyNoiseCancellationStopped: vi.fn().mockResolvedValue(undefined),
|
|
107
|
+
notifyTrackMuteState: vi.fn().mockResolvedValue(undefined),
|
|
108
|
+
refreshPublishedTrack: vi.fn().mockResolvedValue(undefined),
|
|
107
109
|
tracer: new Tracer('tests'),
|
|
108
110
|
};
|
|
109
111
|
};
|
package/src/devices/devices.ts
CHANGED
|
@@ -340,7 +340,6 @@ export const getScreenShareStream = async (
|
|
|
340
340
|
const tag = `navigator.mediaDevices.getDisplayMedia.${getDisplayMediaExecId++}.`;
|
|
341
341
|
try {
|
|
342
342
|
const constraints: DisplayMediaStreamOptions = {
|
|
343
|
-
// @ts-expect-error - not present in types yet
|
|
344
343
|
systemAudio: 'include',
|
|
345
344
|
...options,
|
|
346
345
|
video:
|
|
@@ -357,6 +356,8 @@ export const getScreenShareStream = async (
|
|
|
357
356
|
? options.audio
|
|
358
357
|
: {
|
|
359
358
|
channelCount: { ideal: 2 },
|
|
359
|
+
// @ts-expect-error not yet present in the types
|
|
360
|
+
restrictOwnAudio: true,
|
|
360
361
|
echoCancellation: false,
|
|
361
362
|
autoGainControl: false,
|
|
362
363
|
noiseSuppression: false,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
ClientDetails,
|
|
11
11
|
Codec,
|
|
12
12
|
ConnectionQuality,
|
|
13
|
+
DegradationPreference,
|
|
13
14
|
Error as Error$,
|
|
14
15
|
GoAwayReason,
|
|
15
16
|
ICETrickle as ICETrickle$,
|
|
@@ -836,6 +837,10 @@ export interface VideoSender {
|
|
|
836
837
|
* @generated from protobuf field: int32 publish_option_id = 5;
|
|
837
838
|
*/
|
|
838
839
|
publishOptionId: number;
|
|
840
|
+
/**
|
|
841
|
+
* @generated from protobuf field: stream.video.sfu.models.DegradationPreference degradation_preference = 6;
|
|
842
|
+
*/
|
|
843
|
+
degradationPreference: DegradationPreference;
|
|
839
844
|
}
|
|
840
845
|
/**
|
|
841
846
|
* sent to users when they need to change the quality of their video
|
|
@@ -1796,6 +1801,16 @@ class VideoSender$Type extends MessageType<VideoSender> {
|
|
|
1796
1801
|
kind: 'scalar',
|
|
1797
1802
|
T: 5 /*ScalarType.INT32*/,
|
|
1798
1803
|
},
|
|
1804
|
+
{
|
|
1805
|
+
no: 6,
|
|
1806
|
+
name: 'degradation_preference',
|
|
1807
|
+
kind: 'enum',
|
|
1808
|
+
T: () => [
|
|
1809
|
+
'stream.video.sfu.models.DegradationPreference',
|
|
1810
|
+
DegradationPreference,
|
|
1811
|
+
'DEGRADATION_PREFERENCE_',
|
|
1812
|
+
],
|
|
1813
|
+
},
|
|
1799
1814
|
]);
|
|
1800
1815
|
}
|
|
1801
1816
|
}
|
|
@@ -307,6 +307,12 @@ export interface PublishOption {
|
|
|
307
307
|
* @generated from protobuf field: repeated stream.video.sfu.models.AudioBitrate audio_bitrate_profiles = 10;
|
|
308
308
|
*/
|
|
309
309
|
audioBitrateProfiles: AudioBitrate[];
|
|
310
|
+
/**
|
|
311
|
+
* The degradation preference for video encoding.
|
|
312
|
+
*
|
|
313
|
+
* @generated from protobuf field: stream.video.sfu.models.DegradationPreference degradation_preference = 11;
|
|
314
|
+
*/
|
|
315
|
+
degradationPreference: DegradationPreference;
|
|
310
316
|
}
|
|
311
317
|
/**
|
|
312
318
|
* @generated from protobuf message stream.video.sfu.models.Codec
|
|
@@ -1172,6 +1178,34 @@ export enum ClientCapability {
|
|
|
1172
1178
|
*/
|
|
1173
1179
|
SUBSCRIBER_VIDEO_PAUSE = 1,
|
|
1174
1180
|
}
|
|
1181
|
+
/**
|
|
1182
|
+
* DegradationPreference represents the RTCDegradationPreference from WebRTC.
|
|
1183
|
+
* See https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/setParameters#degradationpreference
|
|
1184
|
+
*
|
|
1185
|
+
* @generated from protobuf enum stream.video.sfu.models.DegradationPreference
|
|
1186
|
+
*/
|
|
1187
|
+
export enum DegradationPreference {
|
|
1188
|
+
/**
|
|
1189
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_UNSPECIFIED = 0;
|
|
1190
|
+
*/
|
|
1191
|
+
UNSPECIFIED = 0,
|
|
1192
|
+
/**
|
|
1193
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_BALANCED = 1;
|
|
1194
|
+
*/
|
|
1195
|
+
BALANCED = 1,
|
|
1196
|
+
/**
|
|
1197
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE = 2;
|
|
1198
|
+
*/
|
|
1199
|
+
MAINTAIN_FRAMERATE = 2,
|
|
1200
|
+
/**
|
|
1201
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_RESOLUTION = 3;
|
|
1202
|
+
*/
|
|
1203
|
+
MAINTAIN_RESOLUTION = 3,
|
|
1204
|
+
/**
|
|
1205
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE_AND_RESOLUTION = 4;
|
|
1206
|
+
*/
|
|
1207
|
+
MAINTAIN_FRAMERATE_AND_RESOLUTION = 4,
|
|
1208
|
+
}
|
|
1175
1209
|
// @generated message type with reflection information, may provide speed optimized methods
|
|
1176
1210
|
class CallState$Type extends MessageType<CallState> {
|
|
1177
1211
|
constructor() {
|
|
@@ -1441,6 +1475,16 @@ class PublishOption$Type extends MessageType<PublishOption> {
|
|
|
1441
1475
|
repeat: 2 /*RepeatType.UNPACKED*/,
|
|
1442
1476
|
T: () => AudioBitrate,
|
|
1443
1477
|
},
|
|
1478
|
+
{
|
|
1479
|
+
no: 11,
|
|
1480
|
+
name: 'degradation_preference',
|
|
1481
|
+
kind: 'enum',
|
|
1482
|
+
T: () => [
|
|
1483
|
+
'stream.video.sfu.models.DegradationPreference',
|
|
1484
|
+
DegradationPreference,
|
|
1485
|
+
'DEGRADATION_PREFERENCE_',
|
|
1486
|
+
],
|
|
1487
|
+
},
|
|
1444
1488
|
]);
|
|
1445
1489
|
}
|
|
1446
1490
|
}
|
|
@@ -21,10 +21,12 @@ export class AudioBindingsWatchdog {
|
|
|
21
21
|
private readonly unsubscribeCallingState: () => void;
|
|
22
22
|
private logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
) {
|
|
24
|
+
private readonly state: CallState;
|
|
25
|
+
private readonly tracer: Tracer;
|
|
26
|
+
|
|
27
|
+
constructor(state: CallState, tracer: Tracer) {
|
|
28
|
+
this.tracer = tracer;
|
|
29
|
+
this.state = state;
|
|
28
30
|
this.unsubscribeCallingState = createSubscription(
|
|
29
31
|
state.callingState$,
|
|
30
32
|
(callingState) => {
|
|
@@ -43,19 +45,19 @@ export class AudioBindingsWatchdog {
|
|
|
43
45
|
* Warns if a different element is already bound to the same key.
|
|
44
46
|
*/
|
|
45
47
|
register = (
|
|
46
|
-
|
|
48
|
+
element: HTMLAudioElement,
|
|
47
49
|
sessionId: string,
|
|
48
50
|
trackType: AudioTrackType,
|
|
49
51
|
) => {
|
|
50
52
|
const key = toBindingKey(sessionId, trackType);
|
|
51
53
|
const existing = this.bindings.get(key);
|
|
52
|
-
if (existing && existing !==
|
|
54
|
+
if (existing && existing !== element) {
|
|
53
55
|
this.logger.warn(
|
|
54
56
|
`Audio element already bound to ${sessionId} and ${trackType}`,
|
|
55
57
|
);
|
|
56
58
|
this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
|
|
57
59
|
}
|
|
58
|
-
this.bindings.set(key,
|
|
60
|
+
this.bindings.set(key, element);
|
|
59
61
|
};
|
|
60
62
|
|
|
61
63
|
/**
|
|
@@ -83,6 +85,7 @@ export class AudioBindingsWatchdog {
|
|
|
83
85
|
*/
|
|
84
86
|
dispose = () => {
|
|
85
87
|
this.stop();
|
|
88
|
+
this.bindings.clear();
|
|
86
89
|
this.unsubscribeCallingState();
|
|
87
90
|
};
|
|
88
91
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { BehaviorSubject, distinctUntilChanged, map } from 'rxjs';
|
|
2
|
+
import { videoLoggerSystem } from '../logger';
|
|
3
|
+
import { Tracer } from '../stats';
|
|
4
|
+
import { setCurrentValue, setCurrentValueAsync } from '../store/rxUtils';
|
|
5
|
+
import { timeboxed } from '../coordinator/connection/utils';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tracks audio elements that the browser's autoplay policy has blocked.
|
|
9
|
+
*/
|
|
10
|
+
export class BlockedAudioTracker {
|
|
11
|
+
private logger = videoLoggerSystem.getLogger('BlockedAudioTracker');
|
|
12
|
+
private tracer: Tracer;
|
|
13
|
+
|
|
14
|
+
private blockedElementsSubject = new BehaviorSubject(
|
|
15
|
+
new Set<HTMLAudioElement>(),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether the browser's autoplay policy is blocking audio playback.
|
|
20
|
+
* Will be `true` when at least one audio element is currently blocked.
|
|
21
|
+
* Use {@link resumeAudio} within a user gesture to unblock.
|
|
22
|
+
*/
|
|
23
|
+
autoplayBlocked$ = this.blockedElementsSubject.pipe(
|
|
24
|
+
map((elements) => elements.size > 0),
|
|
25
|
+
distinctUntilChanged(),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
constructor(tracer: Tracer) {
|
|
29
|
+
this.tracer = tracer;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Registers an audio element as blocked by the browser's autoplay policy.
|
|
34
|
+
*/
|
|
35
|
+
markBlocked = (audioElement: HTMLAudioElement, blocked: boolean) => {
|
|
36
|
+
setCurrentValue(this.blockedElementsSubject, (elements) => {
|
|
37
|
+
if (blocked) elements.add(audioElement);
|
|
38
|
+
else elements.delete(audioElement);
|
|
39
|
+
return elements;
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Returns whether the given audio element is currently flagged as blocked
|
|
45
|
+
* by the browser's autoplay policy.
|
|
46
|
+
*/
|
|
47
|
+
isBlocked = (audioElement: HTMLAudioElement): boolean => {
|
|
48
|
+
return this.blockedElementsSubject.getValue().has(audioElement);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
53
|
+
* Must be called from within a user gesture (e.g., click handler).
|
|
54
|
+
*/
|
|
55
|
+
resumeAudio = async () => {
|
|
56
|
+
this.tracer.trace('resumeAudio', null);
|
|
57
|
+
await setCurrentValueAsync(
|
|
58
|
+
this.blockedElementsSubject,
|
|
59
|
+
async (elements) => {
|
|
60
|
+
await Promise.all(
|
|
61
|
+
Array.from(elements, async (element) => {
|
|
62
|
+
try {
|
|
63
|
+
if (element.srcObject) await timeboxed([element.play()], 2000);
|
|
64
|
+
elements.delete(element);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
this.logger.warn(`Can't resume audio for element`, element, err);
|
|
67
|
+
}
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
return elements;
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
}
|