@stream-io/video-client 1.58.0 → 1.59.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 +15 -0
- package/dist/index.browser.es.js +319 -71
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +329 -69
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.es.js +319 -71
- 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/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/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 +11 -4
- package/src/__tests__/StreamVideoClient.test.ts +14 -12
- 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
|
@@ -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
|