@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/index.browser.es.js +1086 -594
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1086 -594
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1086 -594
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +42 -3
  9. package/dist/src/coordinator/connection/client.d.ts +1 -1
  10. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  11. package/dist/src/coordinator/connection/types.d.ts +14 -0
  12. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  13. package/dist/src/devices/DeviceManager.d.ts +3 -0
  14. package/dist/src/devices/DeviceManagerState.d.ts +0 -1
  15. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  16. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  17. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  18. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  19. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  20. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  21. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  22. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  23. package/dist/src/helpers/browsers.d.ts +13 -0
  24. package/dist/src/helpers/concurrency.d.ts +6 -4
  25. package/dist/src/rtc/Publisher.d.ts +17 -0
  26. package/dist/src/rtc/Subscriber.d.ts +1 -0
  27. package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
  28. package/dist/src/stats/rtc/types.d.ts +1 -1
  29. package/dist/src/store/rxUtils.d.ts +9 -0
  30. package/dist/src/types.d.ts +18 -0
  31. package/package.json +2 -2
  32. package/src/Call.ts +89 -22
  33. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  34. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  35. package/src/coordinator/connection/client.ts +1 -1
  36. package/src/coordinator/connection/connection.ts +149 -96
  37. package/src/coordinator/connection/types.ts +15 -0
  38. package/src/coordinator/connection/utils.ts +15 -0
  39. package/src/devices/DeviceManager.ts +92 -32
  40. package/src/devices/DeviceManagerState.ts +0 -1
  41. package/src/devices/__tests__/DeviceManager.test.ts +283 -0
  42. package/src/devices/__tests__/mocks.ts +2 -0
  43. package/src/gen/video/sfu/event/events.ts +15 -0
  44. package/src/gen/video/sfu/models/models.ts +44 -0
  45. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  46. package/src/helpers/BlockedAudioTracker.ts +74 -0
  47. package/src/helpers/DynascaleManager.ts +46 -337
  48. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  49. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  50. package/src/helpers/ViewportTracker.ts +74 -19
  51. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  52. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  53. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  54. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  55. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  56. package/src/helpers/__tests__/browsers.test.ts +85 -1
  57. package/src/helpers/browsers.ts +24 -0
  58. package/src/helpers/concurrency.ts +9 -10
  59. package/src/rtc/Publisher.ts +47 -1
  60. package/src/rtc/Subscriber.ts +42 -14
  61. package/src/rtc/__tests__/Publisher.test.ts +122 -10
  62. package/src/rtc/__tests__/Subscriber.test.ts +146 -1
  63. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  64. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
  65. package/src/rtc/helpers/degradationPreference.ts +22 -0
  66. package/src/stats/rtc/types.ts +1 -0
  67. package/src/store/__tests__/rxUtils.test.ts +276 -0
  68. package/src/store/rxUtils.ts +19 -0
  69. 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 { isChrome, isFirefox, isSafari, isSupportedBrowser } from '../browsers';
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);
@@ -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: string | symbol,
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<string | symbol, PendingPromise>();
46
+ const pendingPromises = new Map<TagKey, PendingPromise>();
45
47
 
46
- export function hasPending(tag: string | symbol) {
48
+ export function hasPending(tag: TagKey) {
47
49
  return pendingPromises.has(tag);
48
50
  }
49
51
 
50
- export async function settled(tag: string | symbol) {
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: string | symbol, cb: (...args: P) => Promise<T>) {
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: string | symbol,
110
+ tag: TagKey,
112
111
  cb: (signal: AbortSignal) => Promise<T | 'canceled'>,
113
112
  ) {
114
113
  const ac = new AbortController();
@@ -20,6 +20,7 @@ import {
20
20
  toVideoLayers,
21
21
  } from './layers';
22
22
  import { isSvcCodec } from './codecs';
23
+ import { toRTCDegradationPreference } from './helpers/degradationPreference';
23
24
  import { isAudioTrackType } from './helpers/tracks';
24
25
  import { extractMid, removeCodecsExcept, setStartBitrate } from './helpers/sdp';
25
26
  import { withoutConcurrency } from '../helpers/concurrency';
@@ -135,7 +136,9 @@ export class Publisher extends BasePeerConnection {
135
136
  });
136
137
 
137
138
  const params = transceiver.sender.getParameters();
138
- params.degradationPreference = 'maintain-framerate';
139
+ params.degradationPreference =
140
+ toRTCDegradationPreference(publishOption.degradationPreference) ??
141
+ 'maintain-framerate';
139
142
  await transceiver.sender.setParameters(params);
140
143
 
141
144
  const trackType = publishOption.trackType;
@@ -248,6 +251,38 @@ export class Publisher extends BasePeerConnection {
248
251
  return false;
249
252
  };
250
253
 
254
+ /**
255
+ * Re-arms the encoder for the given track type by detaching and
256
+ * reattaching the currently published track on each matching sender.
257
+ *
258
+ * Workaround for a WebKit / iOS Safari quirk: after a system audio
259
+ * session interruption (Siri, PSTN call), the `RTCRtpSender` encoder
260
+ * can stop producing RTP packets even though the underlying
261
+ * `MediaStreamTrack` is `live` and `track.muted === false`.
262
+ * `replaceTrack(null)` followed by `replaceTrack(track)` resets the
263
+ * sender's encoder pipeline without renegotiation, restoring packet
264
+ * flow with the same SSRC.
265
+ *
266
+ * No-op when nothing is published for the given track type.
267
+ *
268
+ * @param trackType the track type to refresh.
269
+ */
270
+ refreshTrack = async (trackType: TrackType) => {
271
+ for (const item of this.transceiverCache.items()) {
272
+ if (item.publishOption.trackType !== trackType) continue;
273
+ const { sender } = item.transceiver;
274
+ const track = sender.track;
275
+ if (!track || track.readyState !== 'live') continue;
276
+ try {
277
+ await sender.replaceTrack(null);
278
+ await sender.replaceTrack(track);
279
+ this.logger.debug(`Refreshed ${TrackType[trackType]} sender`);
280
+ } catch (err) {
281
+ this.logger.warn(`Failed to refresh ${TrackType[trackType]}`, err);
282
+ }
283
+ }
284
+ };
285
+
251
286
  /**
252
287
  * Stops the cloned track that is being published to the SFU.
253
288
  */
@@ -344,6 +379,17 @@ export class Publisher extends BasePeerConnection {
344
379
  }
345
380
  }
346
381
 
382
+ const degradationPreference = toRTCDegradationPreference(
383
+ videoSender.degradationPreference,
384
+ );
385
+ if (
386
+ degradationPreference &&
387
+ params.degradationPreference !== degradationPreference
388
+ ) {
389
+ params.degradationPreference = degradationPreference;
390
+ changed = true;
391
+ }
392
+
347
393
  const activeEncoders = params.encodings.filter((e) => e.active);
348
394
  if (!changed) {
349
395
  return this.logger.info(`${tag} no change:`, activeEncoders);