@stream-io/video-client 1.55.2 → 1.56.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.
@@ -12,11 +12,26 @@ export declare class BlockedAudioTracker {
12
12
  * Use {@link resumeAudio} within a user gesture to unblock.
13
13
  */
14
14
  autoplayBlocked$: import("rxjs").Observable<boolean>;
15
+ /**
16
+ * The list of participant `sessionId`s whose audio element is currently
17
+ * blocked by the browser's autoplay policy. Only some participants may be
18
+ * blocked (e.g. one joined while a gesture was active and a later one was
19
+ * not), so use this to render a per-participant affordance rather than a
20
+ * call-wide one. Call {@link resumeAudio} within a user gesture to unblock
21
+ * all of them.
22
+ *
23
+ * Session ids are registered together with the audio element.
24
+ */
25
+ blockedSessionIds$: import("rxjs").Observable<string[]>;
15
26
  constructor(tracer: Tracer);
16
27
  /**
17
28
  * Registers an audio element as blocked by the browser's autoplay policy.
18
29
  */
19
- markBlocked: (audioElement: HTMLAudioElement, blocked: boolean) => void;
30
+ markBlocked: (audioElement: HTMLAudioElement, blocked: boolean, sessionId?: string) => void;
31
+ /**
32
+ * Clears all tracked elements. The tracker stays usable afterwards.
33
+ */
34
+ reset: () => void;
20
35
  /**
21
36
  * Returns whether the given audio element is currently flagged as blocked
22
37
  * by the browser's autoplay policy.
@@ -4,6 +4,7 @@ import type { PeerConnectionStateChangeEvent } from '../rtc';
4
4
  export type ClientEventPeerConnection = 'publish' | 'subscribe';
5
5
  export type ClientEventStage = 'JoinInitiated' | 'CoordinatorWS' | 'MediaDevicePermission' | 'CoordinatorJoin' | 'WSJoin' | 'PeerConnectionConnect' | 'FirstVideoFrame' | 'FirstAudioFrame';
6
6
  export type MediaPermissionState = 'INITIATED' | 'FAILED' | 'GRANTED' | 'NOT_INITIATED';
7
+ export type ReportedIceState = 'CONNECTED' | 'FAILED' | 'NOT_CONNECTED';
7
8
  export type JoinReason = 'first-attempt' | 'network-available' | 'migration' | 'full-rejoin';
8
9
  export type ClientEventStandardCode = 'CLIENT_ABORTED' | 'BACKEND_LEAVE' | 'REQUEST_TIMEOUT' | 'NETWORK_ERROR' | 'SFU_ERROR' | 'SFU_GO_AWAY' | 'ICE_CONNECTIVITY_FAILED' | 'DTLS_CONNECTIVITY_FAILED';
9
10
  export type CallReportContext = {
@@ -11,6 +11,7 @@ export declare class Publisher extends BasePeerConnection {
11
11
  private readonly clonedTracks;
12
12
  private publishOptions;
13
13
  private readonly selfSubEnabled;
14
+ private readonly transceiverLockKey;
14
15
  /**
15
16
  * Constructs a new `Publisher` instance.
16
17
  */
@@ -43,16 +43,23 @@ export type OnIceConnected = (peerType: PeerType) => void;
43
43
  * Snapshot of the peer connection's ICE and DTLS state surfaced to telemetry
44
44
  * consumers (e.g. `ClientEventReporter`). Fired on every transition of
45
45
  * either `iceConnectionState` or `peerConnectionState`.
46
+ *
47
+ * `iceConnectionState` is the live `RTCPeerConnection.iceConnectionState` read
48
+ * at the moment the event fires, regardless of which transition triggered it.
49
+ * Consumers must use it (rather than assuming a value from `stateType`) because
50
+ * the browser does not guarantee the relative ordering of the `ice` and
51
+ * `peerConnection` `failed` transitions.
46
52
  */
47
53
  export type PeerConnectionStateChangeEvent = {
48
54
  peerType: PeerType;
55
+ iceConnectionState: RTCIceConnectionState;
56
+ } & ({
49
57
  stateType: 'ice';
50
58
  state: RTCIceConnectionState;
51
59
  } | {
52
- peerType: PeerType;
53
60
  stateType: 'peerConnection';
54
61
  state: RTCPeerConnectionState;
55
- };
62
+ });
56
63
  export type OnPeerConnectionStateChange = (event: PeerConnectionStateChangeEvent) => void;
