@stream-io/video-client 1.58.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 +22 -0
- package/dist/index.browser.es.js +367 -76
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +378 -74
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.es.js +367 -76
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +15 -4
- package/dist/src/StreamVideoClient.d.ts +9 -1
- package/dist/src/coordinator/connection/utils.d.ts +10 -0
- package/dist/src/devices/CameraManager.d.ts +2 -1
- package/dist/src/devices/DeviceManager.d.ts +1 -1
- package/dist/src/devices/MicrophoneManager.d.ts +2 -1
- package/dist/src/gen/coordinator/index.d.ts +26 -0
- package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
- package/dist/src/helpers/concurrency.d.ts +8 -0
- package/dist/src/helpers/loopback.d.ts +55 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
- package/dist/src/rtc/Subscriber.d.ts +5 -0
- package/dist/src/sorting/participants.d.ts +13 -0
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/Call.ts +156 -134
- package/src/StreamVideoClient.ts +35 -8
- package/src/__tests__/Call.lifecycle.test.ts +33 -2
- package/src/__tests__/Call.publishing.test.ts +42 -1
- package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
- package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
- package/src/__tests__/StreamVideoClient.ringing.test.ts +43 -24
- package/src/__tests__/StreamVideoClient.test.ts +14 -12
- 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/devices/CameraManager.ts +16 -4
- package/src/devices/DeviceManager.ts +7 -4
- package/src/devices/MicrophoneManager.ts +11 -3
- package/src/devices/__tests__/CameraManager.test.ts +27 -1
- package/src/devices/__tests__/DeviceManager.test.ts +49 -1
- package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
- package/src/events/call.ts +6 -3
- package/src/gen/coordinator/index.ts +26 -0
- package/src/helpers/MediaStreamRecorder.ts +104 -0
- package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
- package/src/helpers/__tests__/concurrency.test.ts +74 -0
- package/src/helpers/__tests__/loopback.test.ts +367 -0
- package/src/helpers/concurrency.ts +24 -0
- package/src/helpers/loopback.ts +200 -0
- package/src/reporting/ClientEventReporter.ts +15 -28
- package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
- package/src/rtc/Subscriber.ts +8 -0
- package/src/rtc/__tests__/Subscriber.test.ts +101 -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
|
@@ -19,6 +19,11 @@ import type {
|
|
|
19
19
|
BrowserPermissionState,
|
|
20
20
|
} from '../devices/BrowserPermission';
|
|
21
21
|
import { getCurrentValue } from '../store/rxUtils';
|
|
22
|
+
import type {
|
|
23
|
+
ClientEvent,
|
|
24
|
+
ReportClientEventRequest,
|
|
25
|
+
ReportClientEventResponse,
|
|
26
|
+
} from '../gen/coordinator';
|
|
22
27
|
|
|
23
28
|
export type ClientEventPeerConnection = 'publish' | 'subscribe';
|
|
24
29
|
|
|
@@ -55,7 +60,6 @@ export type CallReportContext = {
|
|
|
55
60
|
callId: string;
|
|
56
61
|
getSfuId: () => string;
|
|
57
62
|
getCallSessionId: () => string;
|
|
58
|
-
getUserSessionId: () => string;
|
|
59
63
|
};
|
|
60
64
|
|
|
61
65
|
export type ClientEventReporterOptions = {
|
|
@@ -80,7 +84,6 @@ type StagePairState = {
|
|
|
80
84
|
|
|
81
85
|
type PeerConnectionPairState = StagePairState & {
|
|
82
86
|
sfuId: string;
|
|
83
|
-
userSessionId: string;
|
|
84
87
|
wasPreviouslyConnected: boolean;
|
|
85
88
|
lastIceState?: RTCIceConnectionState;
|
|
86
89
|
};
|
|
@@ -192,9 +195,7 @@ export class ClientEventReporter {
|
|
|
192
195
|
this.coordinatorWsPair = undefined;
|
|
193
196
|
};
|
|
194
197
|
|
|
195
|
-
private buildCoordinatorWsCommon = (
|
|
196
|
-
pair: StagePairState,
|
|
197
|
-
): Record<string, unknown> => ({
|
|
198
|
+
private buildCoordinatorWsCommon = (pair: StagePairState): ClientEvent => ({
|
|
198
199
|
user_id: pair.userIdSnapshot ?? this.streamClient.userID,
|
|
199
200
|
stage: 'CoordinatorWS',
|
|
200
201
|
stage_id: pair.sid,
|
|
@@ -383,7 +384,6 @@ export class ClientEventReporter {
|
|
|
383
384
|
user_id: this.streamClient.userID || this.coordinatorConnectUserId,
|
|
384
385
|
type: ctx?.callType,
|
|
385
386
|
id: ctx?.callId,
|
|
386
|
-
call_cid: cid,
|
|
387
387
|
stage: 'JoinInitiated',
|
|
388
388
|
join_attempt_id: joinAttemptId,
|
|
389
389
|
...(coordinatorConnectId && {
|
|
@@ -608,7 +608,6 @@ export class ClientEventReporter {
|
|
|
608
608
|
startedAt: Date.now(),
|
|
609
609
|
joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
|
|
610
610
|
sfuId: this.getSfuId(cid),
|
|
611
|
-
userSessionId: this.getUserSessionId(cid),
|
|
612
611
|
wasPreviouslyConnected: this.pcEverConnected.get(key) === true,
|
|
613
612
|
lastIceState: iceConnectionState,
|
|
614
613
|
};
|
|
@@ -620,9 +619,6 @@ export class ClientEventReporter {
|
|
|
620
619
|
peer_connection: role,
|
|
621
620
|
was_previously_connected: pair.wasPreviouslyConnected,
|
|
622
621
|
...(pair.sfuId && { sfu_id: pair.sfuId }),
|
|
623
|
-
...(pair.userSessionId && {
|
|
624
|
-
user_session_id: pair.userSessionId,
|
|
625
|
-
}),
|
|
626
622
|
event_type: 'initiated',
|
|
627
623
|
});
|
|
628
624
|
};
|
|
@@ -641,9 +637,6 @@ export class ClientEventReporter {
|
|
|
641
637
|
peer_connection: role,
|
|
642
638
|
was_previously_connected: pair.wasPreviouslyConnected,
|
|
643
639
|
...(pair.sfuId && { sfu_id: pair.sfuId }),
|
|
644
|
-
...(pair.userSessionId && {
|
|
645
|
-
user_session_id: pair.userSessionId,
|
|
646
|
-
}),
|
|
647
640
|
event_type: 'completed',
|
|
648
641
|
outcome: 'success',
|
|
649
642
|
retry_count_attempt: 0,
|
|
@@ -667,9 +660,6 @@ export class ClientEventReporter {
|
|
|
667
660
|
...this.sessionIdField(cid),
|
|
668
661
|
peer_connection: role,
|
|
669
662
|
was_previously_connected: pair.wasPreviouslyConnected,
|
|
670
|
-
...(pair.userSessionId && {
|
|
671
|
-
user_session_id: pair.userSessionId,
|
|
672
|
-
}),
|
|
673
663
|
...(pair.sfuId && { sfu_id: pair.sfuId }),
|
|
674
664
|
event_type: 'completed',
|
|
675
665
|
outcome: 'failure',
|
|
@@ -685,10 +675,7 @@ export class ClientEventReporter {
|
|
|
685
675
|
private getSfuId = (cid: string): string =>
|
|
686
676
|
this.callContexts.get(cid)?.getSfuId() ?? '';
|
|
687
677
|
|
|
688
|
-
private
|
|
689
|
-
this.callContexts.get(cid)?.getUserSessionId() ?? '';
|
|
690
|
-
|
|
691
|
-
private sessionIdField = (cid: string): Record<string, unknown> => {
|
|
678
|
+
private sessionIdField = (cid: string): { call_session_id?: string } => {
|
|
692
679
|
const callSessionId = this.callContexts.get(cid)?.getCallSessionId() ?? '';
|
|
693
680
|
return callSessionId ? { call_session_id: callSessionId } : {};
|
|
694
681
|
};
|
|
@@ -697,14 +684,13 @@ export class ClientEventReporter {
|
|
|
697
684
|
cid: string,
|
|
698
685
|
stage: ClientEventStage,
|
|
699
686
|
pair: StagePairState,
|
|
700
|
-
):
|
|
687
|
+
): ClientEvent => {
|
|
701
688
|
const ctx = this.callContexts.get(cid);
|
|
702
689
|
const coordinatorConnectId = this.coordinatorConnectId;
|
|
703
690
|
return {
|
|
704
691
|
user_id: this.streamClient.userID || this.coordinatorConnectUserId,
|
|
705
692
|
type: ctx?.callType ?? '',
|
|
706
693
|
id: ctx?.callId ?? '',
|
|
707
|
-
call_cid: cid,
|
|
708
694
|
stage,
|
|
709
695
|
stage_id: pair.sid,
|
|
710
696
|
...(pair.joinAttemptIdSnapshot && {
|
|
@@ -719,22 +705,23 @@ export class ClientEventReporter {
|
|
|
719
705
|
};
|
|
720
706
|
};
|
|
721
707
|
|
|
722
|
-
private send = (body:
|
|
708
|
+
private send = (body: ClientEvent) => {
|
|
723
709
|
if (!this.enabled) return;
|
|
724
710
|
void this.sendWithRetry(body);
|
|
725
711
|
};
|
|
726
712
|
|
|
727
|
-
private sendForCall = (cid: string, body:
|
|
713
|
+
private sendForCall = (cid: string, body: ClientEvent) => {
|
|
728
714
|
if (!this.callContexts.has(cid)) return;
|
|
729
715
|
this.send(body);
|
|
730
716
|
};
|
|
731
717
|
|
|
732
|
-
private sendWithRetry = async (
|
|
733
|
-
body: Record<string, unknown>,
|
|
734
|
-
): Promise<boolean> => {
|
|
718
|
+
private sendWithRetry = async (body: ClientEvent): Promise<boolean> => {
|
|
735
719
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
736
720
|
try {
|
|
737
|
-
await this.streamClient.doAxiosRequest
|
|
721
|
+
await this.streamClient.doAxiosRequest<
|
|
722
|
+
ReportClientEventResponse,
|
|
723
|
+
ReportClientEventRequest
|
|
724
|
+
>(
|
|
738
725
|
'post',
|
|
739
726
|
'/call_client_event',
|
|
740
727
|
{ events: [body] },
|
|
@@ -77,7 +77,6 @@ describe('ClientEventReporter', () => {
|
|
|
77
77
|
callId: 'call-1',
|
|
78
78
|
getCallSessionId: () => 'session-1',
|
|
79
79
|
getSfuId: () => 'sfu-1',
|
|
80
|
-
getUserSessionId: () => 'user-session-1',
|
|
81
80
|
};
|
|
82
81
|
reporter.registerCall(cid, ctx);
|
|
83
82
|
});
|
|
@@ -91,7 +90,7 @@ describe('ClientEventReporter', () => {
|
|
|
91
90
|
expect(events).toHaveLength(2);
|
|
92
91
|
expect(events[0]).toMatchObject({
|
|
93
92
|
event_type: 'initiated',
|
|
94
|
-
|
|
93
|
+
id: 'call-1',
|
|
95
94
|
user_id: 'user-1',
|
|
96
95
|
coordinator_connect_id: connectId,
|
|
97
96
|
join_reason: 'first-attempt',
|
|
@@ -523,7 +522,6 @@ describe('ClientEventReporter', () => {
|
|
|
523
522
|
peer_connection: 'publish',
|
|
524
523
|
was_previously_connected: false,
|
|
525
524
|
sfu_id: 'sfu-1',
|
|
526
|
-
user_session_id: 'user-session-1',
|
|
527
525
|
});
|
|
528
526
|
expect(events[1]).toMatchObject({
|
|
529
527
|
event_type: 'completed',
|
|
@@ -712,7 +710,6 @@ describe('ClientEventReporter', () => {
|
|
|
712
710
|
callId: 'call-2',
|
|
713
711
|
getCallSessionId: () => 'session-2',
|
|
714
712
|
getSfuId: () => 'sfu-2',
|
|
715
|
-
getUserSessionId: () => 'user-session-2',
|
|
716
713
|
});
|
|
717
714
|
|
|
718
715
|
reporter.startCorrelation(cid, 'first-attempt');
|
|
@@ -722,10 +719,10 @@ describe('ClientEventReporter', () => {
|
|
|
722
719
|
await flush();
|
|
723
720
|
|
|
724
721
|
const ws1 = postedEvents().filter(
|
|
725
|
-
(e) => e.stage === 'WSJoin' && e.
|
|
722
|
+
(e) => e.stage === 'WSJoin' && e.id === 'call-1',
|
|
726
723
|
);
|
|
727
724
|
const ws2 = postedEvents().filter(
|
|
728
|
-
(e) => e.stage === 'WSJoin' && e.
|
|
725
|
+
(e) => e.stage === 'WSJoin' && e.id === 'call-2',
|
|
729
726
|
);
|
|
730
727
|
expect(ws1).toHaveLength(2);
|
|
731
728
|
expect(ws2).toHaveLength(2);
|
|
@@ -806,7 +803,6 @@ describe('ClientEventReporter (disabled)', () => {
|
|
|
806
803
|
callId: 'call-1',
|
|
807
804
|
getCallSessionId: () => 'session-1',
|
|
808
805
|
getSfuId: () => 'sfu-1',
|
|
809
|
-
getUserSessionId: () => 'user-session-1',
|
|
810
806
|
});
|
|
811
807
|
});
|
|
812
808
|
|
package/src/rtc/Subscriber.ts
CHANGED
|
@@ -94,6 +94,13 @@ export class Subscriber extends BasePeerConnection {
|
|
|
94
94
|
}
|
|
95
95
|
};
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Whether the given stream is one the SFU echoed back to us
|
|
99
|
+
* (`allowOwnTracksLoopback`) rather than a local capture stream.
|
|
100
|
+
*/
|
|
101
|
+
isSelfSubscribedStream = (stream: MediaStream | undefined): boolean =>
|
|
102
|
+
!!stream && !!this.trackedStreams?.has(stream);
|
|
103
|
+
|
|
97
104
|
private handleOnTrack = (e: RTCTrackEvent) => {
|
|
98
105
|
const { streams, track } = e;
|
|
99
106
|
const [primaryStream] = streams;
|
|
@@ -212,6 +219,7 @@ export class Subscriber extends BasePeerConnection {
|
|
|
212
219
|
(p) => p.trackLookupPrefix === trackId,
|
|
213
220
|
);
|
|
214
221
|
if (!target) return;
|
|
222
|
+
if (target.isLocalParticipant) return;
|
|
215
223
|
this.state.updateParticipant(target.sessionId, (p) => {
|
|
216
224
|
const current = p.interruptedTracks ?? [];
|
|
217
225
|
const has = current.includes(trackType);
|
|
@@ -447,6 +447,65 @@ describe('Subscriber', () => {
|
|
|
447
447
|
});
|
|
448
448
|
|
|
449
449
|
describe('OnTrack', () => {
|
|
450
|
+
const audioStreamNamed = (trackLookupPrefix: string) => {
|
|
451
|
+
const stream = new MediaStream();
|
|
452
|
+
// @ts-expect-error - mock
|
|
453
|
+
stream.id = `${trackLookupPrefix}:TRACK_TYPE_AUDIO`;
|
|
454
|
+
return stream;
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const audioTrack = () => {
|
|
458
|
+
const track = new MediaStreamTrack();
|
|
459
|
+
// @ts-expect-error - mock
|
|
460
|
+
track.kind = 'audio';
|
|
461
|
+
return track;
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
it('replaces the local capture stream with the echoed one, leaving the capture tracks live', () => {
|
|
465
|
+
const captureTrack = audioTrack();
|
|
466
|
+
const captureStream = new MediaStream();
|
|
467
|
+
captureStream.getTracks = () => [captureTrack];
|
|
468
|
+
const echoedStream = audioStreamNamed('self-lookup');
|
|
469
|
+
// @ts-expect-error - incomplete mock
|
|
470
|
+
state.updateOrAddParticipant('self-session', {
|
|
471
|
+
sessionId: 'self-session',
|
|
472
|
+
trackLookupPrefix: 'self-lookup',
|
|
473
|
+
isLocalParticipant: true,
|
|
474
|
+
audioStream: captureStream,
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
478
|
+
// @ts-expect-error - incomplete mock
|
|
479
|
+
onTrack({ streams: [echoedStream], track: audioTrack() });
|
|
480
|
+
|
|
481
|
+
const participant = state.participants.find(
|
|
482
|
+
(p) => p.sessionId === 'self-session',
|
|
483
|
+
);
|
|
484
|
+
expect(participant?.audioStream).toBe(echoedStream);
|
|
485
|
+
// the device manager owns the capture stream and is still publishing it
|
|
486
|
+
expect(captureTrack.stop).not.toHaveBeenCalled();
|
|
487
|
+
expect(captureStream.removeTrack).not.toHaveBeenCalled();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('reports streams it received as self-subscribed, and capture streams as not', () => {
|
|
491
|
+
const echoedStream = audioStreamNamed('self-lookup');
|
|
492
|
+
const captureStream = new MediaStream();
|
|
493
|
+
// @ts-expect-error - incomplete mock
|
|
494
|
+
state.updateOrAddParticipant('self-session', {
|
|
495
|
+
sessionId: 'self-session',
|
|
496
|
+
trackLookupPrefix: 'self-lookup',
|
|
497
|
+
isLocalParticipant: true,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
501
|
+
// @ts-expect-error - incomplete mock
|
|
502
|
+
onTrack({ streams: [echoedStream], track: audioTrack() });
|
|
503
|
+
|
|
504
|
+
expect(subscriber.isSelfSubscribedStream(echoedStream)).toBe(true);
|
|
505
|
+
expect(subscriber.isSelfSubscribedStream(captureStream)).toBe(false);
|
|
506
|
+
expect(subscriber.isSelfSubscribedStream(undefined)).toBe(false);
|
|
507
|
+
});
|
|
508
|
+
|
|
450
509
|
it('should add unknown tracks to the to the call state', () => {
|
|
451
510
|
const mediaStream = new MediaStream();
|
|
452
511
|
const mediaStreamTrack = new MediaStreamTrack();
|
|
@@ -683,6 +742,48 @@ describe('Subscriber', () => {
|
|
|
683
742
|
expect(interruptedFor('session-id')).toEqual([]);
|
|
684
743
|
});
|
|
685
744
|
|
|
745
|
+
it('leaves the local participant alone on a self-sub track', () => {
|
|
746
|
+
const mediaStream = new MediaStream();
|
|
747
|
+
const track = new MediaStreamTrack();
|
|
748
|
+
// @ts-expect-error - mock
|
|
749
|
+
mediaStream.id = 'self-lookup:TRACK_TYPE_AUDIO';
|
|
750
|
+
// @ts-expect-error - mock
|
|
751
|
+
track.kind = 'audio';
|
|
752
|
+
Object.defineProperty(track, 'muted', {
|
|
753
|
+
configurable: true,
|
|
754
|
+
get: () => true,
|
|
755
|
+
});
|
|
756
|
+
// @ts-expect-error - incomplete mock
|
|
757
|
+
state.updateOrAddParticipant('self-session', {
|
|
758
|
+
sessionId: 'self-session',
|
|
759
|
+
trackLookupPrefix: 'self-lookup',
|
|
760
|
+
isLocalParticipant: true,
|
|
761
|
+
interruptedTracks: [],
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
765
|
+
// @ts-expect-error - incomplete mock
|
|
766
|
+
onTrack({ streams: [mediaStream], track });
|
|
767
|
+
|
|
768
|
+
expect(interruptedFor('self-session')).toEqual([]);
|
|
769
|
+
|
|
770
|
+
const handlers: Record<string, () => void> = {};
|
|
771
|
+
for (const [event, handler] of (
|
|
772
|
+
track.addEventListener as ReturnType<typeof vi.fn>
|
|
773
|
+
).mock.calls) {
|
|
774
|
+
handlers[event] = handler as () => void;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
handlers['mute']();
|
|
778
|
+
expect(interruptedFor('self-session')).toEqual([]);
|
|
779
|
+
|
|
780
|
+
state.updateParticipant('self-session', {
|
|
781
|
+
interruptedTracks: [TrackType.AUDIO],
|
|
782
|
+
});
|
|
783
|
+
handlers['unmute']();
|
|
784
|
+
expect(interruptedFor('self-session')).toEqual([TrackType.AUDIO]);
|
|
785
|
+
});
|
|
786
|
+
|
|
686
787
|
it('ignores non-audio remote tracks to avoid Dynascale false positives', () => {
|
|
687
788
|
// Remote video track.muted is dominated by viewport-driven
|
|
688
789
|
// SFU unsubscriptions, so we deliberately only track audio
|
|
@@ -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
|
});
|