@stream-io/video-client 1.59.0 → 2.0.0-beta.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.
@@ -1,6 +1,7 @@
1
1
  import type { AxiosResponse } from 'axios';
2
2
  import type { APIErrorResponse } from './types';
3
3
  import type { ConnectionErrorEvent } from '../../gen/coordinator';
4
+ import type { ScopedLogger } from '../../logger';
4
5
  export declare const sleep: (m: number) => Promise<unknown>;
5
6
  export declare const timeboxed: <T extends Promise<unknown>[]>(promises: [...T], ms: number) => Promise<{ [K in keyof T]: Awaited<T[K]>; }>;
6
7
  export declare function isFunction<T>(value: Function | T): value is Function;
@@ -25,3 +26,12 @@ export declare function addConnectionEventListeners(cb: (e: Event) => void): voi
25
26
  export declare function removeConnectionEventListeners(cb: (e: Event) => void): void;
26
27
  export declare function isErrorResponse(res: AxiosResponse<unknown>): res is AxiosResponse<APIErrorResponse>;
27
28
  export declare function isCloseEvent(res: CloseEvent | ConnectionErrorEvent): res is CloseEvent;
29
+ /**
30
+ * Invokes a single event listener in isolation, so that a faulty listener
31
+ * cannot abort the dispatch loop nor the bookkeeping that follows it.
32
+ *
33
+ * @param listener the listener to invoke.
34
+ * @param event the event to pass to the listener.
35
+ * @param logger the logger to report listener failures with.
36
+ */
37
+ export declare const invokeEventListener: <T>(listener: (event: T) => unknown, event: T, logger: ScopedLogger) => void;
@@ -36,6 +36,19 @@ export declare const publishingVideo: Comparator<StreamVideoParticipant>;
36
36
  * @param b the second participant.
37
37
  */
38
38
  export declare const publishingAudio: Comparator<StreamVideoParticipant>;
