@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
|
@@ -37,70 +37,13 @@ describe('DynascaleManager', () => {
|
|
|
37
37
|
clientStore: new StreamVideoWriteableStateStore(),
|
|
38
38
|
});
|
|
39
39
|
call.setSortParticipantsBy(noopComparator());
|
|
40
|
-
dynascaleManager =
|
|
41
|
-
call.state,
|
|
42
|
-
call.speaker,
|
|
43
|
-
call.tracer,
|
|
44
|
-
);
|
|
40
|
+
dynascaleManager = call.dynascaleManager;
|
|
45
41
|
});
|
|
46
42
|
|
|
47
43
|
afterEach(() => {
|
|
48
44
|
call.leave();
|
|
49
45
|
});
|
|
50
46
|
|
|
51
|
-
describe('visibility tracking', () => {
|
|
52
|
-
it('should track element visibility visibility', () => {
|
|
53
|
-
let visibilityHandler: any;
|
|
54
|
-
vi.spyOn(dynascaleManager.viewportTracker, 'observe').mockImplementation(
|
|
55
|
-
(el, handler) => {
|
|
56
|
-
visibilityHandler = handler;
|
|
57
|
-
return vi.fn();
|
|
58
|
-
},
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
// @ts-expect-error incomplete data
|
|
62
|
-
call.state.updateOrAddParticipant('session-id', {
|
|
63
|
-
userId: 'user-id',
|
|
64
|
-
sessionId: 'session-id',
|
|
65
|
-
publishedTracks: [],
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
const element = document.createElement('div');
|
|
69
|
-
const untrack = dynascaleManager.trackElementVisibility(
|
|
70
|
-
element,
|
|
71
|
-
'session-id',
|
|
72
|
-
'videoTrack',
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
expect(visibilityHandler).toBeDefined();
|
|
76
|
-
expect(dynascaleManager.viewportTracker.observe).toHaveBeenCalledWith(
|
|
77
|
-
element,
|
|
78
|
-
expect.any(Function),
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
// test becoming visible
|
|
82
|
-
visibilityHandler({ isIntersecting: true });
|
|
83
|
-
expect(
|
|
84
|
-
call.state.findParticipantBySessionId('session-id')
|
|
85
|
-
?.viewportVisibilityState?.videoTrack,
|
|
86
|
-
).toBe(VisibilityState.VISIBLE);
|
|
87
|
-
|
|
88
|
-
// test becoming invisible
|
|
89
|
-
visibilityHandler({ isIntersecting: false });
|
|
90
|
-
expect(
|
|
91
|
-
call.state.findParticipantBySessionId('session-id')
|
|
92
|
-
?.viewportVisibilityState?.videoTrack,
|
|
93
|
-
).toBe(VisibilityState.INVISIBLE);
|
|
94
|
-
|
|
95
|
-
// test track reset
|
|
96
|
-
untrack();
|
|
97
|
-
expect(
|
|
98
|
-
call.state.findParticipantBySessionId('session-id')
|
|
99
|
-
?.viewportVisibilityState?.videoTrack,
|
|
100
|
-
).toBe(VisibilityState.UNKNOWN);
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
47
|
describe('element binding', () => {
|
|
105
48
|
let videoElement: globalThis.HTMLVideoElement;
|
|
106
49
|
|
|
@@ -619,10 +562,13 @@ describe('DynascaleManager', () => {
|
|
|
619
562
|
});
|
|
620
563
|
});
|
|
621
564
|
|
|
622
|
-
it('audio:
|
|
623
|
-
|
|
624
|
-
const
|
|
625
|
-
|
|
565
|
+
it('audio: marks element blocked on NotAllowedError', async () => {
|
|
566
|
+
vi.useFakeTimers();
|
|
567
|
+
const audioElement = document.createElement('audio');
|
|
568
|
+
Object.defineProperty(audioElement, 'srcObject', { writable: true });
|
|
569
|
+
vi.spyOn(audioElement, 'play').mockRejectedValue(
|
|
570
|
+
new DOMException('', 'NotAllowedError'),
|
|
571
|
+
);
|
|
626
572
|
|
|
627
573
|
// @ts-expect-error incomplete data
|
|
628
574
|
call.state.updateOrAddParticipant('session-id', {
|
|
@@ -631,29 +577,34 @@ describe('DynascaleManager', () => {
|
|
|
631
577
|
publishedTracks: [],
|
|
632
578
|
});
|
|
633
579
|
|
|
634
|
-
const cleanup =
|
|
635
|
-
|
|
580
|
+
const cleanup = call.bindAudioElement(
|
|
581
|
+
audioElement,
|
|
636
582
|
'session-id',
|
|
637
583
|
'audioTrack',
|
|
638
584
|
);
|
|
639
585
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
586
|
+
const mediaStream = new MediaStream();
|
|
587
|
+
call.state.updateParticipant('session-id', {
|
|
588
|
+
audioStream: mediaStream,
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
vi.runAllTimers();
|
|
592
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
593
|
+
|
|
594
|
+
expect(getCurrentValue(call.blockedAudioTracker.autoplayBlocked$)).toBe(
|
|
595
|
+
true,
|
|
644
596
|
);
|
|
645
597
|
|
|
646
598
|
cleanup?.();
|
|
647
|
-
|
|
648
|
-
expect(unregisterSpy).toHaveBeenCalledWith('session-id', 'audioTrack');
|
|
649
599
|
});
|
|
650
600
|
|
|
651
|
-
it('audio:
|
|
601
|
+
it('audio: unmarks blocked element on cleanup', async () => {
|
|
652
602
|
vi.useFakeTimers();
|
|
653
603
|
const audioElement = document.createElement('audio');
|
|
654
604
|
Object.defineProperty(audioElement, 'srcObject', { writable: true });
|
|
655
|
-
|
|
656
|
-
|
|
605
|
+
vi.spyOn(audioElement, 'play').mockRejectedValue(
|
|
606
|
+
new DOMException('', 'NotAllowedError'),
|
|
607
|
+
);
|
|
657
608
|
|
|
658
609
|
// @ts-expect-error incomplete data
|
|
659
610
|
call.state.updateOrAddParticipant('session-id', {
|
|
@@ -662,34 +613,37 @@ describe('DynascaleManager', () => {
|
|
|
662
613
|
publishedTracks: [],
|
|
663
614
|
});
|
|
664
615
|
|
|
665
|
-
const cleanup =
|
|
616
|
+
const cleanup = call.bindAudioElement(
|
|
666
617
|
audioElement,
|
|
667
618
|
'session-id',
|
|
668
619
|
'audioTrack',
|
|
669
620
|
);
|
|
670
621
|
|
|
671
|
-
const mediaStream = new MediaStream();
|
|
672
622
|
call.state.updateParticipant('session-id', {
|
|
673
|
-
audioStream:
|
|
623
|
+
audioStream: new MediaStream(),
|
|
674
624
|
});
|
|
675
625
|
|
|
676
626
|
vi.runAllTimers();
|
|
677
627
|
await vi.advanceTimersByTimeAsync(0);
|
|
678
628
|
|
|
679
|
-
expect(getCurrentValue(
|
|
629
|
+
expect(getCurrentValue(call.blockedAudioTracker.autoplayBlocked$)).toBe(
|
|
630
|
+
true,
|
|
631
|
+
);
|
|
680
632
|
|
|
681
633
|
cleanup?.();
|
|
682
|
-
|
|
634
|
+
|
|
635
|
+
expect(getCurrentValue(call.blockedAudioTracker.autoplayBlocked$)).toBe(
|
|
636
|
+
false,
|
|
637
|
+
);
|
|
683
638
|
});
|
|
684
639
|
|
|
685
|
-
it('audio:
|
|
640
|
+
it('audio: unmarks blocked element when the audio stream is removed', async () => {
|
|
686
641
|
vi.useFakeTimers();
|
|
687
642
|
const audioElement = document.createElement('audio');
|
|
688
643
|
Object.defineProperty(audioElement, 'srcObject', { writable: true });
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
.mockResolvedValue(undefined);
|
|
644
|
+
vi.spyOn(audioElement, 'play').mockRejectedValue(
|
|
645
|
+
new DOMException('', 'NotAllowedError'),
|
|
646
|
+
);
|
|
693
647
|
|
|
694
648
|
// @ts-expect-error incomplete data
|
|
695
649
|
call.state.updateOrAddParticipant('session-id', {
|
|
@@ -698,39 +652,90 @@ describe('DynascaleManager', () => {
|
|
|
698
652
|
publishedTracks: [],
|
|
699
653
|
});
|
|
700
654
|
|
|
701
|
-
const cleanup =
|
|
655
|
+
const cleanup = call.bindAudioElement(
|
|
702
656
|
audioElement,
|
|
703
657
|
'session-id',
|
|
704
658
|
'audioTrack',
|
|
705
659
|
);
|
|
706
660
|
|
|
707
|
-
const mediaStream = new MediaStream();
|
|
708
661
|
call.state.updateParticipant('session-id', {
|
|
709
|
-
audioStream:
|
|
662
|
+
audioStream: new MediaStream(),
|
|
710
663
|
});
|
|
711
664
|
|
|
712
665
|
vi.runAllTimers();
|
|
713
666
|
await vi.advanceTimersByTimeAsync(0);
|
|
714
667
|
|
|
715
|
-
expect(getCurrentValue(
|
|
668
|
+
expect(getCurrentValue(call.blockedAudioTracker.autoplayBlocked$)).toBe(
|
|
669
|
+
true,
|
|
670
|
+
);
|
|
716
671
|
|
|
717
|
-
|
|
672
|
+
call.state.updateParticipant('session-id', { audioStream: undefined });
|
|
673
|
+
|
|
674
|
+
vi.runAllTimers();
|
|
718
675
|
await vi.advanceTimersByTimeAsync(0);
|
|
719
676
|
|
|
720
|
-
expect(
|
|
721
|
-
expect(getCurrentValue(
|
|
677
|
+
expect(audioElement.srcObject).toBeNull();
|
|
678
|
+
expect(getCurrentValue(call.blockedAudioTracker.autoplayBlocked$)).toBe(
|
|
679
|
+
false,
|
|
680
|
+
);
|
|
722
681
|
|
|
723
682
|
cleanup?.();
|
|
724
683
|
});
|
|
725
684
|
|
|
726
|
-
it('
|
|
685
|
+
it('video: watchdog re-plays element after a pause event', async () => {
|
|
727
686
|
vi.useFakeTimers();
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
687
|
+
Object.defineProperties(videoElement, {
|
|
688
|
+
paused: { writable: true, configurable: true },
|
|
689
|
+
readyState: { writable: true, configurable: true },
|
|
690
|
+
});
|
|
691
|
+
// @ts-expect-error simulate paused, ready-to-play element
|
|
692
|
+
videoElement.paused = true;
|
|
693
|
+
// @ts-expect-error simulate paused, ready-to-play element
|
|
694
|
+
videoElement.readyState = 4;
|
|
695
|
+
const play = vi.spyOn(videoElement, 'play').mockResolvedValue();
|
|
696
|
+
|
|
697
|
+
// @ts-expect-error incomplete data
|
|
698
|
+
call.state.updateOrAddParticipant('session-id', {
|
|
699
|
+
userId: 'user-id',
|
|
700
|
+
sessionId: 'session-id',
|
|
701
|
+
publishedTracks: [TrackType.VIDEO],
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
const cleanup = dynascaleManager.bindVideoElement(
|
|
705
|
+
videoElement,
|
|
706
|
+
'session-id',
|
|
707
|
+
'videoTrack',
|
|
732
708
|
);
|
|
733
709
|
|
|
710
|
+
const mediaStream = new MediaStream();
|
|
711
|
+
call.state.updateParticipant('session-id', {
|
|
712
|
+
videoStream: mediaStream,
|
|
713
|
+
});
|
|
714
|
+
vi.runAllTimers();
|
|
715
|
+
|
|
716
|
+
const callsBeforePause = play.mock.calls.length;
|
|
717
|
+
videoElement.dispatchEvent(new Event('pause'));
|
|
718
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
719
|
+
|
|
720
|
+
expect(play.mock.calls.length).toBeGreaterThan(callsBeforePause);
|
|
721
|
+
|
|
722
|
+
cleanup?.();
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('audio: watchdog re-plays element after pause when useWebAudio is false', async () => {
|
|
726
|
+
vi.useFakeTimers();
|
|
727
|
+
const audioElement = document.createElement('audio');
|
|
728
|
+
Object.defineProperties(audioElement, {
|
|
729
|
+
srcObject: { writable: true },
|
|
730
|
+
paused: { writable: true, configurable: true },
|
|
731
|
+
readyState: { writable: true, configurable: true },
|
|
732
|
+
});
|
|
733
|
+
// @ts-expect-error simulate paused, ready-to-play element
|
|
734
|
+
audioElement.paused = true;
|
|
735
|
+
// @ts-expect-error simulate paused, ready-to-play element
|
|
736
|
+
audioElement.readyState = 4;
|
|
737
|
+
const play = vi.spyOn(audioElement, 'play').mockResolvedValue();
|
|
738
|
+
|
|
734
739
|
// @ts-expect-error incomplete data
|
|
735
740
|
call.state.updateOrAddParticipant('session-id', {
|
|
736
741
|
userId: 'user-id',
|
|
@@ -744,33 +749,78 @@ describe('DynascaleManager', () => {
|
|
|
744
749
|
'audioTrack',
|
|
745
750
|
);
|
|
746
751
|
|
|
747
|
-
const mediaStream = new MediaStream();
|
|
748
752
|
call.state.updateParticipant('session-id', {
|
|
749
|
-
audioStream:
|
|
753
|
+
audioStream: new MediaStream(),
|
|
750
754
|
});
|
|
751
|
-
|
|
752
755
|
vi.runAllTimers();
|
|
756
|
+
|
|
757
|
+
const callsBeforePause = play.mock.calls.length;
|
|
758
|
+
audioElement.dispatchEvent(new Event('pause'));
|
|
753
759
|
await vi.advanceTimersByTimeAsync(0);
|
|
754
760
|
|
|
755
|
-
expect(
|
|
761
|
+
expect(play.mock.calls.length).toBeGreaterThan(callsBeforePause);
|
|
756
762
|
|
|
757
|
-
|
|
758
|
-
|
|
763
|
+
cleanup?.();
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it('audio: no watchdog attached when useWebAudio is true', async () => {
|
|
767
|
+
globalThis._isSafari = true;
|
|
768
|
+
dynascaleManager.setUseWebAudio(true);
|
|
769
|
+
|
|
770
|
+
vi.useFakeTimers();
|
|
771
|
+
const audioElement = document.createElement('audio');
|
|
772
|
+
Object.defineProperties(audioElement, {
|
|
773
|
+
srcObject: { writable: true },
|
|
774
|
+
paused: { writable: true, configurable: true },
|
|
775
|
+
readyState: { writable: true, configurable: true },
|
|
776
|
+
});
|
|
777
|
+
// @ts-expect-error simulate paused, ready-to-play element
|
|
778
|
+
audioElement.paused = true;
|
|
779
|
+
// @ts-expect-error simulate paused, ready-to-play element
|
|
780
|
+
audioElement.readyState = 4;
|
|
781
|
+
const play = vi.spyOn(audioElement, 'play').mockResolvedValue();
|
|
782
|
+
|
|
783
|
+
// @ts-expect-error incomplete data
|
|
784
|
+
call.state.updateOrAddParticipant('session-id', {
|
|
785
|
+
userId: 'user-id',
|
|
786
|
+
sessionId: 'session-id',
|
|
787
|
+
publishedTracks: [],
|
|
759
788
|
});
|
|
760
789
|
|
|
790
|
+
const cleanup = dynascaleManager.bindAudioElement(
|
|
791
|
+
audioElement,
|
|
792
|
+
'session-id',
|
|
793
|
+
'audioTrack',
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
call.state.updateParticipant('session-id', {
|
|
797
|
+
audioStream: new MediaStream(),
|
|
798
|
+
});
|
|
761
799
|
vi.runAllTimers();
|
|
800
|
+
|
|
801
|
+
audioElement.dispatchEvent(new Event('pause'));
|
|
762
802
|
await vi.advanceTimersByTimeAsync(0);
|
|
763
803
|
|
|
764
|
-
expect(
|
|
765
|
-
expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(false);
|
|
804
|
+
expect(play).not.toHaveBeenCalled();
|
|
766
805
|
|
|
767
806
|
cleanup?.();
|
|
768
807
|
});
|
|
769
808
|
|
|
770
|
-
it('audio:
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
809
|
+
it('audio: watchdog defers to BlockedAudioTracker when element is blocked', async () => {
|
|
810
|
+
vi.useFakeTimers();
|
|
811
|
+
const audioElement = document.createElement('audio');
|
|
812
|
+
Object.defineProperties(audioElement, {
|
|
813
|
+
srcObject: { writable: true },
|
|
814
|
+
paused: { writable: true, configurable: true },
|
|
815
|
+
readyState: { writable: true, configurable: true },
|
|
816
|
+
});
|
|
817
|
+
// @ts-expect-error simulate paused, ready-to-play element
|
|
818
|
+
audioElement.paused = true;
|
|
819
|
+
// @ts-expect-error simulate paused, ready-to-play element
|
|
820
|
+
audioElement.readyState = 4;
|
|
821
|
+
vi.spyOn(audioElement, 'play').mockRejectedValue(
|
|
822
|
+
new DOMException('', 'NotAllowedError'),
|
|
823
|
+
);
|
|
774
824
|
|
|
775
825
|
// @ts-expect-error incomplete data
|
|
776
826
|
call.state.updateOrAddParticipant('session-id', {
|
|
@@ -779,27 +829,30 @@ describe('DynascaleManager', () => {
|
|
|
779
829
|
publishedTracks: [],
|
|
780
830
|
});
|
|
781
831
|
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
const cleanup1 = dynascaleManager.bindAudioElement(
|
|
786
|
-
audioElement1,
|
|
832
|
+
const cleanup = call.bindAudioElement(
|
|
833
|
+
audioElement,
|
|
787
834
|
'session-id',
|
|
788
835
|
'audioTrack',
|
|
789
836
|
);
|
|
790
837
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
);
|
|
838
|
+
call.state.updateParticipant('session-id', {
|
|
839
|
+
audioStream: new MediaStream(),
|
|
840
|
+
});
|
|
841
|
+
vi.runAllTimers();
|
|
842
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
796
843
|
|
|
797
|
-
expect(
|
|
798
|
-
expect.stringContaining('Audio element already bound'),
|
|
799
|
-
);
|
|
844
|
+
expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(true);
|
|
800
845
|
|
|
801
|
-
|
|
802
|
-
|
|
846
|
+
const traceSpy = vi.spyOn(call.tracer, 'trace');
|
|
847
|
+
audioElement.dispatchEvent(new Event('pause'));
|
|
848
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
849
|
+
|
|
850
|
+
expect(traceSpy).toHaveBeenCalledWith('mediaPlayback.recover.skipped', {
|
|
851
|
+
kind: 'audio',
|
|
852
|
+
reason: 'blocked',
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
cleanup?.();
|
|
803
856
|
});
|
|
804
857
|
|
|
805
858
|
it('video: should unsubscribe when element dimensions are zero', () => {
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
afterEach,
|
|
7
|
+
beforeEach,
|
|
8
|
+
describe,
|
|
9
|
+
expect,
|
|
10
|
+
it,
|
|
11
|
+
vi,
|
|
12
|
+
type MockInstance,
|
|
13
|
+
} from 'vitest';
|
|
14
|
+
import { MediaPlaybackWatchdog } from '../MediaPlaybackWatchdog';
|
|
15
|
+
import type { Tracer } from '../../stats';
|
|
16
|
+
|
|
17
|
+
const createTracer = () => ({ trace: vi.fn() }) as unknown as Tracer;
|
|
18
|
+
|
|
19
|
+
type FakeMediaState = {
|
|
20
|
+
srcObject?: MediaStream | null;
|
|
21
|
+
paused?: boolean;
|
|
22
|
+
readyState?: number;
|
|
23
|
+
ended?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const createMediaElement = (
|
|
27
|
+
kind: 'audio' | 'video',
|
|
28
|
+
state: FakeMediaState = {},
|
|
29
|
+
) => {
|
|
30
|
+
const el = document.createElement(kind) as HTMLMediaElement;
|
|
31
|
+
Object.defineProperty(el, 'srcObject', { writable: true });
|
|
32
|
+
Object.defineProperty(el, 'paused', { writable: true, configurable: true });
|
|
33
|
+
Object.defineProperty(el, 'readyState', {
|
|
34
|
+
writable: true,
|
|
35
|
+
configurable: true,
|
|
36
|
+
});
|
|
37
|
+
Object.defineProperty(el, 'ended', { writable: true, configurable: true });
|
|
38
|
+
el.srcObject = 'srcObject' in state ? state.srcObject : new MediaStream();
|
|
39
|
+
el.paused = state.paused ?? true;
|
|
40
|
+
el.readyState = state.readyState ?? 4;
|
|
41
|
+
el.ended = state.ended ?? false;
|
|
42
|
+
return el;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type SetupOpts = {
|
|
46
|
+
kind?: 'audio' | 'video';
|
|
47
|
+
state?: FakeMediaState;
|
|
48
|
+
isBlocked?: () => boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe('MediaPlaybackWatchdog', () => {
|
|
52
|
+
let tracer: Tracer;
|
|
53
|
+
let el: HTMLMediaElement;
|
|
54
|
+
let play: MockInstance;
|
|
55
|
+
let watchdog: MediaPlaybackWatchdog;
|
|
56
|
+
|
|
57
|
+
const setup = (opts: SetupOpts = {}) => {
|
|
58
|
+
watchdog?.dispose();
|
|
59
|
+
const kind = opts.kind ?? 'audio';
|
|
60
|
+
el = createMediaElement(kind, opts.state);
|
|
61
|
+
play = vi.spyOn(el, 'play').mockResolvedValue();
|
|
62
|
+
watchdog = new MediaPlaybackWatchdog({
|
|
63
|
+
element: el,
|
|
64
|
+
kind,
|
|
65
|
+
tracer,
|
|
66
|
+
isBlocked: opts.isBlocked,
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
vi.useFakeTimers();
|
|
72
|
+
tracer = createTracer();
|
|
73
|
+
setup();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
watchdog.dispose();
|
|
78
|
+
vi.useRealTimers();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('calls play() after a pause event', async () => {
|
|
82
|
+
el.dispatchEvent(new Event('pause'));
|
|
83
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
84
|
+
|
|
85
|
+
expect(play).toHaveBeenCalledTimes(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('retries with backoff up to the attempt cap then stops', async () => {
|
|
89
|
+
setup({ kind: 'video' });
|
|
90
|
+
play.mockRejectedValue(new Error('nope'));
|
|
91
|
+
|
|
92
|
+
el.dispatchEvent(new Event('pause'));
|
|
93
|
+
|
|
94
|
+
// Drain all scheduled retries. retryInterval caps at 5000ms per attempt;
|
|
95
|
+
// 10 attempts is bounded by ~50s of fake time.
|
|
96
|
+
for (let i = 0; i < 20; i++) {
|
|
97
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
expect(play).toHaveBeenCalledTimes(10);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('continues recovering on subsequent pause events after a successful resume', async () => {
|
|
104
|
+
play.mockRejectedValueOnce(new Error('fail-1'));
|
|
105
|
+
|
|
106
|
+
el.dispatchEvent(new Event('pause'));
|
|
107
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
108
|
+
expect(play).toHaveBeenCalledTimes(1);
|
|
109
|
+
|
|
110
|
+
// Drain the queued backoff retry, which now resolves.
|
|
111
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
112
|
+
expect(play).toHaveBeenCalledTimes(2);
|
|
113
|
+
|
|
114
|
+
// Simulate the element actually starting to play.
|
|
115
|
+
el.dispatchEvent(new Event('playing'));
|
|
116
|
+
|
|
117
|
+
// A subsequent pause should still trigger a fresh recovery attempt.
|
|
118
|
+
el.dispatchEvent(new Event('pause'));
|
|
119
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
120
|
+
expect(play).toHaveBeenCalledTimes(3);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('does not attempt recovery when srcObject is null', async () => {
|
|
124
|
+
setup({ state: { srcObject: null } });
|
|
125
|
+
|
|
126
|
+
el.dispatchEvent(new Event('pause'));
|
|
127
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
128
|
+
|
|
129
|
+
expect(play).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('does not attempt recovery when isBlocked returns true', async () => {
|
|
133
|
+
setup({ isBlocked: () => true });
|
|
134
|
+
|
|
135
|
+
el.dispatchEvent(new Event('pause'));
|
|
136
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
137
|
+
|
|
138
|
+
expect(play).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('does not attempt recovery when the element is already playing', async () => {
|
|
142
|
+
setup({ state: { paused: false } });
|
|
143
|
+
|
|
144
|
+
// a routine `suspend` while the element is actually playing should not
|
|
145
|
+
// trigger a recovery attempt
|
|
146
|
+
el.dispatchEvent(new Event('suspend'));
|
|
147
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
148
|
+
|
|
149
|
+
expect(play).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does not attempt recovery when readyState is too low', async () => {
|
|
153
|
+
setup({ state: { readyState: 0 } });
|
|
154
|
+
|
|
155
|
+
el.dispatchEvent(new Event('pause'));
|
|
156
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
157
|
+
|
|
158
|
+
expect(play).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('dispose removes listeners and prevents further recovery', async () => {
|
|
162
|
+
watchdog.dispose();
|
|
163
|
+
|
|
164
|
+
el.dispatchEvent(new Event('pause'));
|
|
165
|
+
el.dispatchEvent(new Event('suspend'));
|
|
166
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
167
|
+
|
|
168
|
+
expect(play).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('does not stack timers when pause fires multiple times before the first attempt', async () => {
|
|
172
|
+
el.dispatchEvent(new Event('pause'));
|
|
173
|
+
el.dispatchEvent(new Event('pause'));
|
|
174
|
+
el.dispatchEvent(new Event('suspend'));
|
|
175
|
+
|
|
176
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
177
|
+
|
|
178
|
+
expect(play).toHaveBeenCalledTimes(1);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { SlidingWindowRateLimiter } from '../SlidingWindowRateLimiter';
|
|
3
|
+
|
|
4
|
+
describe('SlidingWindowRateLimiter', () => {
|
|
5
|
+
it('allows up to the configured number of attempts inside the window', () => {
|
|
6
|
+
const limiter = new SlidingWindowRateLimiter(3, 1000);
|
|
7
|
+
expect(limiter.tryRegister(0)).toBe(true);
|
|
8
|
+
expect(limiter.tryRegister(10)).toBe(true);
|
|
9
|
+
expect(limiter.tryRegister(20)).toBe(true);
|
|
10
|
+
// fourth attempt inside the same window is denied
|
|
11
|
+
expect(limiter.tryRegister(30)).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('prunes timestamps outside the rolling window so attempts after it pass', () => {
|
|
15
|
+
const limiter = new SlidingWindowRateLimiter(2, 1000);
|
|
16
|
+
expect(limiter.tryRegister(0)).toBe(true);
|
|
17
|
+
expect(limiter.tryRegister(500)).toBe(true);
|
|
18
|
+
expect(limiter.tryRegister(900)).toBe(false);
|
|
19
|
+
// at t=1501 cutoff=501, so both 0 and 500 fall out of window
|
|
20
|
+
expect(limiter.tryRegister(1501)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('reset() clears the attempt history', () => {
|
|
24
|
+
const limiter = new SlidingWindowRateLimiter(2, 1000);
|
|
25
|
+
limiter.tryRegister(0);
|
|
26
|
+
limiter.tryRegister(100);
|
|
27
|
+
expect(limiter.tryRegister(200)).toBe(false);
|
|
28
|
+
limiter.reset();
|
|
29
|
+
expect(limiter.tryRegister(300)).toBe(true);
|
|
30
|
+
expect(limiter.tryRegister(400)).toBe(true);
|
|
31
|
+
expect(limiter.tryRegister(500)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('setLimits() updates the budget and window in place', () => {
|
|
35
|
+
const limiter = new SlidingWindowRateLimiter(2, 1000);
|
|
36
|
+
limiter.tryRegister(0);
|
|
37
|
+
limiter.tryRegister(100);
|
|
38
|
+
expect(limiter.tryRegister(200)).toBe(false);
|
|
39
|
+
// raising the limit lets the next attempt through without reset
|
|
40
|
+
limiter.setLimits(5, 1000);
|
|
41
|
+
expect(limiter.tryRegister(300)).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|