57
64
  /**
58
65
  * Fired when a remote track starts receiving media (`unmute`). Used by
@@ -5,6 +5,11 @@ type AsyncFunctionPatch<T> = (currentValue: T) => Promise<T>;
5
5
  * A value or a function which takes the current value and returns a new value.
6
6
  */
7
7
  export type Patch<T> = T | FunctionPatch<T>;
8
+ /**
9
+ * Performs shallow comparison of two arrays.
10
+ * Expects primitive values: [1, 2, 3] is equal to [2, 1, 3].
11
+ */
12
+ export declare const isShallowArrayEqual: <T>(a: Array<T>, b: Array<T>) => boolean;
8
13
  /**
9
14
  * Gets the current value of an observable, or undefined if the observable has
10
15
  * not emitted a value yet.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.55.2",
3
+ "version": "1.56.0",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
@@ -46,7 +46,7 @@
46
46
  "@openapitools/openapi-generator-cli": "^2.34.0",
47
47
  "@rollup/plugin-replace": "^6.0.3",
48
48
  "@rollup/plugin-typescript": "^12.3.0",
49
- "@stream-io/audio-filters-web": "^0.10.1",
49
+ "@stream-io/audio-filters-web": "^0.10.2",
50
50
  "@stream-io/node-sdk": "^0.7.59",
51
51
  "@stream-io/typescript-config": "^0.0.0",
52
52
  "@total-typescript/shoehorn": "^0.1.2",
package/src/Call.ts CHANGED
@@ -404,6 +404,7 @@ export class Call {
404
404
  this.tracer,
405
405
  );
406
406
  this.blockedAudioTracker = new BlockedAudioTracker(this.tracer);
407
+ this.leaveCallHooks.add(this.blockedAudioTracker.reset);
407
408
 
408
409
  if (typeof document !== 'undefined') {
409
410
  this.audioBindingsWatchdog = new AudioBindingsWatchdog(
@@ -1,9 +1,18 @@
1
- import { BehaviorSubject, distinctUntilChanged, map } from 'rxjs';
1
+ import { BehaviorSubject, distinctUntilChanged, map, shareReplay } from 'rxjs';
2
2
  import { videoLoggerSystem } from '../logger';
3
3
  import { Tracer } from '../stats';
4
- import { setCurrentValue, setCurrentValueAsync } from '../store/rxUtils';
4
+ import {
5
+ isShallowArrayEqual,
6
+ setCurrentValue,
7
+ setCurrentValueAsync,
8
+ } from '../store/rxUtils';
5
9
  import { timeboxed } from '../coordinator/connection/utils';
6
10
 
11
+ type BlockedAudioElement = {
12
+ element: HTMLAudioElement;
13
+ sessionId?: string;
14
+ };
15
+
7
16
  /**
8
17
  * Tracks audio elements that the browser's autoplay policy has blocked.
9
18
  */
