@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.
- package/CHANGELOG.md +7 -0
- package/dist/index.browser.es.js +52 -9
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +52 -8
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +52 -9
- package/dist/index.es.js.map +1 -1
- package/dist/src/coordinator/connection/utils.d.ts +10 -0
- package/dist/src/sorting/participants.d.ts +13 -0
- package/package.json +1 -1
- package/src/__tests__/StreamVideoClient.ringing.test.ts +32 -20
- package/src/coordinator/connection/__tests__/eventListenerIsolation.test.ts +115 -0
- package/src/coordinator/connection/client.ts +3 -2
- package/src/coordinator/connection/utils.ts +28 -0
- package/src/sorting/__tests__/presets.test.ts +188 -3
- package/src/sorting/__tests__/sorting.test.ts +22 -1
- package/src/sorting/participants.ts +20 -0
- package/src/sorting/presets.ts +4 -0
- package/src/store/__tests__/CallState.test.ts +2 -2
|
@@ -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
|
@@ -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
|
-
|
|
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:
|
|
90
|
+
created_by_id: oliverId,
|
|
79
91
|
members: [
|
|
80
|
-
{ user_id:
|
|
81
|
-
{ user_id:
|
|
82
|
-
{ user_id:
|
|
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:
|
|
116
|
-
{ user_id:
|
|
117
|
-
{ user_id:
|
|
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:
|
|
148
|
-
{ user_id:
|
|
149
|
-
{ user_id:
|
|
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: [
|
|
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: [
|
|
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:
|
|
245
|
-
members: [{ user_id:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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 {
|
|
5
|
-
import {
|
|
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 {
|
|
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
|
*
|
package/src/sorting/presets.ts
CHANGED
|
@@ -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
|
});
|