@stream-io/video-client 1.55.2 → 1.57.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 (34) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.browser.es.js +216 -119
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +216 -119
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +216 -119
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/helpers/BlockedAudioTracker.d.ts +16 -1
  9. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +4 -0
  10. package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
  11. package/dist/src/rtc/Publisher.d.ts +1 -0
  12. package/dist/src/rtc/types.d.ts +9 -2
  13. package/dist/src/store/rxUtils.d.ts +5 -0
  14. package/package.json +11 -11
  15. package/src/Call.ts +1 -0
  16. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -2
  17. package/src/devices/devicePersistence.ts +1 -2
  18. package/src/helpers/BlockedAudioTracker.ts +71 -13
  19. package/src/helpers/DynascaleManager.ts +14 -3
  20. package/src/helpers/MediaPlaybackWatchdog.ts +35 -16
  21. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +54 -6
  22. package/src/helpers/__tests__/DynascaleManager.test.ts +45 -0
  23. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +137 -8
  24. package/src/helpers/__tests__/ViewportTracker.test.ts +1 -2
  25. package/src/reporting/ClientEventReporter.ts +39 -39
  26. package/src/reporting/__tests__/ClientEventReporter.test.ts +159 -44
  27. package/src/rtc/BasePeerConnection.ts +1 -0
  28. package/src/rtc/Publisher.ts +78 -58
  29. package/src/rtc/__tests__/Publisher.test.ts +297 -0
  30. package/src/rtc/types.ts +14 -13
  31. package/src/stats/rtc/types.ts +8 -2
  32. package/src/store/CallState.ts +6 -19
  33. package/src/store/rxUtils.ts +15 -0
  34. package/src/types.ts +1 -4
@@ -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.
@@ -18,12 +18,16 @@ export declare class MediaPlaybackWatchdog {
18
18
  private tracer;
19
19
  private controller;
20
20
  private pendingTimer;
21
+ private settleTimer;
21
22
  private attempt;
22
23
  private disposed;
24
+ private stopped;
23
25
  constructor(opts: MediaPlaybackWatchdogOptions);
24
26
  private attach;
25
27
  dispose: () => void;
28
+ private clearTimers;
26
29
  private onPlaying;
30
+ private onSettled;
27
31
  private onPauseOrSuspend;
28
32
  private scheduleRecovery;
29
33
  private computeSkipReason;
@@ -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.57.0",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
@@ -36,31 +36,31 @@
36
36
  "@protobuf-ts/twirp-transport": "^2.11.1",
37
37
  "@stream-io/logger": "^2.0.0",
38
38
  "@stream-io/worker-timer": "^1.2.5",
39
- "axios": "^1.16.1",
39
+ "axios": "^1.19.0",
40
40
  "rxjs": "~7.8.2",
41
41
  "sdp-transform": "^2.15.0",
42
42
  "ua-parser-js": "^1.0.41",
43
43
  "webrtc-adapter": "^8.2.4"
44
44
  },
45
45
  "devDependencies": {
46
- "@openapitools/openapi-generator-cli": "^2.34.0",
46
+ "@openapitools/openapi-generator-cli": "^2.40.1",
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",
50
- "@stream-io/node-sdk": "^0.7.59",
49
+ "@stream-io/audio-filters-web": "^0.10.4",
50
+ "@stream-io/node-sdk": "^0.7.63",
51
51
  "@stream-io/typescript-config": "^0.0.0",
52
52
  "@total-typescript/shoehorn": "^0.1.2",
53
- "@types/node": "^25.9.3",
53
+ "@types/node": "^25.9.5",
54
54
  "@types/sdp-transform": "^2.15.0",
55
55
  "@types/ua-parser-js": "^0.7.39",
56
- "@vitest/coverage-v8": "^4.1.7",
56
+ "@vitest/coverage-v8": "^4.1.10",
57
57
  "dotenv": "^16.6.1",
58
- "happy-dom": "^20.9.0",
59
- "prettier": "^3.8.3",
58
+ "happy-dom": "^20.11.1",
59
+ "prettier": "^3.9.6",
60
60
  "rimraf": "^6.1.3",
61
- "rollup": "^4.60.4",
61
+ "rollup": "^4.62.4",
62
62
  "typescript": "^6.0.3",
63
- "vitest": "^4.1.7",
63
+ "vitest": "^4.1.10",
64
64
  "vitest-mock-extended": "^4.0.0"
65
65
  }
