@stream-io/video-client 1.59.0 → 1.60.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 (57) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +544 -111
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +544 -110
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +544 -111
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +23 -3
  9. package/dist/src/coordinator/connection/types.d.ts +16 -0
  10. package/dist/src/coordinator/connection/utils.d.ts +10 -0
  11. package/dist/src/events/call.d.ts +0 -11
  12. package/dist/src/gen/coordinator/index.d.ts +73 -0
  13. package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
  14. package/dist/src/ringing/RingStatePoller.d.ts +46 -0
  15. package/dist/src/ringing/RingTimeout.d.ts +27 -0
  16. package/dist/src/ringing/index.d.ts +4 -0
  17. package/dist/src/ringing/reconcileRingState.d.ts +18 -0
  18. package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
  19. package/dist/src/sorting/participants.d.ts +13 -0
  20. package/dist/src/stats/utils.d.ts +6 -0
  21. package/dist/src/store/CallState.d.ts +10 -1
  22. package/package.json +1 -1
  23. package/src/Call.ts +93 -61
  24. package/src/StreamSfuClient.ts +6 -2
  25. package/src/__tests__/Call.autodrop.test.ts +116 -64
  26. package/src/__tests__/Call.getRingState.test.ts +87 -0
  27. package/src/__tests__/Call.lifecycle.test.ts +23 -0
  28. package/src/__tests__/Call.ringSettled.test.ts +71 -0
  29. package/src/__tests__/StreamSfuClient.test.ts +48 -0
  30. package/src/__tests__/StreamVideoClient.ringing.test.ts +32 -20
  31. package/src/coordinator/connection/__tests__/eventListenerIsolation.test.ts +115 -0
  32. package/src/coordinator/connection/client.ts +3 -2
  33. package/src/coordinator/connection/types.ts +19 -0
  34. package/src/coordinator/connection/utils.ts +28 -0
  35. package/src/events/__tests__/call.test.ts +2 -217
  36. package/src/events/call.ts +1 -73
  37. package/src/events/callEventHandlers.ts +12 -23
  38. package/src/gen/coordinator/index.ts +67 -0
  39. package/src/reporting/ClientEventReporter.ts +30 -4
  40. package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
  41. package/src/ringing/RingStatePoller.ts +177 -0
  42. package/src/ringing/RingTimeout.ts +90 -0
  43. package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
  44. package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
  45. package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
  46. package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
  47. package/src/ringing/index.ts +4 -0
  48. package/src/ringing/reconcileRingState.ts +108 -0
  49. package/src/ringing/resolveOwnRingOutcome.ts +58 -0
  50. package/src/sorting/__tests__/presets.test.ts +188 -3
  51. package/src/sorting/__tests__/sorting.test.ts +22 -1
  52. package/src/sorting/participants.ts +20 -0
  53. package/src/sorting/presets.ts +4 -0
  54. package/src/stats/__tests__/utils.test.ts +33 -0
  55. package/src/stats/utils.ts +15 -0
  56. package/src/store/CallState.ts +25 -0
  57. package/src/store/__tests__/CallState.test.ts +61 -2
