@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/index.browser.es.js +319 -71
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +329 -69
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.es.js +319 -71
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +15 -4
  10. package/dist/src/StreamVideoClient.d.ts +9 -1
  11. package/dist/src/devices/CameraManager.d.ts +2 -1
  12. package/dist/src/devices/DeviceManager.d.ts +1 -1
  13. package/dist/src/devices/MicrophoneManager.d.ts +2 -1
  14. package/dist/src/gen/coordinator/index.d.ts +26 -0
  15. package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
  16. package/dist/src/helpers/concurrency.d.ts +8 -0
  17. package/dist/src/helpers/loopback.d.ts +55 -0
  18. package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
  19. package/dist/src/rtc/Subscriber.d.ts +5 -0
  20. package/index.ts +2 -0
  21. package/package.json +1 -1
  22. package/src/Call.ts +156 -134
  23. package/src/StreamVideoClient.ts +35 -8
  24. package/src/__tests__/Call.lifecycle.test.ts +33 -2
  25. package/src/__tests__/Call.publishing.test.ts +42 -1
  26. package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
  27. package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
  28. package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
  29. package/src/__tests__/StreamVideoClient.test.ts +14 -12
  30. package/src/devices/CameraManager.ts +16 -4
  31. package/src/devices/DeviceManager.ts +7 -4
  32. package/src/devices/MicrophoneManager.ts +11 -3
  33. package/src/devices/__tests__/CameraManager.test.ts +27 -1
  34. package/src/devices/__tests__/DeviceManager.test.ts +49 -1
  35. package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
  36. package/src/events/call.ts +6 -3
  37. package/src/gen/coordinator/index.ts +26 -0
  38. package/src/helpers/MediaStreamRecorder.ts +104 -0
  39. package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
  40. package/src/helpers/__tests__/concurrency.test.ts +74 -0
  41. package/src/helpers/__tests__/loopback.test.ts +367 -0
  42. package/src/helpers/concurrency.ts +24 -0
  43. package/src/helpers/loopback.ts +200 -0
  44. package/src/reporting/ClientEventReporter.ts +15 -28
  45. package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
  46. package/src/rtc/Subscriber.ts +8 -0
  47. 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 getUserSessionId = (cid: string): string =>
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
- ): Record<string, unknown> => {
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: Record<string, unknown>) => {
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: Record<string, unknown>) => {
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
- call_cid: cid,
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.call_cid === cid,
722
+ (e) => e.stage === 'WSJoin' && e.id === 'call-1',
726
723
  );
727
724
  const ws2 = postedEvents().filter(
728
- (e) => e.stage === 'WSJoin' && e.call_cid === cid2,
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
 
@@ -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