39
+ /**
40
+ * A comparator which prioritizes participants who are publishing any track
41
+ * over participants who are not publishing at all.
42
+ *
43
+ * Unlike `publishingVideo` and `publishingAudio`, this comparator does not
44
+ * distinguish between track types. Muting the microphone or turning off the
45
+ * camera does not change a participant's rank as long as they still publish
46
+ * something, which keeps the ordering stable across ordinary device toggles.
47
+ *
48
+ * @param a the first participant.
49
+ * @param b the second participant.
50
+ */
51
+ export declare const publishing: Comparator<StreamVideoParticipant>;
39
52
  /**
40
53
  * A comparator which prioritizes participants who are pinned.
41
54
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.59.0",
3
+ "version": "2.0.0-beta.0",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
@@ -17,7 +17,14 @@ const secret = process.env.STREAM_SECRET!;
17
17
 
18
18
  describe('StreamVideoClient Ringing', () => {
19
19
  const serverClient = new StreamClient(apiKey, secret);
20
- const testUserIds = ['oliver', 'sacha', 'marcelo'];
20
+
21
+ // user ids are generated per test: CI runs share a single Stream app, so
22
+ // fixed ids let a concurrently running job ring these users and satisfy the
23
+ // assertions in this file with an unrelated call.
24
+ let oliverId: string;
25
+ let sachaId: string;
26
+ let marceloId: string;
27
+ let testUserIds: string[];
21
28
 
22
29
  let oliverClient: StreamVideoClient;
23
30
  let sachaClient: StreamVideoClient;
@@ -54,6 +61,11 @@ describe('StreamVideoClient Ringing', () => {
54
61
  };
55
62
 
56
63
  beforeEach(async () => {
64
+ const suffix = crypto.randomUUID();
65
+ oliverId = `oliver-${suffix}`;
66
+ sachaId = `sacha-${suffix}`;
67
+ marceloId = `marcelo-${suffix}`;
68
+ testUserIds = [oliverId, sachaId, marceloId];
57
69
  [oliverClient, sachaClient, marceloClient] =
58
70
  await createClients(testUserIds);
59
71
  });
@@ -75,11 +87,11 @@ describe('StreamVideoClient Ringing', () => {
75
87
  await call.create({
76
88
  ring: true,
77
89
  data: {
78
- created_by_id: 'oliver',
90
+ created_by_id: oliverId,
79
91
  members: [
80
- { user_id: 'oliver' },
81
- { user_id: 'sacha' },
82
- { user_id: 'marcelo' },
92
+ { user_id: oliverId },
93
+ { user_id: sachaId },
94
+ { user_id: marceloId },
83
95
  ],
84
96
  },
85
97
  });
@@ -112,9 +124,9 @@ describe('StreamVideoClient Ringing', () => {
112
124
  ring: true,
113
125
  data: {
114
126
  members: [
115
- { user_id: 'oliver' },
116
- { user_id: 'sacha' },
117
- { user_id: 'marcelo' },
127
+ { user_id: oliverId },
128
+ { user_id: sachaId },
129
+ { user_id: marceloId },
118
130
  ],
119
131
  },
120
132
  });
@@ -144,9 +156,9 @@ describe('StreamVideoClient Ringing', () => {
144
156
  ring: false, // don't ring all members by default
145
157
  data: {
146
158
  members: [
147
- { user_id: 'oliver' },
148
- { user_id: 'sacha' },
149
- { user_id: 'marcelo' },
159
+ { user_id: oliverId },
160
+ { user_id: sachaId },
161
+ { user_id: marceloId },
150
162
  ],
151
163
  },
152
164
  });
@@ -162,7 +174,7 @@ describe('StreamVideoClient Ringing', () => {
162
174
  // oliver is calling sacha. only sacha should get a ring event
163
175
  const sachaIndividualRing = expectEvent(sachaClient, 'call.ring');
164
176
  const marceloIndividualRing = expectEvent(marceloClient, 'call.ring');
165
- await oliverCall.ring({ members_ids: ['sacha'] });
177
+ await oliverCall.ring({ members_ids: [sachaId] });
166
178
  await expect(sachaIndividualRing).resolves.toHaveProperty(
167
179
  'call.cid',
168
180
  oliverCall.cid,
@@ -175,7 +187,7 @@ describe('StreamVideoClient Ringing', () => {
175
187
  // sacha is calling marcelo. only marcelo should get a ring event
176
188
  const oliverIndividualRing = expectEvent(oliverClient, 'call.ring');
177
189
  const marceloIndividualRing2 = expectEvent(marceloClient, 'call.ring');
178
- await sachaCall.ring({ members_ids: ['marcelo'] });
190
+ await sachaCall.ring({ members_ids: [marceloId] });
179
191
  await expect(marceloIndividualRing2).resolves.toHaveProperty(
180
192
  'call.cid',
181
193
  sachaCall.cid,
@@ -241,8 +253,8 @@ describe('StreamVideoClient Ringing', () => {
241
253
  const serverCall = serverClient.video.call('default', callId);
242
254
  await serverCall.create({
243
255
  data: {
244
- created_by_id: 'oliver',
245
- members: [{ user_id: 'oliver' }, { user_id: 'sacha' }],
256
+ created_by_id: oliverId,
257
+ members: [{ user_id: oliverId }, { user_id: sachaId }],
246
258
  },
247
259
  });
248
260
 
@@ -266,7 +278,7 @@ describe('StreamVideoClient Ringing', () => {
266
278
  await oliverCall.create({
267
279
  ring: true,
268
280
  data: {
269
- members: [{ user_id: 'oliver' }, { user_id: 'sacha' }],
281
+ members: [{ user_id: oliverId }, { user_id: sachaId }],
270
282
  },
271
283
  });
272
284
 
@@ -302,7 +314,7 @@ describe('StreamVideoClient Ringing', () => {
302
314
  await marceloCall.create({
303
315
  ring: true,
304
316
  data: {
305
- members: [{ user_id: 'marcelo' }, { user_id: 'oliver' }],
317
+ members: [{ user_id: marceloId }, { user_id: oliverId }],
306
318
  },
307
319
  });
308
320
 
@@ -332,7 +344,7 @@ describe('StreamVideoClient Ringing', () => {
332
344
  await marceloCall.create({
333
345
  ring: true,
334
346
  data: {
335
- members: [{ user_id: 'marcelo' }, { user_id: 'sacha' }],
347
+ members: [{ user_id: marceloId }, { user_id: sachaId }],
336
348
  },
337
349
  });
338
350
 
@@ -354,7 +366,7 @@ describe('StreamVideoClient Ringing', () => {
354
366
  await marceloCall.create({
355
367
  ring: true,
356
368
  data: {
357
- members: [{ user_id: 'marcelo' }, { user_id: 'oliver' }],
369
+ members: [{ user_id: marceloId }, { user_id: oliverId }],
358
370
  },
359
371
  });
360
372
 
@@ -375,7 +387,7 @@ describe('StreamVideoClient Ringing', () => {
375
387
  await marceloCall.create({
376
388
  ring: true,
377
389
  data: {
378
- members: [{ user_id: 'marcelo' }, { user_id: 'sacha' }],
390
+ members: [{ user_id: marceloId }, { user_id: sachaId }],
379
391
  },
380
392
  });
381
393
 
@@ -0,0 +1,115 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { StreamClient } from '../client';
3
+ import { StableWSConnection } from '../connection';
4
+ import { videoLoggerSystem } from '../../../logger';
5
+ import type { StreamVideoEvent } from '../types';
6
+
7
+ const event: StreamVideoEvent = { type: 'network.changed', online: true };
8
+
9
+ describe('event listener isolation', () => {
10
+ let client: StreamClient;
11
+ let sink: ReturnType<typeof vi.fn>;
12
+
13
+ beforeEach(() => {
14
+ sink = vi.fn();
15
+ videoLoggerSystem.configureLoggers({
16
+ coordinator: { sink, level: 'error' },
17
+ });
18
+ client = new StreamClient('key');
19
+ });
20
+
21
+ afterEach(() => {
22
+ videoLoggerSystem.restoreDefaults();
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ const errorLogs = () =>
27
+ sink.mock.calls.filter(([level]) => level === 'error');
28
+
29
+ it('keeps dispatching after a listener throws', () => {
30
+ const failure = new Error('listener blew up');
31
+ const first = vi.fn(() => {
32
+ throw failure;
33
+ });
34
+ const second = vi.fn();
35
+
36
+ client.on('network.changed', first);
37
+ client.on('network.changed', second);
38
+
39
+ expect(() => client.dispatchEvent(event)).not.toThrow();
40
+ expect(first).toHaveBeenCalledTimes(1);
41
+ expect(second).toHaveBeenCalledTimes(1);
42
+ });
43
+
44
+ it('reports a synchronous throw through the logger', () => {
45
+ const failure = new Error('listener blew up');
46
+ client.on('network.changed', () => {
47
+ throw failure;
48
+ });
49
+
50
+ client.dispatchEvent(event);
51
+
52
+ expect(errorLogs()).toEqual([
53
+ [
54
+ 'error',
55
+ '[coordinator]: Unhandled error in event listener',
56
+ failure,
57
+ event,
58
+ ],
59
+ ]);
60
+ });
61
+
62
+ it('reports a rejection from an async listener', async () => {
63
+ const failure = new Error('async listener blew up');
64
+ client.on('network.changed', async () => {
65
+ throw failure;
66
+ });
67
+ const next = vi.fn();
68
+ client.on('network.changed', next);
69
+
70
+ client.dispatchEvent(event);
71
+ expect(next).toHaveBeenCalledTimes(1);
72
+ expect(errorLogs()).toHaveLength(0);
73
+
74
+ await vi.waitFor(() => expect(errorLogs()).toHaveLength(1));
75
+ expect(errorLogs()[0]).toEqual([
76
+ 'error',
77
+ '[coordinator]: Unhandled error in event listener',
78
+ failure,
79
+ event,
80
+ ]);
81
+ });
82
+
83
+ it('isolates a faulty `all` listener from type specific listeners', () => {
84
+ const all = vi.fn(() => {
85
+ throw new Error('all listener blew up');
86
+ });
87
+ const typed = vi.fn();
88
+
89
+ client.on('all', all);
90
+ client.on('network.changed', typed);
91
+
92
+ client.dispatchEvent(event);
93
+
94
+ expect(all).toHaveBeenCalledTimes(1);
95
+ expect(typed).toHaveBeenCalledTimes(1);
96
+ expect(errorLogs()).toHaveLength(1);
97
+ });
98
+
99
+ it('schedules the connection check even when a listener throws', () => {
100
+ const connection = new StableWSConnection(client);
101
+ const scheduleConnectionCheck = vi
102
+ .spyOn(connection, 'scheduleConnectionCheck')
103
+ .mockImplementation(() => {});
104
+
105
+ client.on('all', () => {
106
+ throw new Error('listener blew up');
107
+ });
108
+
109
+ connection.onmessage(connection.wsID, {
110
+ data: JSON.stringify({ type: 'custom' }),
111
+ } as MessageEvent);
112
+
113
+ expect(scheduleConnectionCheck).toHaveBeenCalledTimes(1);
114
+ });
115
+ });
@@ -10,6 +10,7 @@ import { TokenManager } from './token_manager';
10
10
  import {
11
11
  addConnectionEventListeners,
12
12
  generateUUIDv4,
13
+ invokeEventListener,
13
14
  isErrorResponse,
14
15
  KnownCodes,
15
16
  removeConnectionEventListeners,
@@ -589,12 +590,12 @@ export class StreamClient {
589
590
 
590
591
  // call generic listeners
591
592
  for (const listener of this.listeners.all || []) {
592
- listener(event);
593
+ invokeEventListener(listener, event, this.logger);
593
594
  }
594
595
 
595
596
  // call type specific listeners
596
597
  for (const listener of this.listeners[event.type] || []) {
597
- listener(event);
598
+ invokeEventListener(listener, event, this.logger);
598
599
  }
599
600
  };
600
601
 
@@ -1,6 +1,7 @@
1
1
  import type { AxiosResponse } from 'axios';
2
2
  import type { APIErrorResponse } from './types';
3
3
  import type { ConnectionErrorEvent } from '../../gen/coordinator';
4
+ import type { ScopedLogger } from '../../logger';
4
5
 
5
6
  export const sleep = (m: number) => new Promise((r) => setTimeout(r, m));
6
7
 
@@ -118,3 +119,30 @@ export function isCloseEvent(
118
119
  ): res is CloseEvent {
119
120
  return (res as CloseEvent).code !== undefined;
120
121
  }
122
+
123
+ /**
124
+ * Invokes a single event listener in isolation, so that a faulty listener
125
+ * cannot abort the dispatch loop nor the bookkeeping that follows it.
126
+ *
127
+ * @param listener the listener to invoke.
128
+ * @param event the event to pass to the listener.
129
+ * @param logger the logger to report listener failures with.
130
+ */
131
+ export const invokeEventListener = <T>(
132
+ listener: (event: T) => unknown,
133
+ event: T,
134
+ logger: ScopedLogger,
135
+ ) => {
136
+ try {
137
+ const result = listener(event);
138
+ if (result && typeof (result as PromiseLike<unknown>).then === 'function') {
139
+ Promise.resolve(result).catch((error) => report(logger, error, event));
140
+ }
141
+ } catch (error) {
142
+ report(logger, error, event);
143
+ }
144
+ };
145
+
146
+ const report = (logger: ScopedLogger, error: unknown, event: unknown) => {
147
+ logger.error('Unhandled error in event listener', error, event);
148
+ };
@@ -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,
@@ -266,8 +266,8 @@ describe('CallState', () => {
266
266
  "E",
267
267
  "F",
268
268
  "A",
269
- "C",
270
269
  "D",
270
+ "C",
271
271
  ]
272
272
  `);
273
273
 
@@ -288,8 +288,8 @@ describe('CallState', () => {
288
288
  "E",
289
289
  "F",
290
290
  "A",
291
- "C",
292
291
  "D",
292
+ "C",
293
293
  ]
294
294
  `);
295
295
  });