@@ -1,8 +1,54 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import * as TestData from './participant-data';
3
- import { VisibilityState } from '../../types';
4
- import { paginatedLayoutSortPreset } from '../presets';
5
- import { TrackType } from '../../gen/video/sfu/models/models';
3
+ import { StreamVideoParticipant, VisibilityState } from '../../types';
4
+ import { Comparator } from '../comparator';
5
+ import {
6
+ defaultSortPreset,
7
+ livestreamOrAudioRoomSortPreset,
8
+ paginatedLayoutSortPreset,
9
+ speakerLayoutSortPreset,
10
+ } from '../presets';
11
+ import {
12
+ ParticipantSource,
13
+ TrackType,
14
+ } from '../../gen/video/sfu/models/models';
15
+
16
+ const participant = (
17
+ name: string,
18
+ publishedTracks: TrackType[],
19
+ visibility: VisibilityState = VisibilityState.VISIBLE,
20
+ overrides: Partial<StreamVideoParticipant> = {},
21
+ ): StreamVideoParticipant =>
22
+ ({
23
+ name,
24
+ userId: name,
25
+ sessionId: name,
26
+ roles: ['user'],
27
+ publishedTracks,
28
+ trackLookupPrefix: name,
29
+ isSpeaking: false,
30
+ isDominantSpeaker: false,
31
+ audioLevel: 0,
32
+ image: '',
33
+ source: ParticipantSource.WEBRTC_UNSPECIFIED,
34
+ viewportVisibilityState: {
35
+ videoTrack: visibility,
36
+ screenShareTrack: visibility,
37
+ },
38
+ ...overrides,
39
+ }) as StreamVideoParticipant;
40
+
41
+ const names = (ps: StreamVideoParticipant[]) => ps.map((p) => p.name);
42
+
43
+ // every preset that prioritizes publishing participants over passive ones
44
+ const presets: [string, Comparator<StreamVideoParticipant>][] = [
45
+ ['defaultSortPreset', defaultSortPreset],
46
+ ['speakerLayoutSortPreset', speakerLayoutSortPreset],
47
+ ['livestreamOrAudioRoomSortPreset', livestreamOrAudioRoomSortPreset],
48
+ ];
49
+
50
+ const { UNKNOWN, VISIBLE, INVISIBLE } = VisibilityState;
51
+ const { AUDIO, VIDEO, SCREEN_SHARE } = TrackType;
6
52
 