66
66
  }
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(
@@ -16,8 +16,7 @@ import { settled, withoutConcurrency } from '../../helpers/concurrency';
16
16
  import { ClientEventReporter } from '../../reporting';
17
17
 
18
18
  let speechActivityCallback:
19
- | ((state: { isSoundDetected: boolean }) => void)
20
- | null = null;
19
+ ((state: { isSoundDetected: boolean }) => void) | null = null;
21
20
  let unsubscribeMocks: ReturnType<typeof vi.fn>[] = [];
22
21
 
23
22
  vi.mock('../../helpers/platforms.ts', () => {
@@ -24,8 +24,7 @@ export type LocalDevicePreference = {
24
24
 
25
25
  export type LocalDevicePreferences = {
26
26
  [type in DevicePreferenceKey]?:
27
- | LocalDevicePreference
28
- | LocalDevicePreference[];
27
+ LocalDevicePreference | LocalDevicePreference[];
29
28
  };
30
29
 
31
30
  export const defaultDeviceId = 'default';
@@ -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();
@@ -23,8 +23,10 @@ export class MediaPlaybackWatchdog {
23
23
  private tracer: Tracer;
24
24
  private controller = new AbortController();
25
25
  private pendingTimer: ReturnType<typeof setTimeout> | undefined;
26
+ private settleTimer: ReturnType<typeof setTimeout> | undefined;
26
27
  private attempt = 0;
27
28
  private disposed = false;
29
+ private stopped = false;
28
30
 
29
31
  constructor(opts: MediaPlaybackWatchdogOptions) {
30
32
  this.element = opts.element;
@@ -46,33 +48,47 @@ export class MediaPlaybackWatchdog {
46
48
  if (this.disposed) return;
47
49
  this.disposed = true;
48
50
  this.controller.abort();
51
+ this.clearTimers();
52
+ };
53
+
54
+ private clearTimers = () => {
49
55
  if (this.pendingTimer) clearTimeout(this.pendingTimer);
50
56
  this.pendingTimer = undefined;
57
+ if (this.settleTimer) clearTimeout(this.settleTimer);
58
+ this.settleTimer = undefined;
51
59
  };
52
60
 
53
61
  private onPlaying = () => {
54
- if (this.attempt > 0) {
55
- this.tracer.trace('mediaPlayback.recover.success', {
56
- kind: this.kind,
57
- attempts: this.attempt,
58
- });
59
- }
60
- this.attempt = 0;
61
62
  if (this.pendingTimer) clearTimeout(this.pendingTimer);
62
63
  this.pendingTimer = undefined;
64
+ if (this.attempt === 0 || this.settleTimer) return;
65
+ this.settleTimer ??= setTimeout(this.onSettled, 1000);
66
+ };
67
+
68
+ private onSettled = () => {
69
+ this.settleTimer = undefined;
70
+ if (this.element.paused) return;
71
+ this.tracer.trace('mediaPlayback.recover.success', {
72
+ kind: this.kind,
73
+ attempts: this.attempt,
74
+ });
75
+ this.attempt = 0;
76
+ this.stopped = false;
63
77
  };
64
78
 
65
79
  private onPauseOrSuspend = (event: Event) => {
66
- if (this.disposed) return;
80
+ if (this.disposed || this.stopped) return;
67
81
  this.tracer.trace('mediaPlayback.paused', {
68
82
  kind: this.kind,
69
83
  reason: event.type,
70
84
  });
85
+ if (this.settleTimer) clearTimeout(this.settleTimer);
86
+ this.settleTimer = undefined;
71
87
  this.scheduleRecovery();
72
88
  };
73
89
 
74
90
  private scheduleRecovery = () => {
75
- if (this.disposed || this.pendingTimer) return;
91
+ if (this.disposed || this.stopped || this.pendingTimer) return;
76
92
  const skipReason = this.computeSkipReason();
77
93
  if (skipReason) {
78
94
  this.tracer.trace('mediaPlayback.recover.skipped', {
@@ -81,6 +97,16 @@ export class MediaPlaybackWatchdog {
81
97
  });
82
98
  return;
83
99
  }
100
+
101
+ if (this.attempt >= 10) {
102
+ this.stopped = true;
103
+ this.clearTimers();
104
+ this.tracer.trace('mediaPlayback.recover.giveUp', {
105
+ kind: this.kind,
106
+ attempts: this.attempt,
107
+ });
108
+ return;
109
+ }
84
110
  const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
85
111
  this.pendingTimer = setTimeout(this.attemptPlay, delay);
86
112
  };
@@ -109,13 +135,6 @@ export class MediaPlaybackWatchdog {
109
135
  } catch (err) {
110
136
  if (this.disposed) return;
111
137
  this.logger.warn(`Failed to recover ${this.kind} playback`, err);
112
- if (this.attempt >= 10) {
113
- this.tracer.trace('mediaPlayback.recover.giveUp', {
114
- kind: this.kind,
115
- attempts: this.attempt,
116
- });
117
- return;
118
- }
119
138
  this.scheduleRecovery();
120
139
  }
121
140
  };
@@ -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