@@ -11,8 +20,8 @@ export class BlockedAudioTracker {
11
20
  private logger = videoLoggerSystem.getLogger('BlockedAudioTracker');
12
21
  private tracer: Tracer;
13
22
 
14
- private blockedElementsSubject = new BehaviorSubject(
15
- new Set<HTMLAudioElement>(),
23
+ private blockedElementsSubject = new BehaviorSubject<BlockedAudioElement[]>(
24
+ [],
16
25
  );
17
26
 
18
27
  /**
@@ -21,10 +30,32 @@ export class BlockedAudioTracker {
21
30
  * Use {@link resumeAudio} within a user gesture to unblock.
22
31
  */
23
32
  autoplayBlocked$ = this.blockedElementsSubject.pipe(
24
- map((elements) => elements.size > 0),
33
+ map((elements) => elements.length > 0),
25
34
  distinctUntilChanged(),
26
35
  );
27
36
 
37
+ /**
38
+ * The list of participant `sessionId`s whose audio element is currently
39
+ * blocked by the browser's autoplay policy. Only some participants may be
40
+ * blocked (e.g. one joined while a gesture was active and a later one was
41
+ * not), so use this to render a per-participant affordance rather than a
42
+ * call-wide one. Call {@link resumeAudio} within a user gesture to unblock
43
+ * all of them.
44
+ *
45
+ * Session ids are registered together with the audio element.
46
+ */
47
+ blockedSessionIds$ = this.blockedElementsSubject.pipe(
48
+ map((elements) => {
49
+ const ids: string[] = [];
50
+ elements.forEach(({ sessionId }) => {
51
+ if (sessionId && !ids.includes(sessionId)) ids.push(sessionId);
52
+ });
53
+ return ids;
54
+ }),
55
+ distinctUntilChanged(isShallowArrayEqual),
56
+ shareReplay({ bufferSize: 1, refCount: true }),
57
+ );
58
+
28
59
  constructor(tracer: Tracer) {
29
60
  this.tracer = tracer;
30
61
  }
@@ -32,20 +63,46 @@ export class BlockedAudioTracker {
32
63
  /**
33
64
  * Registers an audio element as blocked by the browser's autoplay policy.
34
65
  */
35
- markBlocked = (audioElement: HTMLAudioElement, blocked: boolean) => {
66
+ markBlocked = (
67
+ audioElement: HTMLAudioElement,
68
+ blocked: boolean,
69
+ sessionId?: string,
70
+ ) => {
36
71
  setCurrentValue(this.blockedElementsSubject, (elements) => {
37
- if (blocked) elements.add(audioElement);
38
- else elements.delete(audioElement);
39
- return elements;
72
+ if (!blocked) {
73
+ return elements.filter(({ element }) => element !== audioElement);
74
+ }
75
+
76
+ const existing = elements.find(({ element }) => element === audioElement);
77
+ if (existing) {
78
+ return elements.map((entry) =>
79
+ entry.element === audioElement
80
+ ? { element: audioElement, sessionId: sessionId ?? entry.sessionId }
81
+ : entry,
82
+ );
83
+ }
84
+
85
+ return [...elements, { element: audioElement, sessionId }];
40
86
  });
41
87
  };
42
88
 
89
+ /**
90
+ * Clears all tracked elements. The tracker stays usable afterwards.
91
+ */
92
+ reset = () => {
93
+ if (this.blockedElementsSubject.getValue().length > 0) {
94
+ this.blockedElementsSubject.next([]);
95
+ }
96
+ };
97
+
43
98
  /**
44
99
  * Returns whether the given audio element is currently flagged as blocked
45
100
  * by the browser's autoplay policy.
46
101
  */
47
102
  isBlocked = (audioElement: HTMLAudioElement): boolean => {
48
- return this.blockedElementsSubject.getValue().has(audioElement);
103
+ return this.blockedElementsSubject
104
+ .getValue()
105
+ .some(({ element }) => element === audioElement);
49
106
  };
50
107
 
51
108
  /**
@@ -57,17 +114,18 @@ export class BlockedAudioTracker {
57
114
  await setCurrentValueAsync(
58
115
  this.blockedElementsSubject,
59
116
  async (elements) => {
117
+ let next = elements;
60
118
  await Promise.all(
61
- Array.from(elements, async (element) => {
119
+ elements.map(async ({ element }) => {
62
120
  try {
63
121
  if (element.srcObject) await timeboxed([element.play()], 2000);
64
- elements.delete(element);
122
+ next = next.filter((entry) => entry.element !== element);
65
123
  } catch (err) {
66
124
  this.logger.warn(`Can't resume audio for element`, element, err);
67
125
  }
68
126
  }),
69
127
  );
70
- return elements;
128
+ return next;
71
129
  },
72
130
  );
73
131
  };
@@ -328,6 +328,12 @@ export class DynascaleManager {
328
328
  let gainNode: GainNode | undefined = undefined;
329
329
  let audioWatchdog: MediaPlaybackWatchdog | undefined = undefined;
330
330
 
331
+ const clearBlockedAudio = () => {
332
+ if (!this.blockedAudioTracker.isBlocked(audioElement)) return;
333
+ this.blockedAudioTracker.markBlocked(audioElement, false);
334
+ };
335
+ audioElement.addEventListener('playing', clearBlockedAudio);
336
+
331
337
  const isAudioTrack = trackType === 'audioTrack';
332
338
  const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
333
339
  const updateMediaStreamSubscription = participant$
@@ -341,7 +347,7 @@ export class DynascaleManager {
341
347
  audioWatchdog?.dispose();
342
348
  audioWatchdog = undefined;
343
349
  if (!source) {
344
- this.blockedAudioTracker.markBlocked(audioElement, false);
350
+ clearBlockedAudio();
345
351
  return;
346
352
  }
347
353
 
@@ -369,7 +375,11 @@ export class DynascaleManager {
369
375
  this.tracer.trace('audioPlaybackError', e.message);
370
376
  if (e.name === 'NotAllowedError') {
371
377
  this.tracer.trace('audioPlaybackBlocked', null);
372
- this.blockedAudioTracker.markBlocked(audioElement, true);
378
+ this.blockedAudioTracker.markBlocked(
379
+ audioElement,
380
+ true,
381
+ sessionId,
382
+ );
373
383
  }
374
384
  this.logger.warn(`Failed to play audio stream`, e);
375
385
  });
@@ -405,7 +415,8 @@ export class DynascaleManager {
405
415
  audioElement.autoplay = true;
406
416
 
407
417
  return () => {
408
- this.blockedAudioTracker.markBlocked(audioElement, false);
418
+ audioElement.removeEventListener('playing', clearBlockedAudio);
419
+ clearBlockedAudio();
409
420
  sinkIdSubscription?.unsubscribe();
410
421
  volumeSubscription.unsubscribe();
411
422
  updateMediaStreamSubscription.unsubscribe();
@@ -2,7 +2,7 @@
2
2
  * @vitest-environment happy-dom
3
3
  */
4
4
 
5
- import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
6
  import { BlockedAudioTracker } from '../BlockedAudioTracker';
7
7
  import { getCurrentValue } from '../../store/rxUtils';
8
8
  import type { Tracer } from '../../stats';
@@ -30,6 +30,10 @@ describe('BlockedAudioTracker', () => {
30
30
  tracker = new BlockedAudioTracker(tracer);
31
31
  });
32
32
 
33
+ afterEach(() => {
34
+ tracker.reset();
35
+ });
36
+
33
37
  it('emits autoplayBlocked$ true after markBlocked(el, true)', () => {
34
38
  expect(getCurrentValue(tracker.autoplayBlocked$)).toBe(false);
35
39
  tracker.markBlocked(createAudioElement(), true);
@@ -58,14 +62,58 @@ describe('BlockedAudioTracker', () => {
58
62
  expect(getCurrentValue(tracker.autoplayBlocked$)).toBe(false);
59
63
  });
60
64
 
65
+ it('emits blocked participant session ids', () => {
66
+ const el1 = createAudioElement();
67
+ const el2 = createAudioElement();
68
+
69
+ tracker.markBlocked(el1, true, 'session-id-1');
70
+ tracker.markBlocked(el2, true, 'session-id-2');
71
+
72
+ expect(getCurrentValue(tracker.blockedSessionIds$)).toEqual([
73
+ 'session-id-1',
74
+ 'session-id-2',
75
+ ]);
76
+
77
+ tracker.markBlocked(el1, false);
78
+
79
+ expect(getCurrentValue(tracker.blockedSessionIds$)).toEqual([
80
+ 'session-id-2',
81
+ ]);
82
+ });
83
+
84
+ it(`doesn't emit when blocked participant session ids don't change`, () => {
85
+ const el1 = createAudioElement();
86
+ const el2 = createAudioElement();
87
+ tracker.markBlocked(el1, true, 'session-id-1');
88
+
89
+ const subscriber = vi.fn();
90
+ const subscription = tracker.blockedSessionIds$.subscribe(subscriber);
91
+
92
+ expect(subscriber).toHaveBeenCalledTimes(1);
93
+
94
+ tracker.markBlocked(el1, true, 'session-id-1');
95
+ expect(subscriber).toHaveBeenCalledTimes(1);
96
+
97
+ tracker.markBlocked(el2, true, 'session-id-1');
98
+ expect(subscriber).toHaveBeenCalledTimes(1);
99
+
100
+ tracker.markBlocked(el1, false);
101
+ expect(subscriber).toHaveBeenCalledTimes(1);
102
+
103
+ tracker.markBlocked(el2, false);
104
+ expect(subscriber).toHaveBeenCalledTimes(2);
105
+
106
+ subscription.unsubscribe();
107
+ });
108
+
61
109
  it('resumeAudio plays each element with srcObject and clears successful ones', async () => {
62
110
  const el1 = createAudioElement();
63
111
  const el2 = createAudioElement();
64
112
  const play1 = vi.spyOn(el1, 'play').mockResolvedValue();
65
113
  const play2 = vi.spyOn(el2, 'play').mockResolvedValue();
66
114
 
67
- tracker.markBlocked(el1, true);
68
- tracker.markBlocked(el2, true);
115
+ tracker.markBlocked(el1, true, 'session-id-1');
116
+ tracker.markBlocked(el2, true, 'session-id-2');
69
117
 
70
118
  await tracker.resumeAudio();
71
119
 
@@ -82,8 +130,8 @@ describe('BlockedAudioTracker', () => {
82
130
  new DOMException('', 'NotAllowedError'),
83
131
  );
84
132
 
85
- tracker.markBlocked(ok, true);
86
- tracker.markBlocked(stillBlocked, true);
133
+ tracker.markBlocked(ok, true, 'session-id-1');
134
+ tracker.markBlocked(stillBlocked, true, 'session-id-2');
87
135
 
88
136
  await tracker.resumeAudio();
89
137
 
@@ -100,7 +148,7 @@ describe('BlockedAudioTracker', () => {
100
148
  const detached = createAudioElement(false);
101
149
  const play = vi.spyOn(detached, 'play').mockResolvedValue();
102
150
 
103
- tracker.markBlocked(detached, true);
151
+ tracker.markBlocked(detached, true, 'session-id');
104
152
  await tracker.resumeAudio();
105
153
 
106
154
  expect(play).not.toHaveBeenCalled();
@@ -143,6 +143,51 @@ describe('DynascaleManager', () => {
143
143
  cleanup?.();
144
144
  });
145
145
 
146
+ it('audio: tracks the blocked session and clears it once the element plays', async () => {
147
+ vi.useFakeTimers();
148
+ let cleanup: ReturnType<typeof dynascaleManager.bindAudioElement>;
149
+ try {
150
+ const audioElement = document.createElement('audio');
151
+ Object.defineProperty(audioElement, 'srcObject', { writable: true });
152
+ vi.spyOn(audioElement, 'play').mockRejectedValue(
153
+ new DOMException('', 'NotAllowedError'),
154
+ );
155
+
156
+ // @ts-expect-error incomplete data
157
+ call.state.updateOrAddParticipant('session-id', {
158
+ userId: 'user-id',
159
+ sessionId: 'session-id',
160
+ publishedTracks: [],
161
+ });
162
+
163
+ cleanup = dynascaleManager.bindAudioElement(
164
+ audioElement,
165
+ 'session-id',
166
+ 'audioTrack',
167
+ );
168
+
169
+ call.state.updateParticipant('session-id', {
170
+ audioStream: new MediaStream(),
171
+ });
172
+ await vi.advanceTimersByTimeAsync(0); // flush the rejected play()
173
+
174
+ expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(true);
175
+ expect(
176
+ getCurrentValue(call.blockedAudioTracker.blockedSessionIds$),
177
+ ).toEqual(['session-id']);
178
+
179
+ audioElement.dispatchEvent(new Event('playing'));
180
+
181
+ expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(false);
182
+ expect(
183
+ getCurrentValue(call.blockedAudioTracker.blockedSessionIds$),
184
+ ).toEqual([]);
185
+ } finally {
186
+ cleanup?.();
187
+ vi.useRealTimers();
188
+ }
189
+ });
190
+
146
191
  it('audio: Safari should use AudioContext for audio playback', () => {
147
192
  globalThis._isSafari = true;
148
193
  dynascaleManager.setUseWebAudio(true); // enabled by default on Safari
@@ -38,6 +38,8 @@ export type MediaPermissionState =
38
38
  | 'GRANTED'
39
39
  | 'NOT_INITIATED';
40
40
 
41
+ export type ReportedIceState = 'CONNECTED' | 'FAILED' | 'NOT_CONNECTED';
42
+
41
43
  export type JoinReason =
42
44
  | 'first-attempt'
43
45
  | 'network-available'
@@ -86,6 +88,7 @@ type PeerConnectionPairState = StagePairState & {
86
88
  sfuId: string;
87
89
  userSessionId: string;
88
90
  wasPreviouslyConnected: boolean;
91
+ lastIceState?: RTCIceConnectionState;
89
92
  };
90
93
 
91
94
  const pcKey = (cid: string, role: ClientEventPeerConnection): string =>
@@ -356,20 +359,8 @@ export class ClientEventReporter {
356
359
  this.failCoordinator(cid);
357
360
  this.failWs(cid);
358
361
 
359
- this.emitPeerConnectionFailure(
360
- cid,
361
- 'publish',
362
- code,
363
- reason,
364
- 'NOT_CONNECTED',
365
- );
366
- this.emitPeerConnectionFailure(
367
- cid,
368
- 'subscribe',
369
- code,
370
- reason,
371
- 'NOT_CONNECTED',
372
- );
362
+ this.emitPeerConnectionFailure(cid, 'publish', code, reason);
363
+ this.emitPeerConnectionFailure(cid, 'subscribe', code, reason);
373
364
  } catch (err) {
374
365
  this.logger.warn('Failed to report abort', err);
375
366
  }
@@ -384,7 +375,6 @@ export class ClientEventReporter {
384
375
  role,
385
376
  'CLIENT_ABORTED',
386
377
  'superseded by a new join attempt',
387
- 'NOT_CONNECTED',
388
378
  );
389
379
  }
390
380
  };
@@ -573,34 +563,35 @@ export class ClientEventReporter {
573
563
  const role: ClientEventPeerConnection =
574
564
  event.peerType === PeerType.SUBSCRIBER ? 'subscribe' : 'publish';
575
565
 
576
- if (event.stateType === 'ice' && event.state === 'failed') {
577
- this.emitPeerConnectionFailure(
578
- cid,
579
- role,
580
- 'ICE_CONNECTIVITY_FAILED',
581
- 'ICE connectivity checks failed',
582
- 'FAILED',
583
- );
566
+ const pair = this.peerConnectionPairs.get(pcKey(cid, role));
567
+ if (pair) pair.lastIceState = event.iceConnectionState;
568
+
569
+ if (event.stateType === 'ice') {
570
+ if (event.state === 'failed') {
571
+ this.emitPeerConnectionFailure(
572
+ cid,
573
+ role,
574
+ 'ICE_CONNECTIVITY_FAILED',
575
+ 'ICE connectivity checks failed',
576
+ );
577
+ }
584
578
  return;
585
579
  }
586
580
 
587
- if (event.stateType === 'peerConnection' && event.state === 'failed') {
581
+ if (event.state === 'failed') {
588
582
  this.emitPeerConnectionFailure(
589
583
  cid,
590
584
  role,
591
585
  'DTLS_CONNECTIVITY_FAILED',
592
586
  'DTLS connectivity checks failed',
593
- 'CONNECTED',
594
587
  );
595
588
  return;
596
589
  }
597
590
 
598
- if (event.stateType !== 'peerConnection') return;
599
-
600
591
  switch (event.state) {
601
592
  case 'connecting':
602
- if (this.peerConnectionPairs.has(pcKey(cid, role))) return;
603
- this.openPeerConnectionPair(cid, role);
593
+ if (pair) return;
594
+ this.openPeerConnectionPair(cid, role, event.iceConnectionState);
604
595
  break;
605
596
  case 'connected':
606
597
  this.emitPeerConnectionSuccess(cid, role);
@@ -614,6 +605,7 @@ export class ClientEventReporter {
614
605
  private openPeerConnectionPair = (
615
606
  cid: string,
616
607
  role: ClientEventPeerConnection,
608
+ iceConnectionState: RTCIceConnectionState,
617
609
  ) => {
618
610
  const key = pcKey(cid, role);
619
611
  const pair: PeerConnectionPairState = {
@@ -624,6 +616,7 @@ export class ClientEventReporter {
624
616
  sfuId: this.getSfuId(cid),
625
617
  userSessionId: this.getUserSessionId(cid),
626
618
  wasPreviouslyConnected: this.pcEverConnected.get(key) === true,
619
+ lastIceState: iceConnectionState,
627
620
  };
628
621
  this.peerConnectionPairs.set(key, pair);
629
622
 
@@ -670,7 +663,6 @@ export class ClientEventReporter {
670
663
  role: ClientEventPeerConnection,
671
664
  code: ClientEventStandardCode,
672
665
  reason: string,
673
- iceState: 'CONNECTED' | 'FAILED' | 'NOT_CONNECTED',
674
666
  ) => {
675
667
  const key = pcKey(cid, role);
676
668
  const pair = this.peerConnectionPairs.get(key);
@@ -689,7 +681,7 @@ export class ClientEventReporter {
689
681
  outcome: 'failure',
690
682
  retry_count_attempt: 0,
691
683
  elapsed_time: Date.now() - pair.startedAt,
692
- ice_state: iceState,
684
+ ice_state: toReportedIceState(pair.lastIceState),
693
685
  retry_failure_reason: reason,
694
686
  retry_failure_code: code,
695
687
  });
@@ -782,6 +774,20 @@ export class ClientEventReporter {
782
774
  };
783
775
  }
784
776
 
777
+ const toReportedIceState = (
778
+ state: RTCIceConnectionState | undefined,
779
+ ): ReportedIceState => {
780
+ switch (state) {
781
+ case 'connected':
782
+ case 'completed':
783
+ return 'CONNECTED';
784
+ case 'failed':
785
+ return 'FAILED';
786
+ default:
787
+ return 'NOT_CONNECTED';
788
+ }
789
+ };
790
+
785
791
  const readPermissionStatus = (
786
792
  permission: BrowserPermission,
787
793
  ): MediaPermissionState => {