@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
@@ -0,0 +1,243 @@
1
+ import { BehaviorSubject, map, shareReplay } from 'rxjs';
2
+ import { DebounceType } from '../types';
3
+ import type { TrackSubscriptionDetails } from '../gen/video/sfu/signal_rpc/signal';
4
+ import { TrackType, VideoDimension } from '../gen/video/sfu/models/models';
5
+ import { CallState } from '../store';
6
+ import { getCurrentValue, setCurrentValue } from '../store/rxUtils';
7
+ import type { StreamSfuClient } from '../StreamSfuClient';
8
+ import { videoLoggerSystem } from '../logger';
9
+ import { Tracer } from '../stats';
10
+ import {
11
+ hasScreenShare,
12
+ hasScreenShareAudio,
13
+ hasVideo,
14
+ } from './participantUtils';
15
+
16
+ /**
17
+ * Per-participant (or global) video-subscription override.
18
+ *
19
+ * - `{ enabled: true, dimension }`: request video at a specific
20
+ * resolution. If set globally, applies to every remote participant
21
+ * that doesn't have a per-session override.
22
+ * - `{ enabled: false }`: unsubscribe from video entirely. The SFU
23
+ * sends nothing; bandwidth is saved.
24
+ */
25
+ export type VideoTrackSubscriptionOverride =
26
+ | {
27
+ enabled: true;
28
+ dimension: VideoDimension;
29
+ }
30
+ | { enabled: false };
31
+
32
+ /** Symbol key for the "applies to all participants" override slot. */
33
+ const globalOverrideKey = Symbol('globalOverrideKey');
34
+
35
+ /**
36
+ * Map of per-session overrides plus one optional global override under
37
+ * the `globalOverrideKey` symbol slot.
38
+ */
39
+ export interface VideoTrackSubscriptionOverrides {
40
+ [sessionId: string]: VideoTrackSubscriptionOverride | undefined;
41
+ [globalOverrideKey]?: VideoTrackSubscriptionOverride;
42
+ }
43
+
44
+ /**
45
+ * Owns the SFU-side video-subscription machinery for a `Call`:
46
+ *
47
+ * - Holds the per-session / global override state in a
48
+ * `BehaviorSubject<VideoTrackSubscriptionOverrides>`.
49
+ * - Derives the SFU subscription list from `CallState` participants +
50
+ * current overrides via the `subscriptions` getter.
51
+ * - Debounces and pushes the list to the SFU through
52
+ * `sfuClient.updateSubscriptions`.
53
+ * - Exposes `incomingVideoSettings$`: a consumer-friendly projection of
54
+ * the override state for React hooks.
55
+ *
56
+ * Owned by `DynascaleManager` as `readonly trackSubscriptionManager`.
57
+ * `DynascaleManager.bindVideoElement` triggers `apply()` on every
58
+ * dimension / visibility change.
59
+ */
60
+ export class TrackSubscriptionManager {
61
+ private logger = videoLoggerSystem.getLogger('TrackSubscriptionManager');
62
+ private callState: CallState;
63
+ private tracer: Tracer;
64
+
65
+ private sfuClient: StreamSfuClient | undefined;
66
+ private pendingUpdate: NodeJS.Timeout | null = null;
67
+
68
+ private overridesSubject =
69
+ new BehaviorSubject<VideoTrackSubscriptionOverrides>({});
70
+
71
+ overrides$ = this.overridesSubject.asObservable();
72
+
73
+ /**
74
+ * Consumer-friendly projection of the override state. Used by the
75
+ * `useIncomingVideoSettings()` React hook.
76
+ */
77
+ incomingVideoSettings$ = this.overrides$.pipe(
78
+ map((overrides) => {
79
+ const { [globalOverrideKey]: globalSettings, ...participants } =
80
+ overrides;
81
+ return {
82
+ enabled: globalSettings?.enabled !== false,
83
+ preferredResolution: globalSettings?.enabled
84
+ ? globalSettings.dimension
85
+ : undefined,
86
+ participants: Object.fromEntries(
87
+ Object.entries(participants).map(
88
+ ([sessionId, participantOverride]) => [
89
+ sessionId,
90
+ {
91
+ enabled: participantOverride?.enabled !== false,
92
+ preferredResolution: participantOverride?.enabled
93
+ ? participantOverride.dimension
94
+ : undefined,
95
+ },
96
+ ],
97
+ ),
98
+ ),
99
+ isParticipantVideoEnabled: (sessionId: string) =>
100
+ overrides[sessionId]?.enabled ??
101
+ overrides[globalOverrideKey]?.enabled ??
102
+ true,
103
+ };
104
+ }),
105
+ shareReplay(1),
106
+ );
107
+
108
+ /**
109
+ * Constructs new TrackSubscriptionManager instance.
110
+ *
111
+ * @param callState the call state.
112
+ * @param tracer the tracer to use.
113
+ */
114
+ constructor(callState: CallState, tracer: Tracer) {
115
+ this.tracer = tracer;
116
+ this.callState = callState;
117
+ }
118
+
119
+ /**
120
+ * Sets the SFU client used by `apply()` to push subscription updates.
121
+ * Called by the owner on call join; cleared on leave.
122
+ */
123
+ setSfuClient = (sfuClient: StreamSfuClient | undefined) => {
124
+ this.sfuClient = sfuClient;
125
+ };
126
+
127
+ /**
128
+ * Cancels any pending debounced subscription push. Idempotent.
129
+ */
130
+ dispose = () => {
131
+ if (this.pendingUpdate) {
132
+ clearTimeout(this.pendingUpdate);
133
+ this.pendingUpdate = null;
134
+ }
135
+ };
136
+
137
+ /**
138
+ * The current SFU subscription list, computed from `CallState`
139
+ * participants and the override state. Used by:
140
+ *
141
+ * - `apply()` to push to the SFU each time the set changes.
142
+ * - `Call.getReconnectDetails` to include the subscription list in
143
+ * the reconnect payload.
144
+ */
145
+ get subscriptions(): TrackSubscriptionDetails[] {
146
+ const subscriptions: TrackSubscriptionDetails[] = [];
147
+ // Use getParticipantsSnapshot() to bypass the observable pipeline
148
+ // and avoid stale data caused by shareReplay with no active subscribers
149
+ const participants = this.callState.getParticipantsSnapshot();
150
+ const overrides = this.overridesSubject.getValue();
151
+ for (const p of participants) {
152
+ if (p.isLocalParticipant) continue;
153
+ // NOTE: audio tracks don't have to be requested explicitly
154
+ // as the SFU will implicitly subscribe us to all of them,
155
+ // once they become available.
156
+ if (p.videoDimension && hasVideo(p)) {
157
+ const override = overrides[p.sessionId] ?? overrides[globalOverrideKey];
158
+
159
+ if (override?.enabled !== false) {
160
+ subscriptions.push({
161
+ userId: p.userId,
162
+ sessionId: p.sessionId,
163
+ trackType: TrackType.VIDEO,
164
+ dimension: override?.dimension ?? p.videoDimension,
165
+ });
166
+ }
167
+ }
168
+ if (p.screenShareDimension && hasScreenShare(p)) {
169
+ subscriptions.push({
170
+ userId: p.userId,
171
+ sessionId: p.sessionId,
172
+ trackType: TrackType.SCREEN_SHARE,
173
+ dimension: p.screenShareDimension,
174
+ });
175
+ }
176
+ if (hasScreenShareAudio(p)) {
177
+ subscriptions.push({
178
+ userId: p.userId,
179
+ sessionId: p.sessionId,
180
+ trackType: TrackType.SCREEN_SHARE_AUDIO,
181
+ });
182
+ }
183
+ }
184
+ return subscriptions;
185
+ }
186
+
187
+ get overrides() {
188
+ return getCurrentValue(this.overrides$);
189
+ }
190
+
191
+ /**
192
+ * Sets video-subscription overrides. Called by
193
+ * `Call.setIncomingVideoEnabled` and
194
+ * `Call.setPreferredIncomingVideoResolution`.
195
+ *
196
+ * - `sessionIds` omitted → applies `override` globally (or clears the
197
+ * global override if `override` is `undefined`).
198
+ * - `sessionIds` provided → applies `override` to each listed session.
199
+ */
200
+ setOverrides = (
201
+ override: VideoTrackSubscriptionOverride | undefined,
202
+ sessionIds?: string[],
203
+ ) => {
204
+ this.tracer.trace('setOverrides', [override, sessionIds]);
205
+ if (!sessionIds) {
206
+ return setCurrentValue(
207
+ this.overridesSubject,
208
+ override ? { [globalOverrideKey]: override } : {},
209
+ );
210
+ }
211
+
212
+ return setCurrentValue(this.overridesSubject, (overrides) => ({
213
+ ...overrides,
214
+ ...Object.fromEntries(sessionIds.map((id) => [id, override])),
215
+ }));
216
+ };
217
+
218
+ /**
219
+ * Pushes `subscriptions` to the SFU. Debounced by `debounceType`
220
+ * (SLOW by default). Multiple rapid calls coalesce into one RPC.
221
+ * Passing `0` fires synchronously.
222
+ */
223
+ apply = (debounceType: DebounceType = DebounceType.SLOW) => {
224
+ if (this.pendingUpdate) {
225
+ clearTimeout(this.pendingUpdate);
226
+ }
227
+
228
+ const updateSubscriptions = () => {
229
+ this.pendingUpdate = null;
230
+ this.sfuClient
231
+ ?.updateSubscriptions(this.subscriptions)
232
+ .catch((err: unknown) => {
233
+ this.logger.debug(`Failed to update track subscriptions`, err);
234
+ });
235
+ };
236
+
237
+ if (debounceType) {
238
+ this.pendingUpdate = setTimeout(updateSubscriptions, debounceType);
239
+ } else {
240
+ updateSubscriptions();
241
+ }
242
+ };
243
+ }
@@ -1,5 +1,16 @@
1
+ import type { CallState } from '../store';
2
+ import { VideoTrackType, VisibilityState } from '../types';
3
+
1
4
  const DEFAULT_THRESHOLD = 0.35;
