@stream-io/video-client 1.49.0 → 1.51.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 +22 -0
- package/dist/index.browser.es.js +1404 -682
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1404 -682
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1404 -682
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +43 -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/CameraManager.d.ts +1 -0
- package/dist/src/devices/DeviceManager.d.ts +23 -0
- package/dist/src/devices/DeviceManagerState.d.ts +0 -1
- package/dist/src/devices/VirtualDevice.d.ts +59 -0
- package/dist/src/devices/devicePersistence.d.ts +1 -1
- package/dist/src/devices/index.d.ts +1 -0
- 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/BasePeerConnection.d.ts +7 -2
- package/dist/src/rtc/Publisher.d.ts +38 -3
- package/dist/src/rtc/Subscriber.d.ts +1 -0
- package/dist/src/rtc/TransceiverCache.d.ts +5 -1
- package/dist/src/rtc/helpers/degradationPreference.d.ts +3 -0
- package/dist/src/rtc/types.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 +111 -33
- 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/CameraManager.ts +9 -2
- package/src/devices/DeviceManager.ts +239 -39
- package/src/devices/DeviceManagerState.ts +4 -2
- package/src/devices/VirtualDevice.ts +69 -0
- package/src/devices/__tests__/CameraManager.test.ts +19 -0
- package/src/devices/__tests__/DeviceManager.test.ts +404 -1
- package/src/devices/__tests__/mocks.ts +2 -0
- package/src/devices/devicePersistence.ts +2 -1
- package/src/devices/index.ts +1 -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/BasePeerConnection.ts +15 -3
- package/src/rtc/Publisher.ts +185 -40
- package/src/rtc/Subscriber.ts +42 -14
- package/src/rtc/TransceiverCache.ts +10 -3
- package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
- package/src/rtc/__tests__/Publisher.test.ts +747 -88
- package/src/rtc/__tests__/Subscriber.test.ts +148 -3
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +55 -0
- package/src/rtc/helpers/degradationPreference.ts +40 -0
- package/src/rtc/types.ts +2 -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,310 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { fromPartial } from '@total-typescript/shoehorn';
|
|
3
|
+
import { TrackSubscriptionManager } from '../TrackSubscriptionManager';
|
|
4
|
+
import { CallState } from '../../store';
|
|
5
|
+
import { Tracer } from '../../stats';
|
|
6
|
+
import { DebounceType } from '../../types';
|
|
7
|
+
import { TrackType } from '../../gen/video/sfu/models/models';
|
|
8
|
+
import type { StreamSfuClient } from '../../StreamSfuClient';
|
|
9
|
+
|
|
10
|
+
describe('TrackSubscriptionManager', () => {
|
|
11
|
+
let state: CallState;
|
|
12
|
+
let tracer: Tracer;
|
|
13
|
+
let manager: TrackSubscriptionManager;
|
|
14
|
+
let updateSubscriptions: ReturnType<typeof vi.fn>;
|
|
15
|
+
let sfuClient: Pick<StreamSfuClient, 'updateSubscriptions'>;
|
|
16
|
+
|
|
17
|
+
const addParticipant = (
|
|
18
|
+
sessionId: string,
|
|
19
|
+
overrides: Partial<{
|
|
20
|
+
userId: string;
|
|
21
|
+
publishedTracks: TrackType[];
|
|
22
|
+
videoDimension: { width: number; height: number };
|
|
23
|
+
screenShareDimension: { width: number; height: number };
|
|
24
|
+
isLocalParticipant: boolean;
|
|
25
|
+
}> = {},
|
|
26
|
+
) => {
|
|
27
|
+
state.updateOrAddParticipant(
|
|
28
|
+
sessionId,
|
|
29
|
+
fromPartial({
|
|
30
|
+
sessionId,
|
|
31
|
+
userId: overrides.userId ?? `user-${sessionId}`,
|
|
32
|
+
publishedTracks: overrides.publishedTracks ?? [],
|
|
33
|
+
videoDimension: overrides.videoDimension,
|
|
34
|
+
screenShareDimension: overrides.screenShareDimension,
|
|
35
|
+
isLocalParticipant: overrides.isLocalParticipant,
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
state = new CallState();
|
|
42
|
+
tracer = new Tracer('test');
|
|
43
|
+
manager = new TrackSubscriptionManager(state, tracer);
|
|
44
|
+
updateSubscriptions = vi.fn().mockResolvedValue(undefined);
|
|
45
|
+
sfuClient = { updateSubscriptions };
|
|
46
|
+
manager.setSfuClient(sfuClient as StreamSfuClient);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------
|
|
50
|
+
// subscriptions getter
|
|
51
|
+
// ---------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
it('subscriptions returns empty for a call with no remote participants', () => {
|
|
54
|
+
expect(manager.subscriptions).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('subscriptions returns one VIDEO entry per published remote video track, skipping the local participant', () => {
|
|
58
|
+
addParticipant('local', {
|
|
59
|
+
isLocalParticipant: true,
|
|
60
|
+
publishedTracks: [TrackType.VIDEO],
|
|
61
|
+
videoDimension: { width: 640, height: 480 },
|
|
62
|
+
});
|
|
63
|
+
addParticipant('remote-a', {
|
|
64
|
+
publishedTracks: [TrackType.VIDEO],
|
|
65
|
+
videoDimension: { width: 320, height: 240 },
|
|
66
|
+
});
|
|
67
|
+
addParticipant('remote-b', {
|
|
68
|
+
publishedTracks: [TrackType.VIDEO],
|
|
69
|
+
videoDimension: { width: 1280, height: 720 },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const subs = manager.subscriptions;
|
|
73
|
+
expect(subs).toHaveLength(2);
|
|
74
|
+
expect(subs.every((s) => s.sessionId !== 'local')).toBe(true);
|
|
75
|
+
expect(subs.map((s) => s.trackType)).toEqual([
|
|
76
|
+
TrackType.VIDEO,
|
|
77
|
+
TrackType.VIDEO,
|
|
78
|
+
]);
|
|
79
|
+
expect(subs.find((s) => s.sessionId === 'remote-a')?.dimension).toEqual({
|
|
80
|
+
width: 320,
|
|
81
|
+
height: 240,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('subscriptions includes SCREEN_SHARE + SCREEN_SHARE_AUDIO entries when published', () => {
|
|
86
|
+
addParticipant('presenter', {
|
|
87
|
+
publishedTracks: [
|
|
88
|
+
TrackType.VIDEO,
|
|
89
|
+
TrackType.SCREEN_SHARE,
|
|
90
|
+
TrackType.SCREEN_SHARE_AUDIO,
|
|
91
|
+
],
|
|
92
|
+
videoDimension: { width: 320, height: 240 },
|
|
93
|
+
screenShareDimension: { width: 1920, height: 1080 },
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const subs = manager.subscriptions;
|
|
97
|
+
expect(subs).toHaveLength(3);
|
|
98
|
+
const trackTypes = new Set(subs.map((s) => s.trackType));
|
|
99
|
+
expect(trackTypes.has(TrackType.VIDEO)).toBe(true);
|
|
100
|
+
expect(trackTypes.has(TrackType.SCREEN_SHARE)).toBe(true);
|
|
101
|
+
expect(trackTypes.has(TrackType.SCREEN_SHARE_AUDIO)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------
|
|
105
|
+
// setOverrides
|
|
106
|
+
// ---------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
it('applies a global override with a preferred dimension to every remote video subscription', () => {
|
|
109
|
+
addParticipant('a', {
|
|
110
|
+
publishedTracks: [TrackType.VIDEO],
|
|
111
|
+
videoDimension: { width: 320, height: 240 },
|
|
112
|
+
});
|
|
113
|
+
addParticipant('b', {
|
|
114
|
+
publishedTracks: [TrackType.VIDEO],
|
|
115
|
+
videoDimension: { width: 320, height: 240 },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
manager.setOverrides({
|
|
119
|
+
enabled: true,
|
|
120
|
+
dimension: { width: 1280, height: 720 },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const subs = manager.subscriptions;
|
|
124
|
+
expect(subs).toHaveLength(2);
|
|
125
|
+
expect(subs.every((s) => s.dimension?.width === 1280)).toBe(true);
|
|
126
|
+
expect(subs.every((s) => s.dimension?.height === 720)).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('applies a per-session override only to listed participants', () => {
|
|
130
|
+
addParticipant('a', {
|
|
131
|
+
publishedTracks: [TrackType.VIDEO],
|
|
132
|
+
videoDimension: { width: 320, height: 240 },
|
|
133
|
+
});
|
|
134
|
+
addParticipant('b', {
|
|
135
|
+
publishedTracks: [TrackType.VIDEO],
|
|
136
|
+
videoDimension: { width: 320, height: 240 },
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
manager.setOverrides(
|
|
140
|
+
{ enabled: true, dimension: { width: 1280, height: 720 } },
|
|
141
|
+
['a'],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const subs = manager.subscriptions;
|
|
145
|
+
const a = subs.find((s) => s.sessionId === 'a');
|
|
146
|
+
const b = subs.find((s) => s.sessionId === 'b');
|
|
147
|
+
expect(a?.dimension).toEqual({ width: 1280, height: 720 });
|
|
148
|
+
expect(b?.dimension).toEqual({ width: 320, height: 240 });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('drops video from the subscription list when the override sets enabled=false globally', () => {
|
|
152
|
+
addParticipant('a', {
|
|
153
|
+
publishedTracks: [TrackType.VIDEO],
|
|
154
|
+
videoDimension: { width: 320, height: 240 },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
manager.setOverrides({ enabled: false });
|
|
158
|
+
|
|
159
|
+
expect(manager.subscriptions).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------
|
|
163
|
+
// apply(): debouncing + SFU push
|
|
164
|
+
// ---------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
it('apply() debounces rapid calls into one SFU RPC with the exact subscription payload', () => {
|
|
167
|
+
vi.useFakeTimers();
|
|
168
|
+
addParticipant('a', {
|
|
169
|
+
publishedTracks: [TrackType.VIDEO],
|
|
170
|
+
videoDimension: { width: 320, height: 240 },
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
manager.apply(DebounceType.FAST);
|
|
174
|
+
manager.apply(DebounceType.FAST);
|
|
175
|
+
manager.apply(DebounceType.FAST);
|
|
176
|
+
|
|
177
|
+
expect(updateSubscriptions).not.toHaveBeenCalled();
|
|
178
|
+
vi.advanceTimersByTime(DebounceType.FAST);
|
|
179
|
+
expect(updateSubscriptions).toHaveBeenCalledTimes(1);
|
|
180
|
+
const [payload] = updateSubscriptions.mock.calls[0];
|
|
181
|
+
expect(payload).toEqual([
|
|
182
|
+
{
|
|
183
|
+
userId: 'user-a',
|
|
184
|
+
sessionId: 'a',
|
|
185
|
+
trackType: TrackType.VIDEO,
|
|
186
|
+
dimension: { width: 320, height: 240 },
|
|
187
|
+
},
|
|
188
|
+
]);
|
|
189
|
+
vi.useRealTimers();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('apply(0) fires synchronously - no timer involved - with the exact subscription payload', () => {
|
|
193
|
+
addParticipant('a', {
|
|
194
|
+
publishedTracks: [TrackType.VIDEO],
|
|
195
|
+
videoDimension: { width: 320, height: 240 },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// DebounceType is a numeric enum; the implementation uses a truthy
|
|
199
|
+
// check, so `0` takes the synchronous branch.
|
|
200
|
+
manager.apply(0 as DebounceType);
|
|
201
|
+
|
|
202
|
+
expect(updateSubscriptions).toHaveBeenCalledTimes(1);
|
|
203
|
+
const [payload] = updateSubscriptions.mock.calls[0];
|
|
204
|
+
expect(payload).toEqual([
|
|
205
|
+
{
|
|
206
|
+
userId: 'user-a',
|
|
207
|
+
sessionId: 'a',
|
|
208
|
+
trackType: TrackType.VIDEO,
|
|
209
|
+
dimension: { width: 320, height: 240 },
|
|
210
|
+
},
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('apply() with DebounceType.SLOW (default) fires after 1200ms', () => {
|
|
215
|
+
vi.useFakeTimers();
|
|
216
|
+
addParticipant('a', {
|
|
217
|
+
publishedTracks: [TrackType.VIDEO],
|
|
218
|
+
videoDimension: { width: 320, height: 240 },
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
manager.apply();
|
|
222
|
+
expect(updateSubscriptions).not.toHaveBeenCalled();
|
|
223
|
+
vi.advanceTimersByTime(DebounceType.SLOW);
|
|
224
|
+
expect(updateSubscriptions).toHaveBeenCalledTimes(1);
|
|
225
|
+
vi.useRealTimers();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------
|
|
229
|
+
// dispose() cancels pending timeout
|
|
230
|
+
// ---------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
it('dispose() cancels a pending debounced push: no RPC fires after dispose', () => {
|
|
233
|
+
vi.useFakeTimers();
|
|
234
|
+
addParticipant('a', {
|
|
235
|
+
publishedTracks: [TrackType.VIDEO],
|
|
236
|
+
videoDimension: { width: 320, height: 240 },
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
manager.apply(DebounceType.FAST);
|
|
240
|
+
manager.dispose();
|
|
241
|
+
vi.advanceTimersByTime(DebounceType.FAST * 2);
|
|
242
|
+
|
|
243
|
+
expect(updateSubscriptions).not.toHaveBeenCalled();
|
|
244
|
+
vi.useRealTimers();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------
|
|
248
|
+
// incomingVideoSettings$
|
|
249
|
+
// ---------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
it('incomingVideoSettings$ reflects global + per-session overrides', () => {
|
|
252
|
+
let latest: unknown;
|
|
253
|
+
const sub = manager.incomingVideoSettings$.subscribe((v) => {
|
|
254
|
+
latest = v;
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
manager.setOverrides({
|
|
258
|
+
enabled: true,
|
|
259
|
+
dimension: { width: 1280, height: 720 },
|
|
260
|
+
});
|
|
261
|
+
manager.setOverrides({ enabled: false }, ['muted-session']);
|
|
262
|
+
|
|
263
|
+
expect(latest).toMatchObject({
|
|
264
|
+
enabled: true,
|
|
265
|
+
preferredResolution: { width: 1280, height: 720 },
|
|
266
|
+
participants: {
|
|
267
|
+
'muted-session': { enabled: false, preferredResolution: undefined },
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
// isParticipantVideoEnabled helper honors per-session + global precedence.
|
|
271
|
+
expect(
|
|
272
|
+
(
|
|
273
|
+
latest as { isParticipantVideoEnabled: (id: string) => boolean }
|
|
274
|
+
).isParticipantVideoEnabled('muted-session'),
|
|
275
|
+
).toBe(false);
|
|
276
|
+
expect(
|
|
277
|
+
(
|
|
278
|
+
latest as { isParticipantVideoEnabled: (id: string) => boolean }
|
|
279
|
+
).isParticipantVideoEnabled('other-session'),
|
|
280
|
+
).toBe(true);
|
|
281
|
+
|
|
282
|
+
sub.unsubscribe();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('incomingVideoSettings$ replays the latest value to a late subscriber (shareReplay(1))', () => {
|
|
286
|
+
// Set state BEFORE any subscriber attaches.
|
|
287
|
+
manager.setOverrides({
|
|
288
|
+
enabled: true,
|
|
289
|
+
dimension: { width: 1280, height: 720 },
|
|
290
|
+
});
|
|
291
|
+
manager.setOverrides({ enabled: false }, ['muted-session']);
|
|
292
|
+
|
|
293
|
+
let latest: unknown;
|
|
294
|
+
const sub = manager.incomingVideoSettings$.subscribe((v) => {
|
|
295
|
+
latest = v;
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// The late subscriber must receive the buffered value synchronously
|
|
299
|
+
// on attach without needing a fresh setOverrides call.
|
|
300
|
+
expect(latest).toMatchObject({
|
|
301
|
+
enabled: true,
|
|
302
|
+
preferredResolution: { width: 1280, height: 720 },
|
|
303
|
+
participants: {
|
|
304
|
+
'muted-session': { enabled: false, preferredResolution: undefined },
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
sub.unsubscribe();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import '../../rtc/__tests__/mocks/webrtc.mocks';
|
|
6
|
+
|
|
7
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import { Call } from '../../Call';
|
|
9
|
+
import { StreamClient } from '../../coordinator/connection/client';
|
|
10
|
+
import { StreamVideoWriteableStateStore } from '../../store';
|
|
11
|
+
import { noopComparator } from '../../sorting';
|
|
12
|
+
import { VisibilityState } from '../../types';
|
|
13
|
+
import { ViewportTracker } from '../ViewportTracker';
|
|
14
|
+
|
|
15
|
+
describe('ViewportTracker', () => {
|
|
16
|
+
let call: Call;
|
|
17
|
+
let viewportTracker: ViewportTracker;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
call = new Call({
|
|
21
|
+
id: 'id',
|
|
22
|
+
type: 'default',
|
|
23
|
+
streamClient: new StreamClient('api-key', {
|
|
24
|
+
devicePersistence: { enabled: false },
|
|
25
|
+
}),
|
|
26
|
+
clientStore: new StreamVideoWriteableStateStore(),
|
|
27
|
+
});
|
|
28
|
+
call.setSortParticipantsBy(noopComparator());
|
|
29
|
+
viewportTracker = call.viewportTracker!;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('is constructed by Call and exposed as call.viewportTracker', () => {
|
|
33
|
+
expect(viewportTracker).toBeInstanceOf(ViewportTracker);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('updates participant viewportVisibilityState as visibility changes', () => {
|
|
37
|
+
let visibilityHandler:
|
|
38
|
+
| ((entry: IntersectionObserverEntry) => void)
|
|
39
|
+
| undefined;
|
|
40
|
+
vi.spyOn(viewportTracker, 'observe').mockImplementation((_el, handler) => {
|
|
41
|
+
visibilityHandler = handler;
|
|
42
|
+
return vi.fn();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// @ts-expect-error incomplete data
|
|
46
|
+
call.state.updateOrAddParticipant('session-id', {
|
|
47
|
+
userId: 'user-id',
|
|
48
|
+
sessionId: 'session-id',
|
|
49
|
+
publishedTracks: [],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const element = document.createElement('div');
|
|
53
|
+
const untrack = viewportTracker.trackElementVisibility(
|
|
54
|
+
element,
|
|
55
|
+
'session-id',
|
|
56
|
+
'videoTrack',
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(visibilityHandler).toBeDefined();
|
|
60
|
+
expect(viewportTracker.observe).toHaveBeenCalledWith(
|
|
61
|
+
element,
|
|
62
|
+
expect.any(Function),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
visibilityHandler!({ isIntersecting: true } as IntersectionObserverEntry);
|
|
66
|
+
expect(
|
|
67
|
+
call.state.findParticipantBySessionId('session-id')
|
|
68
|
+
?.viewportVisibilityState?.videoTrack,
|
|
69
|
+
).toBe(VisibilityState.VISIBLE);
|
|
70
|
+
|
|
71
|
+
visibilityHandler!({ isIntersecting: false } as IntersectionObserverEntry);
|
|
72
|
+
expect(
|
|
73
|
+
call.state.findParticipantBySessionId('session-id')
|
|
74
|
+
?.viewportVisibilityState?.videoTrack,
|
|
75
|
+
).toBe(VisibilityState.INVISIBLE);
|
|
76
|
+
|
|
77
|
+
untrack();
|
|
78
|
+
expect(
|
|
79
|
+
call.state.findParticipantBySessionId('session-id')
|
|
80
|
+
?.viewportVisibilityState?.videoTrack,
|
|
81
|
+
).toBe(VisibilityState.UNKNOWN);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
isChrome,
|
|
4
|
+
isFirefox,
|
|
5
|
+
isSafari,
|
|
6
|
+
isSupportedBrowser,
|
|
7
|
+
isWebKit,
|
|
8
|
+
} from '../browsers';
|
|
3
9
|
import { getClientDetails } from '../client-details';
|
|
4
10
|
import { ClientDetails } from '../../gen/video/sfu/models/models';
|
|
5
11
|
|
|
@@ -31,6 +37,84 @@ describe('browsers', () => {
|
|
|
31
37
|
});
|
|
32
38
|
});
|
|
33
39
|
|
|
40
|
+
describe('isWebKit', () => {
|
|
41
|
+
it('should return false for an empty user agent', () => {
|
|
42
|
+
expect(isWebKit()).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should return true for Safari on macOS', () => {
|
|
46
|
+
// @ts-expect-error - mocking navigator
|
|
47
|
+
globalThis.navigator.userAgent =
|
|
48
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
|
|
49
|
+
expect(isWebKit()).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should return true for Safari on iOS', () => {
|
|
53
|
+
// @ts-expect-error - mocking navigator
|
|
54
|
+
globalThis.navigator.userAgent =
|
|
55
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1';
|
|
56
|
+
expect(isWebKit()).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should return true for the default iOS WKWebView (no "Safari" token)', () => {
|
|
60
|
+
// The key case isSafari misses: default WKWebView UA omits the
|
|
61
|
+
// Safari token unless the host sets `applicationNameForUserAgent`.
|
|
62
|
+
// @ts-expect-error - mocking navigator
|
|
63
|
+
globalThis.navigator.userAgent =
|
|
64
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148';
|
|
65
|
+
expect(isWebKit()).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should return false for Chrome on macOS', () => {
|
|
69
|
+
// @ts-expect-error - mocking navigator
|
|
70
|
+
globalThis.navigator.userAgent =
|
|
71
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
72
|
+
expect(isWebKit()).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should return true for Chrome on iOS (CriOS) — still WKWebView underneath', () => {
|
|
76
|
+
// @ts-expect-error - mocking navigator
|
|
77
|
+
globalThis.navigator.userAgent =
|
|
78
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148 Safari/604.1';
|
|
79
|
+
expect(isWebKit()).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should return true for Edge on iOS (EdgiOS) — still WKWebView underneath', () => {
|
|
83
|
+
// @ts-expect-error - mocking navigator
|
|
84
|
+
globalThis.navigator.userAgent =
|
|
85
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 EdgiOS/120.0.0.0 Mobile/15E148 Safari/604.1';
|
|
86
|
+
expect(isWebKit()).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should return true for Opera on iOS (OPiOS) — still WKWebView underneath', () => {
|
|
90
|
+
// @ts-expect-error - mocking navigator
|
|
91
|
+
globalThis.navigator.userAgent =
|
|
92
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 OPiOS/16.0.0 Mobile/15E148 Safari/9537.53';
|
|
93
|
+
expect(isWebKit()).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should return true for Firefox on iOS (FxiOS) — still WKWebView underneath', () => {
|
|
97
|
+
// @ts-expect-error - mocking navigator
|
|
98
|
+
globalThis.navigator.userAgent =
|
|
99
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/120.0 Mobile/15E148 Safari/605.1.15';
|
|
100
|
+
expect(isWebKit()).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should return false for Firefox on desktop (no AppleWebKit token)', () => {
|
|
104
|
+
// @ts-expect-error - mocking navigator
|
|
105
|
+
globalThis.navigator.userAgent =
|
|
106
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0';
|
|
107
|
+
expect(isWebKit()).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should return false for Android Chrome', () => {
|
|
111
|
+
// @ts-expect-error - mocking navigator
|
|
112
|
+
globalThis.navigator.userAgent =
|
|
113
|
+
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36';
|
|
114
|
+
expect(isWebKit()).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
34
118
|
describe('isFirefox', () => {
|
|
35
119
|
it('should return false if navigator is undefined', () => {
|
|
36
120
|
expect(isFirefox()).toBe(false);
|
package/src/helpers/browsers.ts
CHANGED
|
@@ -8,6 +8,30 @@ export const isSafari = () => {
|
|
|
8
8
|
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Checks whether the current runtime is a WebKit-engine browser.
|
|
13
|
+
*
|
|
14
|
+
* Returns true for desktop Safari, iOS Safari, bare iOS WKWebView hosts
|
|
15
|
+
* (in-app browsers, React Native WebView, Tauri-on-macOS, etc.), and for
|
|
16
|
+
* Chromium / Gecko-branded iOS browsers (`CriOS`, `EdgiOS`, `OPiOS`,
|
|
17
|
+
* `FxiOS`) since Apple forces every iOS browser onto WKWebView and they
|
|
18
|
+
* share the underlying WebKit quirks.
|
|
19
|
+
*
|
|
20
|
+
* Returns false for desktop Chromium-based browsers (which reuse the
|
|
21
|
+
* `AppleWebKit/` token in their UA) and Android.
|
|
22
|
+
*/
|
|
23
|
+
export const isWebKit = () => {
|
|
24
|
+
if (typeof navigator === 'undefined') return false;
|
|
25
|
+
const ua = navigator.userAgent || '';
|
|
26
|
+
if (!/AppleWebKit\//.test(ua)) return false;
|
|
27
|
+
// Desktop Chromium reuses the AppleWebKit/ token. The `Chrome/` and
|
|
28
|
+
// `Chromium/` markers are only present on desktop Chromium builds
|
|
29
|
+
// (their iOS counterparts use `CriOS/` instead). `Android` rules out
|
|
30
|
+
// the mobile Blink stack.
|
|
31
|
+
const regExp = /Chrome\/|Chromium\/|Android/;
|
|
32
|
+
return !regExp.test(ua);
|
|
33
|
+
};
|
|
34
|
+
|
|
11
35
|
/**
|
|
12
36
|
* Checks whether the current browser is Firefox.
|
|
13
37
|
*/
|
|
@@ -3,8 +3,10 @@ interface PendingPromise {
|
|
|
3
3
|
onContinued: () => void;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
type TagKey = string | symbol | object;
|
|
7
|
+
|
|
6
8
|
type AsyncWrapper<P extends unknown[], T> = (
|
|
7
|
-
tag:
|
|
9
|
+
tag: TagKey,
|
|
8
10
|
cb: (...args: P) => Promise<T>,
|
|
9
11
|
) => {
|
|
10
12
|
cb: () => Promise<T>;
|
|
@@ -41,13 +43,13 @@ export const withoutConcurrency = createRunner(wrapWithContinuationTracking);
|
|
|
41
43
|
*/
|
|
42
44
|
export const withCancellation = createRunner(wrapWithCancellation);
|
|
43
45
|
|
|
44
|
-
const pendingPromises = new Map<
|
|
46
|
+
const pendingPromises = new Map<TagKey, PendingPromise>();
|
|
45
47
|
|
|
46
|
-
export function hasPending(tag:
|
|
48
|
+
export function hasPending(tag: TagKey) {
|
|
47
49
|
return pendingPromises.has(tag);
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
export async function settled(tag:
|
|
52
|
+
export async function settled(tag: TagKey) {
|
|
51
53
|
let pending: PendingPromise | undefined;
|
|
52
54
|
while ((pending = pendingPromises.get(tag))) {
|
|
53
55
|
await pending.promise;
|
|
@@ -66,7 +68,7 @@ export async function settled(tag: string | symbol) {
|
|
|
66
68
|
* is defined by the wrapper.
|
|
67
69
|
*/
|
|
68
70
|
function createRunner<P extends unknown[], T>(wrapper: AsyncWrapper<P, T>) {
|
|
69
|
-
return function run(tag:
|
|
71
|
+
return function run(tag: TagKey, cb: (...args: P) => Promise<T>) {
|
|
70
72
|
const { cb: wrapped, onContinued } = wrapper(tag, cb);
|
|
71
73
|
const pending = pendingPromises.get(tag);
|
|
72
74
|
pending?.onContinued();
|
|
@@ -83,10 +85,7 @@ function createRunner<P extends unknown[], T>(wrapper: AsyncWrapper<P, T>) {
|
|
|
83
85
|
* if the function is the last in the queue, it cleans up the whole chain
|
|
84
86
|
* of promises after finishing.
|
|
85
87
|
*/
|
|
86
|
-
function wrapWithContinuationTracking<T>(
|
|
87
|
-
tag: string | symbol,
|
|
88
|
-
cb: () => Promise<T>,
|
|
89
|
-
) {
|
|
88
|
+
function wrapWithContinuationTracking<T>(tag: TagKey, cb: () => Promise<T>) {
|
|
90
89
|
let hasContinuation = false;
|
|
91
90
|
const wrapped = () =>
|
|
92
91
|
cb().finally(() => {
|
|
@@ -108,7 +107,7 @@ function wrapWithContinuationTracking<T>(
|
|
|
108
107
|
* of promises after finishing.
|
|
109
108
|
*/
|
|
110
109
|
function wrapWithCancellation<T>(
|
|
111
|
-
tag:
|
|
110
|
+
tag: TagKey,
|
|
112
111
|
cb: (signal: AbortSignal) => Promise<T | 'canceled'>,
|
|
113
112
|
) {
|
|
114
113
|
const ac = new AbortController();
|
|
@@ -42,7 +42,7 @@ export abstract class BasePeerConnection {
|
|
|
42
42
|
private iceRestartTimeout?: NodeJS.Timeout;
|
|
43
43
|
private preConnectStuckTimeout?: NodeJS.Timeout;
|
|
44
44
|
protected isIceRestarting = false;
|
|
45
|
-
|
|
45
|
+
protected isDisposed = false;
|
|
46
46
|
|
|
47
47
|
protected trackIdToTrackType = new Map<string, TrackType>();
|
|
48
48
|
|
|
@@ -115,7 +115,7 @@ export abstract class BasePeerConnection {
|
|
|
115
115
|
/**
|
|
116
116
|
* Disposes the `RTCPeerConnection` instance.
|
|
117
117
|
*/
|
|
118
|
-
dispose() {
|
|
118
|
+
async dispose(): Promise<void> {
|
|
119
119
|
clearTimeout(this.iceRestartTimeout);
|
|
120
120
|
this.iceRestartTimeout = undefined;
|
|
121
121
|
clearTimeout(this.preConnectStuckTimeout);
|
|
@@ -141,6 +141,10 @@ export abstract class BasePeerConnection {
|
|
|
141
141
|
this.onIceConnectionStateChange,
|
|
142
142
|
);
|
|
143
143
|
pc.removeEventListener('icegatheringstatechange', this.onIceGatherChange);
|
|
144
|
+
pc.removeEventListener(
|
|
145
|
+
'connectionstatechange',
|
|
146
|
+
this.onConnectionStateChange,
|
|
147
|
+
);
|
|
144
148
|
this.unsubscribeIceTrickle?.();
|
|
145
149
|
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
|
146
150
|
this.subscriptions = [];
|
|
@@ -183,7 +187,7 @@ export abstract class BasePeerConnection {
|
|
|
183
187
|
const getTag = () => this.tag;
|
|
184
188
|
this.subscriptions.push(
|
|
185
189
|
this.dispatcher.on(event, getTag, (e) => {
|
|
186
|
-
const lockKey =
|
|
190
|
+
const lockKey = this.eventLockKey(event);
|
|
187
191
|
withoutConcurrency(lockKey, async () => fn(e)).catch((err) => {
|
|
188
192
|
if (this.isDisposed) return;
|
|
189
193
|
this.logger.warn(`Error handling ${event}`, err);
|
|
@@ -192,6 +196,14 @@ export abstract class BasePeerConnection {
|
|
|
192
196
|
);
|
|
193
197
|
};
|
|
194
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Returns the per-event `withoutConcurrency` tag used to serialize the
|
|
201
|
+
* dispatcher handler for `event` on this peer connection.
|
|
202
|
+
*/
|
|
203
|
+
protected eventLockKey = (event: keyof AllSfuEvents): string => {
|
|
204
|
+
return `pc.${this.lock}.${event}`;
|
|
205
|
+
};
|
|
206
|
+
|
|
195
207
|
/**
|
|
196
208
|
* Appends the trickled ICE candidates to the `RTCPeerConnection`.
|
|
197
209
|
*/
|