@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/index.browser.es.js +1497 -677
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1497 -677
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1497 -677
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +77 -4
  9. package/dist/src/StreamSfuClient.d.ts +8 -1
  10. package/dist/src/coordinator/connection/client.d.ts +1 -1
  11. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  12. package/dist/src/coordinator/connection/types.d.ts +14 -0
  13. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  14. package/dist/src/devices/DeviceManager.d.ts +3 -0
  15. package/dist/src/devices/DeviceManagerState.d.ts +13 -1
  16. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  17. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  18. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  19. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  20. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  21. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  22. package/dist/src/helpers/SlidingWindowRateLimiter.d.ts +28 -0
  23. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  24. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  25. package/dist/src/helpers/browsers.d.ts +13 -0
  26. package/dist/src/helpers/concurrency.d.ts +6 -4
  27. package/dist/src/rtc/BasePeerConnection.d.ts +11 -2
  28. package/dist/src/rtc/Publisher.d.ts +17 -0
  29. package/dist/src/rtc/Subscriber.d.ts +1 -0
  30. package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
  31. package/dist/src/rtc/index.d.ts +1 -0
  32. package/dist/src/rtc/types.d.ts +33 -1
  33. package/dist/src/stats/rtc/types.d.ts +1 -1
  34. package/dist/src/store/rxUtils.d.ts +9 -0
  35. package/dist/src/types.d.ts +18 -0
  36. package/package.json +2 -2
  37. package/src/Call.ts +268 -40
  38. package/src/StreamSfuClient.ts +75 -12
  39. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  40. package/src/__tests__/Call.publishing.test.ts +103 -0
  41. package/src/__tests__/StreamSfuClient.test.ts +275 -0
  42. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  43. package/src/coordinator/connection/client.ts +1 -1
  44. package/src/coordinator/connection/connection.ts +149 -96
  45. package/src/coordinator/connection/types.ts +15 -0
  46. package/src/coordinator/connection/utils.ts +15 -0
  47. package/src/devices/DeviceManager.ts +92 -32
  48. package/src/devices/DeviceManagerState.ts +20 -1
  49. package/src/devices/__tests__/DeviceManager.test.ts +283 -0
  50. package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
  51. package/src/devices/__tests__/mocks.ts +2 -0
  52. package/src/devices/devices.ts +2 -1
  53. package/src/gen/video/sfu/event/events.ts +15 -0
  54. package/src/gen/video/sfu/models/models.ts +44 -0
  55. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  56. package/src/helpers/BlockedAudioTracker.ts +74 -0
  57. package/src/helpers/DynascaleManager.ts +46 -337
  58. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  59. package/src/helpers/SlidingWindowRateLimiter.ts +49 -0
  60. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  61. package/src/helpers/ViewportTracker.ts +74 -19
  62. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  63. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  64. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  65. package/src/helpers/__tests__/SlidingWindowRateLimiter.test.ts +43 -0
  66. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  67. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  68. package/src/helpers/__tests__/browsers.test.ts +85 -1
  69. package/src/helpers/browsers.ts +24 -0
  70. package/src/helpers/concurrency.ts +9 -10
  71. package/src/rpc/retryable.ts +0 -1
  72. package/src/rtc/BasePeerConnection.ts +96 -6
  73. package/src/rtc/Publisher.ts +49 -2
  74. package/src/rtc/Subscriber.ts +42 -14
  75. package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
  76. package/src/rtc/__tests__/Publisher.test.ts +332 -10
  77. package/src/rtc/__tests__/Subscriber.test.ts +202 -1
  78. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  79. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
  80. package/src/rtc/helpers/degradationPreference.ts +22 -0
  81. package/src/rtc/index.ts +1 -0
  82. package/src/rtc/types.ts +38 -1
  83. package/src/stats/rtc/types.ts +1 -0
  84. package/src/store/__tests__/rxUtils.test.ts +276 -0
  85. package/src/store/rxUtils.ts +19 -0
  86. 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 = new 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: should register and unregister watchdog binding', () => {
