@stream-io/video-client 1.52.0 → 1.53.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 (52) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/index.browser.es.js +796 -51
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +796 -50
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +796 -51
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +5 -1
  9. package/dist/src/StreamVideoClient.d.ts +2 -0
  10. package/dist/src/coordinator/connection/client.d.ts +1 -0
  11. package/dist/src/errors/SfuTimeoutError.d.ts +8 -0
  12. package/dist/src/errors/index.d.ts +1 -0
  13. package/dist/src/helpers/firstVideoFrame.d.ts +7 -0
  14. package/dist/src/reporting/ClientEventReporter.d.ts +84 -0
  15. package/dist/src/reporting/index.d.ts +1 -0
  16. package/dist/src/rtc/BasePeerConnection.d.ts +5 -2
  17. package/dist/src/rtc/types.d.ts +24 -1
  18. package/dist/src/types.d.ts +5 -0
  19. package/package.json +1 -1
  20. package/src/Call.ts +184 -60
  21. package/src/StreamSfuClient.ts +3 -3
  22. package/src/StreamVideoClient.ts +18 -3
  23. package/src/__tests__/Call.autodrop.test.ts +4 -1
  24. package/src/__tests__/Call.lifecycle.test.ts +4 -1
  25. package/src/__tests__/Call.publishing.test.ts +4 -1
  26. package/src/__tests__/Call.test.ts +23 -0
  27. package/src/coordinator/connection/client.ts +5 -0
  28. package/src/devices/__tests__/CameraManager.test.ts +10 -1
  29. package/src/devices/__tests__/DeviceManager.test.ts +10 -1
  30. package/src/devices/__tests__/DeviceManagerFilters.test.ts +4 -1
  31. package/src/devices/__tests__/MicrophoneManager.test.ts +10 -1
  32. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +4 -1
  33. package/src/devices/__tests__/ScreenShareManager.test.ts +4 -1
  34. package/src/devices/__tests__/SpeakerManager.test.ts +13 -4
  35. package/src/errors/SfuTimeoutError.ts +7 -0
  36. package/src/errors/index.ts +1 -0
  37. package/src/events/__tests__/call.test.ts +2 -0
  38. package/src/events/__tests__/mutes.test.ts +4 -1
  39. package/src/events/call.ts +8 -0
  40. package/src/helpers/__tests__/AudioBindingsWatchdog.test.ts +6 -3
  41. package/src/helpers/__tests__/DynascaleManager.test.ts +6 -3
  42. package/src/helpers/__tests__/ViewportTracker.test.ts +6 -3
  43. package/src/helpers/__tests__/firstVideoFrame.test.ts +91 -0
  44. package/src/helpers/firstVideoFrame.ts +38 -0
  45. package/src/reporting/ClientEventReporter.ts +859 -0
  46. package/src/reporting/__tests__/ClientEventReporter.test.ts +620 -0
  47. package/src/reporting/index.ts +1 -0
  48. package/src/rtc/BasePeerConnection.ts +30 -0
  49. package/src/rtc/Subscriber.ts +1 -0
  50. package/src/rtc/__tests__/Call.reconnect.test.ts +3 -0
  51. package/src/rtc/types.ts +34 -0
  52. package/src/types.ts +6 -0
@@ -1,6 +1,7 @@
1
1
  /* @vitest-environment happy-dom */
2
2
  import { Call } from '../../Call';
3
3
  import { StreamClient } from '../../coordinator/connection/client';
4
+ import { ClientEventReporter } from '../../reporting';
4
5
  import { CallingState, StreamVideoWriteableStateStore } from '../../store';
5
6
 
6
7
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -22,6 +23,12 @@ import { TrackType } from '../../gen/video/sfu/models/models';
22
23
  import { PermissionsContext } from '../../permissions';
23
24
  import { readPreferences } from '../devicePersistence';
24
25
 
