@stream-io/video-client 1.49.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 +11 -0
- package/dist/index.browser.es.js +1086 -594
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1086 -594
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1086 -594
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +42 -3
- 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 +0 -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/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/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/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 +89 -22
- package/src/__tests__/Call.lifecycle.test.ts +67 -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 +0 -1
- package/src/devices/__tests__/DeviceManager.test.ts +283 -0
- package/src/devices/__tests__/mocks.ts +2 -0
- 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/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__/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/rtc/Publisher.ts +47 -1
- package/src/rtc/Subscriber.ts +42 -14
- package/src/rtc/__tests__/Publisher.test.ts +122 -10
- package/src/rtc/__tests__/Subscriber.test.ts +146 -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/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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|