623
- const watchdog = dynascaleManager.audioBindingsWatchdog!;
624
- const registerSpy = vi.spyOn(watchdog, 'register');
625
- const unregisterSpy = vi.spyOn(watchdog, 'unregister');
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 = dynascaleManager.bindAudioElement(
635
- document.createElement('audio'),
580
+ const cleanup = call.bindAudioElement(
581
+ audioElement,
636
582
  'session-id',
637
583
  'audioTrack',
638
584
  );
639
585
 
640
- expect(registerSpy).toHaveBeenCalledWith(
641
- expect.any(HTMLAudioElement),
642
- 'session-id',
643
- 'audioTrack',
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: should track blocked audio elements on NotAllowedError', async () => {
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
- const notAllowedError = new DOMException('', 'NotAllowedError');
656
- vi.spyOn(audioElement, 'play').mockRejectedValue(notAllowedError);
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 = dynascaleManager.bindAudioElement(
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: mediaStream,
623
+ audioStream: new MediaStream(),
674
624
  });
675
625
 
676
626
  vi.runAllTimers();
677
627
  await vi.advanceTimersByTimeAsync(0);
678
628
 
679
- expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(true);
629
+ expect(getCurrentValue(call.blockedAudioTracker.autoplayBlocked$)).toBe(
630
+ true,
631
+ );
680
632
 
681
633
  cleanup?.();
682
- expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(false);
634
+
635
+ expect(getCurrentValue(call.blockedAudioTracker.autoplayBlocked$)).toBe(
636
+ false,
637
+ );
683
638
  });
684
639
 
685
- it('audio: should unblock audio elements on explicit resumeAudio call', async () => {
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
- const playSpy = vi
690
- .spyOn(audioElement, 'play')
691
- .mockRejectedValueOnce(new DOMException('', 'NotAllowedError'))
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 = dynascaleManager.bindAudioElement(
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: mediaStream,
662
+ audioStream: new MediaStream(),
710
663
  });
711
664
 
712
665
  vi.runAllTimers();
713
666
  await vi.advanceTimersByTimeAsync(0);
714
667
 
715
- expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(true);
668
+ expect(getCurrentValue(call.blockedAudioTracker.autoplayBlocked$)).toBe(
669
+ true,
670
+ );
716
671
 
717
- await dynascaleManager.resumeAudio();
672
+ call.state.updateParticipant('session-id', { audioStream: undefined });
673
+
674
+ vi.runAllTimers();
718
675
  await vi.advanceTimersByTimeAsync(0);
719
676
 
720
- expect(playSpy).toHaveBeenCalledTimes(2);
721
- expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(false);
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('audio: should clear blocked state when the audio stream is removed', async () => {
685
+ it('video: watchdog re-plays element after a pause event', async () => {
727
686
  vi.useFakeTimers();
728
- const audioElement = document.createElement('audio');
729
- Object.defineProperty(audioElement, 'srcObject', { writable: true });
730
- vi.spyOn(audioElement, 'play').mockRejectedValue(
731
- new DOMException('', 'NotAllowedError'),
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: mediaStream,
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(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(true);
761
+ expect(play.mock.calls.length).toBeGreaterThan(callsBeforePause);
756
762
 
757
- call.state.updateParticipant('session-id', {
758
- audioStream: undefined,
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(audioElement.srcObject).toBeNull();
765
- expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(false);
804
+ expect(play).not.toHaveBeenCalled();
766
805
 
767
806
  cleanup?.();
768
807
  });
769
808
 
770
- it('audio: should warn when binding an already-bound session', () => {
771
- const watchdog = dynascaleManager.audioBindingsWatchdog!;
772
- // @ts-expect-error private property
773
- const warnSpy = vi.spyOn(watchdog.logger, 'warn');
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 audioElement1 = document.createElement('audio');
783
- const audioElement2 = document.createElement('audio');
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
- const cleanup2 = dynascaleManager.bindAudioElement(
792
- audioElement2,
793
- 'session-id',
794
- 'audioTrack',
795
- );
838
+ call.state.updateParticipant('session-id', {
839
+ audioStream: new MediaStream(),
840
+ });
841
+ vi.runAllTimers();
842
+ await vi.advanceTimersByTimeAsync(0);
796
843
 
797
- expect(warnSpy).toHaveBeenCalledWith(
798
- expect.stringContaining('Audio element already bound'),
799
- );
844
+ expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(true);
800
845
 
801
- cleanup1?.();
802
- cleanup2?.();
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
+ });