26
+ vi.mock('../../reporting/ClientEventReporter', () => ({
27
+ ClientEventReporter: vi.fn(function () {
28
+ return {};
29
+ }),
30
+ }));
31
+
25
32
  vi.mock('../../Call.ts', () => {
26
33
  console.log('MOCKING Call');
27
34
  return {
@@ -85,11 +92,13 @@ describe('Device Manager', () => {
85
92
  configurable: true,
86
93
  value: localStorageMock,
87
94
  });
95
+ const streamClient = new StreamClient('abc123');
88
96
  manager = new TestInputMediaDeviceManager(
89
97
  new Call({
90
98
  id: '',
91
99
  type: '',
92
- streamClient: new StreamClient('abc123'),
100
+ streamClient,
101
+ clientEventReporter: new ClientEventReporter({ streamClient }),
93
102
  clientStore: new StreamVideoWriteableStateStore(),
94
103
  }),
95
104
  { enabled: false, storageKey },
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { of } from 'rxjs';
3
3
  import { Call } from '../../Call';
4
4
  import { StreamClient } from '../../coordinator/connection/client';
5
+ import { ClientEventReporter } from '../../reporting';
5
6
  import { StreamVideoWriteableStateStore } from '../../store';
6
7
  import { DeviceManagerState } from '../DeviceManagerState';
7
8
  import { DeviceManager } from '../DeviceManager';
@@ -45,11 +46,13 @@ describe('MediaStream Filters', () => {
45
46
  let manager: TestInputMediaDeviceManager;
46
47
 
47
48
  beforeEach(() => {
49
+ const streamClient = new StreamClient('abc123');
48
50
  manager = new TestInputMediaDeviceManager(
49
51
  new Call({
50
52
  id: '',
51
53
  type: '',
52
- streamClient: new StreamClient('abc123'),
54
+ streamClient,
55
+ clientEventReporter: new ClientEventReporter({ streamClient }),
53
56
  clientStore: new StreamVideoWriteableStateStore(),
54
57
  }),
55
58
  );
@@ -43,6 +43,7 @@ import {
43
43
  readPreferences,
44
44
  toPreferenceList,
45
45
  } from '../devicePersistence';
46
+ import { ClientEventReporter } from '../../reporting';
46
47
 
47
48
  vi.mock('../devices.ts', () => {
48
49
  console.log('MOCKING devices API');
@@ -82,6 +83,12 @@ vi.mock('../../Call.ts', () => {
82
83
  };
83
84
  });
84
85
 
86
+ vi.mock('../../reporting/ClientEventReporter', () => ({
87
+ ClientEventReporter: vi.fn(function () {
88
+ return {};
89
+ }),
90
+ }));
91
+
85
92
  describe('MicrophoneManager', () => {
86
93
  let manager: MicrophoneManager;
87
94
  let call: Call;
@@ -92,10 +99,12 @@ describe('MicrophoneManager', () => {
92
99
  of('granted'),
93
100
  );
94
101
 
102
+ const streamClient = new StreamClient('abc123');
95
103
  call = new Call({
96
104
  id: '',
97
105
  type: '',
98
- streamClient: new StreamClient('abc123'),
106
+ streamClient,
107
+ clientEventReporter: new ClientEventReporter({ streamClient }),
99
108
  clientStore: new StreamVideoWriteableStateStore(),
100
109
  });
101
110
  const devicePersistence = { enabled: false, storageKey: '' };
@@ -13,6 +13,7 @@ import { of } from 'rxjs';
13
13
  import '../../rtc/__tests__/mocks/webrtc.mocks';
14
14
  import { OwnCapability } from '../../gen/coordinator';
15
15
  import { settled, withoutConcurrency } from '../../helpers/concurrency';
16
+ import { ClientEventReporter } from '../../reporting';
16
17
 
17
18
  let speechActivityCallback:
18
19
  | ((state: { isSoundDetected: boolean }) => void)
@@ -82,12 +83,14 @@ describe('MicrophoneManager React Native', () => {
82
83
  };
83
84
 
84
85
  const devicePersistence = { enabled: false, storageKey: '' };
86
+ const streamClient = new StreamClient('abc123');
85
87
  manager = new MicrophoneManager(
86
88
  new Call({
87
89
  id: '',
88
90
  type: '',
89
- streamClient: new StreamClient('abc123'),
91
+ streamClient,
90
92
  clientStore: new StreamVideoWriteableStateStore(),
93
+ clientEventReporter: new ClientEventReporter({ streamClient }),
91
94
  }),
92
95
  devicePersistence,
93
96
  );
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { ScreenShareManager } from '../ScreenShareManager';
3
3
  import { Call } from '../../Call';
4
4
  import { StreamClient } from '../../coordinator/connection/client';
5
+ import { ClientEventReporter } from '../../reporting';
5
6
  import { CallingState, StreamVideoWriteableStateStore } from '../../store';
6
7
  import * as RxUtils from '../../store/rxUtils';
7
8
  import { mockCall, mockDeviceIds$, mockScreenShareStream } from './mocks';
@@ -36,11 +37,13 @@ describe('ScreenShareManager', () => {
36
37
  let manager: ScreenShareManager;
37
38
 
38
39
  beforeEach(() => {
40
+ const streamClient = new StreamClient('abc123');
39
41
  manager = new ScreenShareManager(
40
42
  new Call({
41
43
  id: '',
42
44
  type: '',
43
- streamClient: new StreamClient('abc123'),
45
+ streamClient,
46
+ clientEventReporter: new ClientEventReporter({ streamClient }),
44
47
  clientStore: new StreamVideoWriteableStateStore(),
45
48
  }),
46
49
  );
@@ -14,6 +14,7 @@ import { SpeakerManager } from '../SpeakerManager';
14
14
  import { checkIfAudioOutputChangeSupported } from '../devices';
15
15
  import { Call } from '../../Call';
16
16
  import { StreamClient } from '../../coordinator/connection/client';
17
+ import { ClientEventReporter } from '../../reporting';
17
18
  import { StreamVideoWriteableStateStore } from '../../store';
18
19
  import { defaultDeviceId } from '../devicePersistence';
19
20
 
@@ -45,11 +46,13 @@ describe('SpeakerManager.test', () => {
45
46
  value: localStorageMock,
46
47
  });
47
48
  const devicePersistence = { enabled: false, storageKey };
49
+ const streamClient = new StreamClient('abc123');
48
50
  manager = new SpeakerManager(
49
51
  new Call({
50
52
  id: '',
51
53
  type: '',
52
- streamClient: new StreamClient('abc123'),
54
+ streamClient,
55
+ clientEventReporter: new ClientEventReporter({ streamClient }),
53
56
  clientStore: new StreamVideoWriteableStateStore(),
54
57
  }),
55
58
  devicePersistence,
@@ -129,11 +132,13 @@ describe('SpeakerManager.test', () => {
129
132
  });
130
133
 
131
134
  it('persists speaker selection when permission is granted', async () => {
135
+ const streamClient = new StreamClient('abc123');
132
136
  const persistedManager = new SpeakerManager(
133
137
  new Call({
134
138
  id: '',
135
139
  type: '',
136
- streamClient: new StreamClient('abc123'),
140
+ streamClient,
141
+ clientEventReporter: new ClientEventReporter({ streamClient }),
137
142
  clientStore: new StreamVideoWriteableStateStore(),
138
143
  }),
139
144
  { enabled: true, storageKey },
@@ -162,11 +167,13 @@ describe('SpeakerManager.test', () => {
162
167
  });
163
168
 
164
169
  it('selects the persisted speaker device', () => {
170
+ const streamClient = new StreamClient('abc123');
165
171
  const persistedManager = new SpeakerManager(
166
172
  new Call({
167
173
  id: '',
168
174
  type: '',
169
- streamClient: new StreamClient('abc123'),
175
+ streamClient,
176
+ clientEventReporter: new ClientEventReporter({ streamClient }),
170
177
  clientStore: new StreamVideoWriteableStateStore(),
171
178
  }),
172
179
  { enabled: true, storageKey },
@@ -193,11 +200,13 @@ describe('SpeakerManager.test', () => {
193
200
  });
194
201
 
195
202
  it('selects system default when persisted device is default', () => {
203
+ const streamClient = new StreamClient('abc123');
196
204
  const persistedManager = new SpeakerManager(
197
205
  new Call({
198
206
  id: '',
199
207
  type: '',
200
- streamClient: new StreamClient('abc123'),
208
+ streamClient,
209
+ clientEventReporter: new ClientEventReporter({ streamClient }),
201
210
  clientStore: new StreamVideoWriteableStateStore(),
202
211
  }),
203
212
  { enabled: true, storageKey },
@@ -0,0 +1,7 @@
1
+ /**
2
+ * An error thrown when a client-side SFU deadline (e.g., waiting for the
3
+ * signaling WS to open or for the `joinResponse` to arrive) fires before
4
+ * the awaited operation resolves. Allows consumers (e.g., the client event
5
+ * reporter) to classify timeouts without relying on message wording.
6
+ */
7
+ export class SfuTimeoutError extends Error {}
@@ -1 +1,2 @@
1
1
  export * from './SfuJoinError';
2
+ export * from './SfuTimeoutError';
@@ -16,6 +16,7 @@ import {
16
16
  } from '../../gen/coordinator';
17
17
  import { Call } from '../../Call';
18
18
  import { StreamClient } from '../../coordinator/connection/client';
19
+ import { ClientEventReporter } from '../../reporting';
19
20
  import { SfuEvent } from '../../gen/video/sfu/event/events';
20
21
  import { CallEndedReason } from '../../gen/video/sfu/models/models';
21
22
 
@@ -372,6 +373,7 @@ const fakeCall = ({ ring = true, currentUserId = 'test-user-id' } = {}) => {
372
373
  id: '12345',
373
374
  clientStore: store,
374
375
  streamClient: client,
376
+ clientEventReporter: new ClientEventReporter({ streamClient: client }),
375
377
  ringing: ring,
376
378
  });
377
379
  };
@@ -5,6 +5,7 @@ import {
5
5
  TrackUnpublishReason,
6
6
  } from '../../gen/video/sfu/models/models';
7
7
  import { StreamClient } from '../../coordinator/connection/client';
8
+ import { ClientEventReporter } from '../../reporting';
8
9
  import { handleRemoteSoftMute } from '../mutes';
9
10
  import type { CallEventListener } from '../../coordinator/connection/types';
10
11
 
@@ -15,10 +16,12 @@ describe('mutes', () => {
15
16
 
16
17
  beforeEach(() => {
17
18
  // @ts-expect-error incomplete data
19
+ const streamClient = new StreamClient('api-key');
18
20
  call = new Call({
19
21
  type: 'test',
20
22
  id: 'test',
21
- streamClient: new StreamClient('api-key'),
23
+ streamClient,
24
+ clientEventReporter: new ClientEventReporter({ streamClient }),
22
25
  });
23
26
 
24
27
  // @ts-expect-error partial data
@@ -87,6 +87,10 @@ export const watchCallEnded = (call: Call) => {
87
87
  callingState !== CallingState.IDLE &&
88
88
  callingState !== CallingState.LEFT
89
89
  ) {
90
+ call.clientEventReporter.abort(call.cid, {
91
+ code: 'BACKEND_LEAVE',
92
+ reason: 'call.ended event received',
93
+ });
90
94
  call
91
95
  .leave({ message: 'call.ended event received', reject: false })
92
96
  .catch((err) => {
@@ -116,6 +120,10 @@ export const watchSfuCallEnded = (call: Call) => {
116
120
  call.state.setEndedAt(new Date());
117
121
  const reason = CallEndedReason[e.reason];
118
122
  globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
123
+ call.clientEventReporter.abort(call.cid, {
124
+ code: 'BACKEND_LEAVE',
125
+ reason: `callEnded received: ${reason}`,
126
+ });
119
127
  await call.leave({ message: `callEnded received: ${reason}` });
120
128
  } catch (err) {
121
129
  call.logger.error(
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
8
  import { AudioBindingsWatchdog } from '../AudioBindingsWatchdog';
9
9
  import { Call } from '../../Call';
10
10
  import { StreamClient } from '../../coordinator/connection/client';
11
+ import { ClientEventReporter } from '../../reporting';
11
12
  import { CallingState, StreamVideoWriteableStateStore } from '../../store';
12
13
  import { noopComparator } from '../../sorting';
13
14
  import { fromPartial } from '@total-typescript/shoehorn';
@@ -19,12 +20,14 @@ describe('AudioBindingsWatchdog', () => {
19
20
 
20
21
  beforeEach(() => {
21
22
  vi.useFakeTimers();
23
+ const streamClient = new StreamClient('api-key', {
24
+ devicePersistence: { enabled: false },
25
+ });
22
26
  call = new Call({
23
27
  id: 'id',
24
28
  type: 'default',
25
- streamClient: new StreamClient('api-key', {
26
- devicePersistence: { enabled: false },
27
- }),
29
+ streamClient,
30
+ clientEventReporter: new ClientEventReporter({ streamClient }),
28
31
  clientStore: new StreamVideoWriteableStateStore(),
29
32
  });
30
33
  call.setSortParticipantsBy(noopComparator());
@@ -17,6 +17,7 @@ import {
17
17
  import { DynascaleManager } from '../DynascaleManager';
18
18
  import { Call } from '../../Call';
19
19
  import { StreamClient } from '../../coordinator/connection/client';
20
+ import { ClientEventReporter } from '../../reporting';
20
21
  import { StreamVideoWriteableStateStore } from '../../store';
21
22
  import { getCurrentValue } from '../../store/rxUtils';
22
23
  import { VisibilityState } from '../../types';
@@ -36,12 +37,14 @@ describe('DynascaleManager', () => {
36
37
  let call: Call;
37
38
 
38
39
  beforeEach(() => {
40
+ const streamClient = new StreamClient('api-key', {
41
+ devicePersistence: { enabled: false },
42
+ });
39
43
  call = new Call({
40
44
  id: 'id',
41
45
  type: 'default',
42
- streamClient: new StreamClient('api-key', {
43
- devicePersistence: { enabled: false },
44
- }),
46
+ streamClient,
47
+ clientEventReporter: new ClientEventReporter({ streamClient }),
45
48
  clientStore: new StreamVideoWriteableStateStore(),
46
49
  });
47
50
  call.setSortParticipantsBy(noopComparator());
@@ -7,6 +7,7 @@ import '../../rtc/__tests__/mocks/webrtc.mocks';
7
7
  import { beforeEach, describe, expect, it, vi } from 'vitest';
8
8
  import { Call } from '../../Call';
9
9
  import { StreamClient } from '../../coordinator/connection/client';
10
+ import { ClientEventReporter } from '../../reporting';
10
11
  import { StreamVideoWriteableStateStore } from '../../store';
11
12
  import { noopComparator } from '../../sorting';
12
13
  import { VisibilityState } from '../../types';
@@ -17,12 +18,14 @@ describe('ViewportTracker', () => {
17
18
  let viewportTracker: ViewportTracker;
18
19
 
19
20
  beforeEach(() => {
21
+ const streamClient = new StreamClient('api-key', {
22
+ devicePersistence: { enabled: false },
23
+ });
20
24
  call = new Call({
21
25
  id: 'id',
22
26
  type: 'default',
23
- streamClient: new StreamClient('api-key', {
24
- devicePersistence: { enabled: false },
25
- }),
27
+ streamClient,
28
+ clientEventReporter: new ClientEventReporter({ streamClient }),
26
29
  clientStore: new StreamVideoWriteableStateStore(),
27
30
  });
28
31
  call.setSortParticipantsBy(noopComparator());
@@ -0,0 +1,91 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createFirstVideoFrameDetector } from '../firstVideoFrame';
3
+
4
+ const createVideoElement = () => {
5
+ const listeners = new Map<string, EventListener>();
6
+
7
+ return {
8
+ readyState: 0,
9
+ HAVE_CURRENT_DATA: 2,
10
+ addEventListener: vi.fn((type: string, listener: EventListener) => {
11
+ listeners.set(type, listener);
12
+ }),
13
+ removeEventListener: vi.fn((type: string, listener: EventListener) => {
14
+ if (listeners.get(type) === listener) {
15
+ listeners.delete(type);
16
+ }
17
+ }),
18
+ dispatchEvent: vi.fn((event: Event) => {
19
+ listeners.get(event.type)?.(event);
20
+ return true;
21
+ }),
22
+ } as unknown as HTMLVideoElement;
23
+ };
24
+
25
+ describe('createFirstVideoFrameDetector', () => {
26
+ let videoElement: HTMLVideoElement;
27
+
28
+ beforeEach(() => {
29
+ videoElement = createVideoElement();
30
+ });
31
+
32
+ it('invokes the callback once on loadeddata when video frame callbacks are unavailable', () => {
33
+ const onFirstFrame = vi.fn();
34
+ const stop = createFirstVideoFrameDetector(videoElement, onFirstFrame);
35
+
36
+ videoElement.dispatchEvent(new Event('loadeddata'));
37
+ videoElement.dispatchEvent(new Event('loadeddata'));
38
+
39
+ expect(onFirstFrame).toHaveBeenCalledOnce();
40
+ stop();
41
+ });
42
+
43
+ it('cancels the loadeddata fallback', () => {
44
+ const onFirstFrame = vi.fn();
45
+ const stop = createFirstVideoFrameDetector(videoElement, onFirstFrame);
46
+
47
+ stop();
48
+ videoElement.dispatchEvent(new Event('loadeddata'));
49
+
50
+ expect(onFirstFrame).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it('invokes the callback when frame data is already available', async () => {
54
+ const onFirstFrame = vi.fn();
55
+ Object.defineProperty(videoElement, 'requestVideoFrameCallback', {
56
+ configurable: true,
57
+ value: undefined,
58
+ });
59
+ Object.defineProperty(videoElement, 'HAVE_CURRENT_DATA', {
60
+ configurable: true,
61
+ value: 2,
62
+ });
63
+ Object.defineProperty(videoElement, 'readyState', {
64
+ configurable: true,
65
+ value: 2,
66
+ });
67
+
68
+ createFirstVideoFrameDetector(videoElement, onFirstFrame);
69
+ await Promise.resolve();
70
+
71
+ expect(onFirstFrame).toHaveBeenCalledOnce();
72
+ });
73
+
74
+ it('uses requestVideoFrameCallback when available', () => {
75
+ const onFirstFrame = vi.fn();
76
+ let frameCallback: VideoFrameRequestCallback | undefined;
77
+ videoElement.requestVideoFrameCallback = vi.fn((callback) => {
78
+ frameCallback = callback;
79
+ return 1;
80
+ });
81
+ videoElement.cancelVideoFrameCallback = vi.fn();
82
+
83
+ const stop = createFirstVideoFrameDetector(videoElement, onFirstFrame);
84
+ frameCallback?.(0, {} as VideoFrameCallbackMetadata);
85
+
86
+ expect(onFirstFrame).toHaveBeenCalledOnce();
87
+
88
+ stop();
89
+ expect(videoElement.cancelVideoFrameCallback).toHaveBeenCalledWith(1);
90
+ });
91
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Invokes `onFirstFrame` once when the video element renders a frame.
3
+ *
4
+ * Uses `requestVideoFrameCallback` when available, falling back to `loadeddata`
5
+ * for browsers that don't support it.
6
+ */
7
+ export const createFirstVideoFrameDetector = (
8
+ videoElement: HTMLVideoElement,
9
+ onFirstFrame: () => void,
10
+ ) => {
11
+ let done = false;
12
+ const notify = () => {
13
+ if (done) return;
14
+ done = true;
15
+ onFirstFrame();
16
+ };
17
+
18
+ if (typeof videoElement.requestVideoFrameCallback === 'function') {
19
+ const handle = videoElement.requestVideoFrameCallback(notify);
20
+ return () => {
21
+ done = true;
22
+ videoElement.cancelVideoFrameCallback(handle);
23
+ };
24
+ }
25
+
26
+ if (videoElement.readyState >= videoElement.HAVE_CURRENT_DATA) {
27
+ queueMicrotask(notify);
28
+ return () => {
29
+ done = true;
30
+ };
31
+ }
32
+
33
+ videoElement.addEventListener('loadeddata', notify, { once: true });
34
+ return () => {
35
+ done = true;
36
+ videoElement.removeEventListener('loadeddata', notify);
37
+ };
38
+ };