7
53
  describe('presets', () => {
8
54
  it('paginatedLayoutSortPreset', () => {
@@ -60,4 +106,143 @@ describe('presets', () => {
60
106
  ]
61
107
  `);
62
108
  });
109
+
110
+ // In SpeakerLayout the viewport is the participants bar, so the spotlight
111
+ // tile is never observed and stays UNKNOWN. Regression test for a passive
112
+ // participant holding the spotlight over a participant publishing video.
113
+ it.each(presets)(
114
+ '%s keeps passive participants out of the spotlight',
115
+ (_, preset) => {
116
+ const ps = [
117
+ participant('passive-viewer', [], UNKNOWN),
118
+ participant('host', [VIDEO]),
119
+ participant('local-viewer', [], VISIBLE, { isLocalParticipant: true }),
120
+ ];
121
+
122
+ expect(names(ps.sort(preset))).toEqual([
123
+ 'host',
124
+ 'passive-viewer',
125
+ 'local-viewer',
126
+ ]);
127
+ },
128
+ );
129
+
130
+ it.each(presets)(
131
+ '%s promotes a rejoined publisher appended last',
132
+ (_, preset) => {
133
+ const ps = [
134
+ participant('v1', [], UNKNOWN),
135
+ participant('v2', []),
136
+ participant('v3', []),
137
+ ].sort(preset);
138
+ // a rejoining participant is appended to the end of the list
139
+ ps.push(participant('host', [AUDIO, VIDEO], UNKNOWN));
140
+
141
+ expect(names(ps.sort(preset))).toEqual(['host', 'v1', 'v2', 'v3']);
142
+ },
143
+ );
144
+
145
+ it.each(presets)(
146
+ '%s ranks passive participants below publishing ones regardless of visibility',
147
+ (_, preset) => {
148
+ const passive = [
149
+ participant('p1', [], UNKNOWN),
150
+ participant('p2', [], VISIBLE),
151
+ participant('p3', [], INVISIBLE),
152
+ ];
153
+ const publishing = [
154
+ participant('a1', [VIDEO], UNKNOWN),
155
+ participant('a2', [AUDIO], VISIBLE),
156
+ participant('a3', [AUDIO, VIDEO], INVISIBLE),
157
+ ];
158
+
159
+ for (const a of passive) {
160
+ for (const b of publishing) {
161
+ expect(preset(a, b)).toBe(1);
162
+ expect(preset(b, a)).toBe(-1);
163
+ }
164
+ }
165
+ },
166
+ );
167
+
168
+ // `publishing` ignores the track type, so a participant keeps its rank while
169
+ // it still publishes something. In speakerLayoutSortPreset the gated
170
+ // comparators cannot override this, because the gate only opens on INVISIBLE.
171
+ it.each<[string, TrackType[]]>([
172
+ ['camera and microphone on', [AUDIO, VIDEO]],
173
+ ['the microphone muted', [VIDEO]],
174
+ ['the camera turned off', [AUDIO]],
175
+ ])(
176
+ 'speakerLayoutSortPreset keeps the spotlight with %s',
177
+ (_, spotlightTracks) => {
178
+ const ps = [
179
+ participant('spotlight', spotlightTracks, UNKNOWN),
180
+ participant('bar', [AUDIO, VIDEO]),
181
+ ];
182
+
183
+ expect(names(ps.sort(speakerLayoutSortPreset))).toEqual([
184
+ 'spotlight',
185
+ 'bar',
186
+ ]);
187
+ },
188
+ );
189
+
190
+ it('speakerLayoutSortPreset drops a spotlight that stops publishing', () => {
191
+ const ps = [
192
+ participant('spotlight', [], UNKNOWN),
193
+ participant('bar', [AUDIO, VIDEO]),
194
+ ];
195
+
196
+ expect(names(ps.sort(speakerLayoutSortPreset))).toEqual([
197
+ 'bar',
198
+ 'spotlight',
199
+ ]);
200
+ });
201
+
202
+ // livestreamOrAudioRoomSortPreset gates on INVISIBLE *or* UNKNOWN, so an
203
+ // unobserved spotlight tile stays comparable by audio and video state. This
204
+ // predates the `publishing` comparator, which only breaks ties between a
205
+ // publishing and a non-publishing participant.
206
+ it('livestreamOrAudioRoomSortPreset still re-ranks publishers by track state', () => {
207
+ const ps = [
208
+ participant('spotlight', [VIDEO], UNKNOWN),
209
+ participant('bar', [AUDIO, VIDEO]),
210
+ ];
211
+
212
+ expect(names(ps.sort(livestreamOrAudioRoomSortPreset))).toEqual([
213
+ 'bar',
214
+ 'spotlight',
215
+ ]);
216
+ });
217
+
218
+ it.each<[string, TrackType[], Partial<StreamVideoParticipant>]>([
219
+ ['a pinned participant', [], { pin: { isLocalPin: true, pinnedAt: 1 } }],
220
+ ['a presenter', [SCREEN_SHARE], {}],
221
+ ['the dominant speaker', [AUDIO], { isDominantSpeaker: true }],
222
+ ])(
223
+ 'speakerLayoutSortPreset ranks %s above a publishing participant',
224
+ (_, tracks, overrides) => {
225
+ const ps = [
226
+ participant('publisher', [AUDIO, VIDEO], UNKNOWN),
227
+ participant('contender', tracks, VISIBLE, overrides),
228
+ ];
229
+
230
+ expect(names(ps.sort(speakerLayoutSortPreset))).toEqual([
231
+ 'contender',
232
+ 'publisher',
233
+ ]);
234
+ },
235
+ );
236
+
237
+ it('livestreamOrAudioRoomSortPreset still uses role as a tie breaker between publishers', () => {
238
+ const ps = [
239
+ participant('viewer', [AUDIO, VIDEO]),
240
+ participant('host', [AUDIO, VIDEO], UNKNOWN, { roles: ['host'] }),
241
+ ];
242
+
243
+ expect(names(ps.sort(livestreamOrAudioRoomSortPreset))).toEqual([
244
+ 'host',
245
+ 'viewer',
246
+ ]);
247
+ });
63
248
  });
@@ -1,11 +1,16 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { ParticipantSource } from '../../gen/video/sfu/models/models';
2
+ import {
3
+ ParticipantSource,
4
+ TrackType,
5
+ } from '../../gen/video/sfu/models/models';
6
+ import { StreamVideoParticipant } from '../../types';
3
7
  import {
4
8
  combineComparators,
5
9
  Comparator,
6
10
  conditional,
7
11
  dominantSpeaker,
8
12
  pinned,
13
+ publishing,
9
14
  publishingAudio,
10
15
  publishingVideo,
11
16
  screenSharing,
@@ -37,6 +42,22 @@ describe('Sorting', () => {
37
42
  expect(sorted.map((p) => p.name)).toEqual(['F', 'D', 'B', 'A', 'E', 'C']);
38
43
  });
39
44
 
45
+ it('publishing', () => {
46
+ const { AUDIO, VIDEO, SCREEN_SHARE } = TrackType;
47
+ const p = (publishedTracks: TrackType[]) =>
48
+ ({ publishedTracks }) as StreamVideoParticipant;
49
+
50
+ // publishing anything ranks above publishing nothing
51
+ expect(publishing(p([AUDIO]), p([]))).toEqual(-1);
52
+ expect(publishing(p([SCREEN_SHARE]), p([]))).toEqual(-1);
53
+ expect(publishing(p([]), p([VIDEO]))).toEqual(1);
54
+
55
+ // the track type is irrelevant, so device toggles do not change the rank
56
+ expect(publishing(p([AUDIO, VIDEO]), p([AUDIO]))).toEqual(0);
57
+ expect(publishing(p([VIDEO]), p([AUDIO]))).toEqual(0);
58
+ expect(publishing(p([]), p([]))).toEqual(0);
59
+ });
60
+
40
61
  it('conditional comparator', () => {
41
62
  const byValue: Comparator<number> = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
42
63
  const input = [2, 3, 1];
@@ -73,6 +73,26 @@ export const publishingAudio: Comparator<StreamVideoParticipant> = (a, b) => {
73
73
  return 0;
74
74
  };
75
75
 
76
+ /**
77
+ * A comparator which prioritizes participants who are publishing any track
78
+ * over participants who are not publishing at all.
79
+ *
80
+ * Unlike `publishingVideo` and `publishingAudio`, this comparator does not
81
+ * distinguish between track types. Muting the microphone or turning off the
82
+ * camera does not change a participant's rank as long as they still publish
83
+ * something, which keeps the ordering stable across ordinary device toggles.
84
+ *
85
+ * @param a the first participant.
86
+ * @param b the second participant.
87
+ */
88
+ export const publishing: Comparator<StreamVideoParticipant> = (a, b) => {
89
+ const hasA = a.publishedTracks.length > 0;
90
+ const hasB = b.publishedTracks.length > 0;
91
+ if (hasA && !hasB) return -1;
92
+ if (!hasA && hasB) return 1;
93
+ return 0;
94
+ };
95
+
76
96
  /**
77
97
  * A comparator which prioritizes participants who are pinned.
78
98
  *
@@ -4,6 +4,7 @@ import { ParticipantSource } from '../gen/video/sfu/models/models';
4
4
  import {
5
5
  dominantSpeaker,
6
6
  pinned,
7
+ publishing,
7
8
  publishingAudio,
8
9
  publishingVideo,
9
10
  reactionType,
@@ -51,6 +52,7 @@ const withVideoIngressSource = withParticipantSource(
51
52
  export const defaultSortPreset = combineComparators(
52
53
  screenSharing,
53
54
  pinned,
55
+ publishing,
54
56
  ifInvisibleBy(
55
57
  combineComparators(
56
58
  dominantSpeaker,
@@ -69,6 +71,7 @@ export const speakerLayoutSortPreset = combineComparators(
69
71
  screenSharing,
70
72
  pinned,
71
73
  dominantSpeaker,
74
+ publishing,
72
75
  ifInvisibleBy(
73
76
  combineComparators(
74
77
  speaking,
@@ -102,6 +105,7 @@ export const paginatedLayoutSortPreset = combineComparators(
102
105
  * The sorting preset for livestreams and audio rooms.
103
106
  */
104
107
  export const livestreamOrAudioRoomSortPreset = combineComparators(
108
+ publishing,
105
109
  ifInvisibleOrUnknownBy(
106
110
  combineComparators(
107
111
  dominantSpeaker,
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getStreamClientId } from '../utils';
3
+ import { SdkType } from '../../gen/video/sfu/models/models';
4
+
5
+ describe('getStreamClientId', () => {
6
+ const sdk = (type: SdkType) => ({
7
+ type,
8
+ major: '1',
9
+ minor: '43',
10
+ patch: '1',
11
+ });
12
+
13
+ it('names the React SDK', () => {
14
+ expect(getStreamClientId(sdk(SdkType.REACT))).toBe(
15
+ 'stream-video-react-v1.43.1',
16
+ );
17
+ });
18
+
19
+ it('names the React Native SDK', () => {
20
+ expect(getStreamClientId(sdk(SdkType.REACT_NATIVE))).toBe(
21
+ 'stream-video-react-native-v1.43.1',
22
+ );
23
+ });
24
+
25
+ it('falls back to the plain JS client for every other SDK type', () => {
26
+ expect(getStreamClientId(sdk(SdkType.PLAIN_JAVASCRIPT))).toBe(
27
+ 'stream-video-js-v1.43.1',
28
+ );
29
+ expect(getStreamClientId(undefined)).toBe(
30
+ 'stream-video-js-v0.0.0-development',
31
+ );
32
+ });
33
+ });
@@ -51,3 +51,18 @@ export const getSdkName = (sdk: Sdk | undefined) => {
51
51
  export const getSdkVersion = (sdk: Sdk | undefined) => {
52
52
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
53
53
  };
54
+
55
+ /**
56
+ * Returns the identifier the SFU receives in the `X-Stream-Client` header,
57
+ * e.g. `stream-video-react-v1.43.1`. Matches the format the coordinator
58
+ * already gets through the client's user agent.
59
+ */
60
+ export const getStreamClientId = (sdk: Sdk | undefined) => {
61
+ const sdkPlatforms: Partial<Record<SdkType, string>> = {
62
+ [SdkType.REACT]: 'react',
63
+ [SdkType.REACT_NATIVE]: 'react-native',
64
+ };
65
+
66
+ const platform = (sdk && sdkPlatforms[sdk.type]) || 'js';
67
+ return `stream-video-${platform}-v${getSdkVersion(sdk)}`;
68
+ };
@@ -37,6 +37,7 @@ import {
37
37
  CallSessionParticipantLeftEvent,
38
38
  CallSessionResponse,
39
39
  CallSettingsResponse,
40
+ GetCallRingStateResponse,
40
41
  ClosedCaptionEvent,
41
42
  EgressResponse,
42
43
  MemberResponse,
@@ -1352,6 +1353,30 @@ export class CallState {
1352
1353
  this.setCurrentValue(this.thumbnailsSubject, call.thumbnails);
1353
1354
  };
1354
1355
 
1356
+ /**
1357
+ * Merges a polled ring state into the current session. Only the ring fields
1358
+ * are touched: the endpoint does not return the session roster.
1359
+ *
1360
+ * @internal
1361
+ *
1362
+ * @param ringState the ring state as returned by the coordinator.
1363
+ */
1364
+ updateFromRingState = (ringState: GetCallRingStateResponse) => {
1365
+ this.setCurrentValue(this.sessionSubject, (session) => {
1366
+ if (!session || session.id !== ringState.session_id) return session;
1367
+ return {
1368
+ ...session,
1369
+ accepted_by: ringState.accepted_by,
1370
+ rejected_by: ringState.rejected_by,
1371
+ missed_by: ringState.missed_by,
1372
+ ended_at: ringState.session_ended_at ?? session.ended_at,
1373
+ };
1374
+ });
1375
+ if (ringState.call_ended_at) {
1376
+ this.setEndedAt(new Date(ringState.call_ended_at));
1377
+ }
1378
+ };
1379
+
1355
1380
  /**
1356
1381
  * Updates the call state with the data received from the SFU server.
1357
1382
  *
@@ -24,6 +24,7 @@ import {
24
24
  CallAcceptedEvent,
25
25
  CallEndedEvent,
26
26
  CallUpdatedEvent,
27
+ type GetCallRingStateResponse,
27
28
  MemberResponse,
28
29
  OwnCapability,
29
30
  } from '../../gen/coordinator';
@@ -266,8 +267,8 @@ describe('CallState', () => {
266
267
  "E",
267
268
  "F",
268
269
  "A",
269
- "C",
270
270
  "D",
271
+ "C",
271
272
  ]
272
273
  `);
273
274
 
@@ -288,8 +289,8 @@ describe('CallState', () => {
288
289
  "E",
289
290
  "F",
290
291
  "A",
291
- "C",
292
292
  "D",
293
+ "C",
293
294
  ]
294
295
  `);
295
296
  });
@@ -1474,4 +1475,62 @@ describe('CallState', () => {
1474
1475
  expect(state['closedCaptionsTasks'].size).toBe(0);
1475
1476
  });
1476
1477
  });
1478
+
1479
+ describe('updateFromRingState', () => {
1480
+ const ringState = fromPartial<GetCallRingStateResponse>({
1481
+ session_id: 'session-1',
1482
+ accepted_by: { bob: '2026-08-24T10:00:04Z' },
1483
+ rejected_by: { carol: '2026-08-24T10:00:09Z' },
1484
+ missed_by: { dave: '2026-08-24T10:00:35Z' },
1485
+ });
1486
+
1487
+ const withSession = (id: string) => {
1488
+ const state = new CallState();
1489
+ state['sessionSubject'].next(
1490
+ fromPartial({
1491
+ id,
1492
+ accepted_by: {},
1493
+ rejected_by: {},
1494
+ missed_by: {},
1495
+ participants: [fromPartial({ user_session_id: 'p1' })],
1496
+ }),
1497
+ );
1498
+ return state;
1499
+ };
1500
+
1501
+ it('merges the ring maps into the current session', () => {
1502
+ const state = withSession('session-1');
1503
+ state.updateFromRingState(ringState);
1504
+
1505
+ expect(state.session?.accepted_by).toEqual(ringState.accepted_by);
1506
+ expect(state.session?.rejected_by).toEqual(ringState.rejected_by);
1507
+ expect(state.session?.missed_by).toEqual(ringState.missed_by);
1508
+ });
1509
+
1510
+ it('leaves the session roster untouched', () => {
1511
+ const state = withSession('session-1');
1512
+ state.updateFromRingState(ringState);
1513
+
1514
+ expect(state.session?.participants).toHaveLength(1);
1515
+ });
1516
+
1517
+ it('ignores a ring state that belongs to another session', () => {
1518
+ const state = withSession('session-2');
1519
+ state.updateFromRingState(ringState);
1520
+
1521
+ expect(state.session?.accepted_by).toEqual({});
1522
+ });
1523
+
1524
+ it('applies the end timestamps', () => {
1525
+ const state = withSession('session-1');
1526
+ state.updateFromRingState({
1527
+ ...ringState,
1528
+ session_ended_at: '2026-08-24T10:01:00Z',
1529
+ call_ended_at: '2026-08-24T10:01:00Z',
1530
+ });
1531
+
1532
+ expect(state.session?.ended_at).toBe('2026-08-24T10:01:00Z');
1533
+ expect(state.endedAt).toEqual(new Date('2026-08-24T10:01:00Z'));
1534
+ });
1535
+ });
1477
1536
  });