2
5
 
6
+ const DEFAULT_VIEWPORT_VISIBILITY_STATE: Record<
7
+ VideoTrackType,
8
+ VisibilityState
9
+ > = {
10
+ videoTrack: VisibilityState.UNKNOWN,
11
+ screenShareTrack: VisibilityState.UNKNOWN,
12
+ } as const;
13
+
3
14
  export type EntryHandler = (entry: IntersectionObserverEntry) => void;
4
15
 
5
16
  export type Unobserve = () => void;
@@ -10,33 +21,28 @@ export type Observe = (
10
21
  ) => Unobserve;
11
22
 
12
23
  export class ViewportTracker {
13
- /**
14
- * @private
15
- */
24
+ private callState: CallState;
25
+
16
26
  private elementHandlerMap: Map<
17
27
  HTMLElement,
18
28
  (entry: IntersectionObserverEntry) => void
19
29
  > = new Map();
20
- /**
21
- * @private
22
- */
30
+
23
31
  private observer: IntersectionObserver | null = null;
32
+
24
33
  // in React children render before viewport is set, add
25
34
  // them to the queue and observe them once the observer is ready
26
- /**
27
- * @private
28
- */
29
35
  private queueSet: Set<readonly [HTMLElement, EntryHandler]> = new Set();
30
36
 
37
+ constructor(callState: CallState) {
38
+ this.callState = callState;
39
+ }
40
+
31
41
  /**
32
42
  * Method to set scrollable viewport as root for the IntersectionObserver, returns
33
43
  * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
34
- *
35
- * @param viewportElement
36
- * @param options
37
- * @returns Unobserve
38
44
  */
39
- public setViewport = (
45
+ setViewport = (
40
46
  viewportElement: HTMLElement,
41
47
  options?: Pick<IntersectionObserverInit, 'threshold' | 'rootMargin'>,
42
48
  ) => {
@@ -81,12 +87,8 @@ export class ViewportTracker {
81
87
  * Method to set element to observe and handler to be triggered whenever IntersectionObserver
82
88
  * detects a possible change in element's visibility within specified viewport, returns
83
89
  * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
84
- *
85
- * @param element
86
- * @param handler
87
- * @returns Unobserve
88
90
  */
89
- public observe: Observe = (element, handler) => {
91
+ observe: Observe = (element, handler) => {
90
92
  const queueItem = [element, handler] as const;
91
93
 
92
94
  const cleanup = () => {
@@ -109,4 +111,57 @@ export class ViewportTracker {
109
111
 
110
112
  return cleanup;
111
113
  };
114
+
115
+ /**
116
+ * Tracks the given element for visibility changes and mirrors the result
117
+ * into `participant.viewportVisibilityState[trackType]` in `CallState`.
118
+ * Returns a function that unobserves the element and resets the visibility
119
+ * state back to `UNKNOWN`.
120
+ */
121
+ trackElementVisibility = <T extends HTMLElement>(
122
+ element: T,
123
+ sessionId: string,
124
+ trackType: VideoTrackType,
125
+ ) => {
126
+ const cleanup = this.observe(element, (entry) => {
127
+ this.callState.updateParticipant(sessionId, (participant) => {
128
+ const previousVisibilityState =
129
+ participant.viewportVisibilityState ??
130
+ DEFAULT_VIEWPORT_VISIBILITY_STATE;
131
+
132
+ // observer triggers when the element is "moved" to be a fullscreen element
133
+ // keep it VISIBLE if that happens to prevent fullscreen with placeholder
134
+ const isVisible =
135
+ entry.isIntersecting || document.fullscreenElement === element
136
+ ? VisibilityState.VISIBLE
137
+ : VisibilityState.INVISIBLE;
138
+ return {
139
+ ...participant,
140
+ viewportVisibilityState: {
141
+ ...previousVisibilityState,
142
+ [trackType]: isVisible,
143
+ },
144
+ };
145
+ });
146
+ });
147
+
148
+ return () => {
149
+ cleanup();
150
+ // reset visibility state to UNKNOWN upon cleanup
151
+ // so that the layouts that are not actively observed
152
+ // can still function normally (runtime layout switching)
153
+ this.callState.updateParticipant(sessionId, (participant) => {
154
+ const previousVisibilityState =
155
+ participant.viewportVisibilityState ??
156
+ DEFAULT_VIEWPORT_VISIBILITY_STATE;
157
+ return {
158
+ ...participant,
159
+ viewportVisibilityState: {
160
+ ...previousVisibilityState,
161
+ [trackType]: VisibilityState.UNKNOWN,
162
+ },
163
+ };
164
+ });
165
+ };
166
+ };
112
167
  }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { BlockedAudioTracker } from '../BlockedAudioTracker';
7
+ import { getCurrentValue } from '../../store/rxUtils';
8
+ import type { Tracer } from '../../stats';
9
+
10
+ const createTracer = () =>
11
+ ({
12
+ trace: vi.fn(),
13
+ }) as unknown as Tracer;
14
+
15
+ const createAudioElement = (withSrcObject = true) => {
16
+ const el = document.createElement('audio');
17
+ Object.defineProperty(el, 'srcObject', { writable: true });
18
+ if (withSrcObject) {
19
+ (el as HTMLAudioElement).srcObject = new MediaStream();
20
+ }
21
+ return el;
22
+ };
23
+
24
+ describe('BlockedAudioTracker', () => {
25
+ let tracer: Tracer;
26
+ let tracker: BlockedAudioTracker;
27
+
28
+ beforeEach(() => {
29
+ tracer = createTracer();
30
+ tracker = new BlockedAudioTracker(tracer);
31
+ });
32
+
33
+ it('emits autoplayBlocked$ true after markBlocked(el, true)', () => {
34
+ expect(getCurrentValue(tracker.autoplayBlocked$)).toBe(false);
35
+ tracker.markBlocked(createAudioElement(), true);
36
+ expect(getCurrentValue(tracker.autoplayBlocked$)).toBe(true);
37
+ });
38
+
39
+ it('emits autoplayBlocked$ false after the last element is unmarked', () => {
40
+ const el1 = createAudioElement();
41
+ const el2 = createAudioElement();
42
+ tracker.markBlocked(el1, true);
43
+ tracker.markBlocked(el2, true);
44
+ expect(getCurrentValue(tracker.autoplayBlocked$)).toBe(true);
45
+
46
+ tracker.markBlocked(el1, false);
47
+ expect(getCurrentValue(tracker.autoplayBlocked$)).toBe(true);
48
+
49
+ tracker.markBlocked(el2, false);
50
+ expect(getCurrentValue(tracker.autoplayBlocked$)).toBe(false);
51
+ });
52
+
53
+ it('is idempotent: marking the same element twice keeps a single entry', () => {
54
+ const el = createAudioElement();
55
+ tracker.markBlocked(el, true);
56
+ tracker.markBlocked(el, true);
57
+ tracker.markBlocked(el, false);
58
+ expect(getCurrentValue(tracker.autoplayBlocked$)).toBe(false);
59
+ });
60
+
61
+ it('resumeAudio plays each element with srcObject and clears successful ones', async () => {
62
+ const el1 = createAudioElement();
63
+ const el2 = createAudioElement();
64
+ const play1 = vi.spyOn(el1, 'play').mockResolvedValue();
65
+ const play2 = vi.spyOn(el2, 'play').mockResolvedValue();
66
+
67
+ tracker.markBlocked(el1, true);
68
+ tracker.markBlocked(el2, true);
69
+
70
+ await tracker.resumeAudio();
71
+
72
+ expect(play1).toHaveBeenCalled();
73
+ expect(play2).toHaveBeenCalled();
74
+ expect(getCurrentValue(tracker.autoplayBlocked$)).toBe(false);
75
+ });
76
+
77
+ it('resumeAudio keeps elements whose play() still rejects', async () => {
78
+ const ok = createAudioElement();
79
+ const stillBlocked = createAudioElement();
80
+ vi.spyOn(ok, 'play').mockResolvedValue();
81
+ vi.spyOn(stillBlocked, 'play').mockRejectedValue(
82
+ new DOMException('', 'NotAllowedError'),
83
+ );
84
+
85
+ tracker.markBlocked(ok, true);
86
+ tracker.markBlocked(stillBlocked, true);
87
+
88
+ await tracker.resumeAudio();
89
+
90
+ expect(getCurrentValue(tracker.autoplayBlocked$)).toBe(true);
91
+
92
+ // Resuming again with the now-cooperative element should clear the flag.
93
+ vi.spyOn(stillBlocked, 'play').mockResolvedValue();
94
+ await tracker.resumeAudio();
95
+
96
+ expect(getCurrentValue(tracker.autoplayBlocked$)).toBe(false);
97
+ });
98
+
99
+ it('resumeAudio drops elements without srcObject without calling play()', async () => {
100
+ const detached = createAudioElement(false);
101
+ const play = vi.spyOn(detached, 'play').mockResolvedValue();
102
+
103
+ tracker.markBlocked(detached, true);
104
+ await tracker.resumeAudio();
105
+
106
+ expect(play).not.toHaveBeenCalled();
107
+ expect(getCurrentValue(tracker.autoplayBlocked$)).toBe(false);
108
+ });
109
+
110
+ it('traces resumeAudio', async () => {
111
+ await tracker.resumeAudio();
112
+ expect(tracer.trace).toHaveBeenCalledWith('resumeAudio', null);
113
+ });
114
+ });