@stream-io/video-client 1.44.4 → 1.44.5

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.
@@ -0,0 +1,37 @@
1
+ import type { AudioTrackType } from '../types';
2
+ import { CallState } from '../store';
3
+ import { Tracer } from '../stats';
4
+ /**
5
+ * Tracks audio element bindings and periodically warns about
6
+ * remote participants whose audio streams have no bound element.
7
+ */
8
+ export declare class AudioBindingsWatchdog {
9
+ private state;
10
+ private tracer;
11
+ private bindings;
12
+ private enabled;
13
+ private watchdogInterval?;
14
+ private readonly unsubscribeCallingState;
15
+ private logger;
16
+ constructor(state: CallState, tracer: Tracer);
17
+ /**
18
+ * Registers an audio element binding for the given session and track type.
19
+ * Warns if a different element is already bound to the same key.
20
+ */
21
+ register: (audioElement: HTMLAudioElement, sessionId: string, trackType: AudioTrackType) => void;
22
+ /**
23
+ * Removes the audio element binding for the given session and track type.
24
+ */
25
+ unregister: (sessionId: string, trackType: AudioTrackType) => void;
26
+ /**
27
+ * Enables or disables the watchdog.
28
+ * When disabled, the periodic check stops but bindings are still tracked.
29
+ */
30
+ setEnabled: (enabled: boolean) => void;
31
+ /**
32
+ * Stops the watchdog and unsubscribes from callingState changes.
33
+ */
34
+ dispose: () => void;
35
+ private start;
36
+ private stop;
37
+ }
@@ -1,8 +1,9 @@
1
1
  import { AudioTrackType, DebounceType, VideoTrackType } from '../types';
2
2
  import { VideoDimension } from '../gen/video/sfu/models/models';
3
3
  import { ViewportTracker } from './ViewportTracker';
4
+ import { AudioBindingsWatchdog } from './AudioBindingsWatchdog';
4
5
  import type { TrackSubscriptionDetails } from '../gen/video/sfu/signal_rpc/signal';
5
- import type { CallState } from '../store';
6
+ import { CallState } from '../store';
6
7
  import type { StreamSfuClient } from '../StreamSfuClient';
7
8
  import { SpeakerManager } from '../devices';
8
9
  import { Tracer } from '../stats';
@@ -40,6 +41,7 @@ export declare class DynascaleManager {
40
41
  private audioContext;
41
42
  private sfuClient;
42
43
  private pendingSubscriptionsUpdate;
44
+ readonly audioBindingsWatchdog: AudioBindingsWatchdog | undefined;
43
45
  private videoTrackSubscriptionOverridesSubject;
44
46
  videoTrackSubscriptionOverrides$: import("rxjs").Observable<VideoTrackSubscriptionOverrides>;
45
47
  incomingVideoSettings$: import("rxjs").Observable<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.44.4",
3
+ "version": "1.44.5",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
@@ -11,7 +11,7 @@ import {
11
11
  } from 'rxjs';
12
12
  import { BrowserPermission } from './BrowserPermission';
13
13
  import { lazy } from '../helpers/lazy';
14
- import { isFirefox, isSafari } from '../helpers/browsers';
14
+ import { isFirefox } from '../helpers/browsers';
15
15
  import { dumpStream, Tracer } from '../stats';
16
16
  import { getCurrentValue } from '../store/rxUtils';
17
17
  import { videoLoggerSystem } from '../logger';
@@ -61,8 +61,6 @@ const getDevices = (
61
61
  */
62
62
  export const checkIfAudioOutputChangeSupported = () => {
63
63
  if (typeof document === 'undefined') return false;
64
- // Safari uses WebAudio API for playing audio, so we check the AudioContext prototype
65
- if (isSafari()) return 'setSinkId' in AudioContext.prototype;
66
64
  const element = document.createElement('audio');
67
65
  return 'setSinkId' in element;
68
66
  };
@@ -0,0 +1,118 @@
1
+ import type { AudioTrackType } from '../types';
2
+ import { CallingState, CallState } from '../store';
3
+ import { createSubscription } from '../store/rxUtils';
4
+ import { videoLoggerSystem } from '../logger';
5
+ import { Tracer } from '../stats';
6
+
7
+ const toBindingKey = (
8
+ sessionId: string,
9
+ trackType: AudioTrackType = 'audioTrack',
10
+ ) => `${sessionId}/${trackType}`;
11
+
12
+ /**
13
+ * Tracks audio element bindings and periodically warns about
14
+ * remote participants whose audio streams have no bound element.
15
+ */
16
+ export class AudioBindingsWatchdog {
17
+ private bindings = new Map<string, HTMLAudioElement>();
18
+ private enabled = true;
19
+ private watchdogInterval?: NodeJS.Timeout;
20
+ private readonly unsubscribeCallingState: () => void;
21
+ private logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
22
+
23
+ constructor(
24
+ private state: CallState,
25
+ private tracer: Tracer,
26
+ ) {
27
+ this.unsubscribeCallingState = createSubscription(
28
+ state.callingState$,
29
+ (callingState) => {
30
+ if (!this.enabled) return;
31
+ if (callingState !== CallingState.JOINED) {
32
+ this.stop();
33
+ } else {
34
+ this.start();
35
+ }
36
+ },
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Registers an audio element binding for the given session and track type.
42
+ * Warns if a different element is already bound to the same key.
43
+ */
44
+ register = (
45
+ audioElement: HTMLAudioElement,
46
+ sessionId: string,
47
+ trackType: AudioTrackType,
48
+ ) => {
49
+ const key = toBindingKey(sessionId, trackType);
50
+ const existing = this.bindings.get(key);
51
+ if (existing && existing !== audioElement) {
52
+ this.logger.warn(
53
+ `Audio element already bound to ${sessionId} and ${trackType}`,
54
+ );
55
+ this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
56
+ }
57
+ this.bindings.set(key, audioElement);
58
+ };
59
+
60
+ /**
61
+ * Removes the audio element binding for the given session and track type.
62
+ */
63
+ unregister = (sessionId: string, trackType: AudioTrackType) => {
64
+ this.bindings.delete(toBindingKey(sessionId, trackType));
65
+ };
66
+
67
+ /**
68
+ * Enables or disables the watchdog.
69
+ * When disabled, the periodic check stops but bindings are still tracked.
70
+ */
71
+ setEnabled = (enabled: boolean) => {
72
+ this.enabled = enabled;
73
+ if (enabled) {
74
+ this.start();
75
+ } else {
76
+ this.stop();
77
+ }
78
+ };
79
+
80
+ /**
81
+ * Stops the watchdog and unsubscribes from callingState changes.
82
+ */
83
+ dispose = () => {
84
+ this.stop();
85
+ this.unsubscribeCallingState();
86
+ };
87
+
88
+ private start = () => {
89
+ clearInterval(this.watchdogInterval);
90
+ this.watchdogInterval = setInterval(() => {
91
+ const danglingUserIds: string[] = [];
92
+ for (const p of this.state.participants) {
93
+ if (p.isLocalParticipant) continue;
94
+ const { audioStream, screenShareAudioStream, sessionId, userId } = p;
95
+ if (audioStream && !this.bindings.has(toBindingKey(sessionId))) {
96
+ danglingUserIds.push(userId);
97
+ }
98
+ if (
99
+ screenShareAudioStream &&
100
+ !this.bindings.has(toBindingKey(sessionId, 'screenShareAudioTrack'))
101
+ ) {
102
+ danglingUserIds.push(userId);
103
+ }
104
+ }
105
+ if (danglingUserIds.length > 0) {
106
+ const key = 'audioBinding.danglingWarning';
107
+ this.tracer.traceOnce(key, key, danglingUserIds);
108
+ this.logger.warn(
109
+ `Dangling audio bindings detected. Did you forget to bind the audio element? user_ids: ${danglingUserIds}.`,
110
+ );
111
+ }
112
+ }, 3000);
113
+ };
114
+
115
+ private stop = () => {
116
+ clearInterval(this.watchdogInterval);
117
+ };
118
+ }
@@ -15,14 +15,16 @@ import {
15
15
  takeWhile,
16
16
  } from 'rxjs';
17
17
  import { ViewportTracker } from './ViewportTracker';
18
+ import { AudioBindingsWatchdog } from './AudioBindingsWatchdog';
18
19
  import { isFirefox, isSafari } from './browsers';
20
+ import { isReactNative } from './platforms';
19
21
  import {
20
22
  hasScreenShare,
21
23
  hasScreenShareAudio,
22
24
  hasVideo,
23
25
  } from './participantUtils';
24
26
  import type { TrackSubscriptionDetails } from '../gen/video/sfu/signal_rpc/signal';
25
- import type { CallState } from '../store';
27
+ import { CallState } from '../store';
26
28
  import type { StreamSfuClient } from '../StreamSfuClient';
27
29
  import { SpeakerManager } from '../devices';
28
30
  import { getCurrentValue, setCurrentValue } from '../store/rxUtils';
@@ -71,10 +73,11 @@ export class DynascaleManager {
71
73
  private callState: CallState;
72
74
  private speaker: SpeakerManager;
73
75
  private tracer: Tracer;
74
- private useWebAudio = isSafari();
76
+ private useWebAudio = false;
75
77
  private audioContext: AudioContext | undefined;
76
78
  private sfuClient: StreamSfuClient | undefined;
77
79
  private pendingSubscriptionsUpdate: NodeJS.Timeout | null = null;
80
+ readonly audioBindingsWatchdog: AudioBindingsWatchdog | undefined;
78
81
 
79
82
  private videoTrackSubscriptionOverridesSubject =
80
83
  new BehaviorSubject<VideoTrackSubscriptionOverrides>({});
@@ -120,6 +123,9 @@ export class DynascaleManager {
120
123
  this.callState = callState;
121
124
  this.speaker = speaker;
122
125
  this.tracer = tracer;
126
+ if (!isReactNative()) {
127
+ this.audioBindingsWatchdog = new AudioBindingsWatchdog(callState, tracer);
128
+ }
123
129
  }
124
130
 
125
131
  /**
@@ -129,7 +135,8 @@ export class DynascaleManager {
129
135
  if (this.pendingSubscriptionsUpdate) {
130
136
  clearTimeout(this.pendingSubscriptionsUpdate);
131
137
  }
132
- const context = this.getOrCreateAudioContext();
138
+ this.audioBindingsWatchdog?.dispose();
139
+ const context = this.audioContext;
133
140
  if (context && context.state !== 'closed') {
134
141
  document.removeEventListener('click', this.resumeAudioContext);
135
142
  await context.close();
@@ -447,6 +454,7 @@ export class DynascaleManager {
447
454
  });
448
455
  resizeObserver?.observe(videoElement);
449
456
 
457
+ const isVideoTrack = trackType === 'videoTrack';
450
458
  // element renders and gets bound - track subscription gets
451
459
  // triggered first other ones get skipped on initial subscriptions
452
460
  const publishedTracksSubscription = boundParticipant.isLocalParticipant
@@ -454,9 +462,7 @@ export class DynascaleManager {
454
462
  : participant$
455
463
  .pipe(
456
464
  distinctUntilKeyChanged('publishedTracks'),
457
- map((p) =>
458
- trackType === 'videoTrack' ? hasVideo(p) : hasScreenShare(p),
459
- ),
465
+ map((p) => (isVideoTrack ? hasVideo(p) : hasScreenShare(p))),
460
466
  distinctUntilChanged(),
461
467
  )
462
468
  .subscribe((isPublishing) => {
@@ -480,15 +486,11 @@ export class DynascaleManager {
480
486
  // https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
481
487
  videoElement.muted = true;
482
488
 
489
+ const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
483
490
  const streamSubscription = participant$
484
- .pipe(
485
- distinctUntilKeyChanged(
486
- trackType === 'videoTrack' ? 'videoStream' : 'screenShareStream',
487
- ),
488
- )
491
+ .pipe(distinctUntilKeyChanged(trackKey))
489
492
  .subscribe((p) => {
490
- const source =
491
- trackType === 'videoTrack' ? p.videoStream : p.screenShareStream;
493
+ const source = isVideoTrack ? p.videoStream : p.screenShareStream;
492
494
  if (videoElement.srcObject === source) return;
493
495
  videoElement.srcObject = source ?? null;
494
496
  if (isSafari() || isFirefox()) {
@@ -532,6 +534,8 @@ export class DynascaleManager {
532
534
  const participant = this.callState.findParticipantBySessionId(sessionId);
533
535
  if (!participant || participant.isLocalParticipant) return;
534
536
 
537
+ this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
538
+
535
539
  const participant$ = this.callState.participants$.pipe(
536
540
  map((ps) => ps.find((p) => p.sessionId === sessionId)),
537
541
  takeWhile((p) => !!p),
@@ -561,19 +565,12 @@ export class DynascaleManager {
561
565
  let sourceNode: MediaStreamAudioSourceNode | undefined = undefined;
562
566
  let gainNode: GainNode | undefined = undefined;
563
567
 
568
+ const isAudioTrack = trackType === 'audioTrack';
569
+ const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
564
570
  const updateMediaStreamSubscription = participant$
565
- .pipe(
566
- distinctUntilKeyChanged(
567
- trackType === 'screenShareAudioTrack'
568
- ? 'screenShareAudioStream'
569
- : 'audioStream',
570
- ),
571
- )
571
+ .pipe(distinctUntilKeyChanged(trackKey))
572
572
  .subscribe((p) => {
573
- const source =
574
- trackType === 'screenShareAudioTrack'
575
- ? p.screenShareAudioStream
576
- : p.audioStream;
573
+ const source = isAudioTrack ? p.audioStream : p.screenShareAudioStream;
577
574
  if (audioElement.srcObject === source) return;
578
575
 
579
576
  setTimeout(() => {
@@ -630,6 +627,7 @@ export class DynascaleManager {
630
627
  audioElement.autoplay = true;
631
628
 
632
629
  return () => {
630
+ this.audioBindingsWatchdog?.unregister(sessionId, trackType);
633
631
  sinkIdSubscription?.unsubscribe();
634
632
  volumeSubscription.unsubscribe();
635
633
  updateMediaStreamSubscription.unsubscribe();
@@ -0,0 +1,325 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+
5
+ import '../../rtc/__tests__/mocks/webrtc.mocks';
6
+
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import { AudioBindingsWatchdog } from '../AudioBindingsWatchdog';
9
+ import { Call } from '../../Call';
10
+ import { StreamClient } from '../../coordinator/connection/client';
11
+ import { CallingState, StreamVideoWriteableStateStore } from '../../store';
12
+ import { noopComparator } from '../../sorting';
13
+ import { fromPartial } from '@total-typescript/shoehorn';
14
+
15
+ describe('AudioBindingsWatchdog', () => {
16
+ let watchdog: AudioBindingsWatchdog;
17
+ let call: Call;
18
+
19
+ beforeEach(() => {
20
+ vi.useFakeTimers();
21
+ call = new Call({
22
+ id: 'id',
23
+ type: 'default',
24
+ streamClient: new StreamClient('api-key', {
25
+ devicePersistence: { enabled: false },
26
+ }),
27
+ clientStore: new StreamVideoWriteableStateStore(),
28
+ });
29
+ call.setSortParticipantsBy(noopComparator());
30
+ watchdog = new AudioBindingsWatchdog(call.state, call.tracer);
31
+ });
32
+
33
+ afterEach(() => {
34
+ watchdog.dispose();
35
+ call.leave();
36
+ vi.useRealTimers();
37
+ });
38
+
39
+ const addRemoteParticipant = (
40
+ sessionId: string,
41
+ userId: string,
42
+ streams?: {
43
+ audioStream?: MediaStream;
44
+ screenShareAudioStream?: MediaStream;
45
+ },
46
+ ) => {
47
+ call.state.updateOrAddParticipant(
48
+ sessionId,
49
+ fromPartial({
50
+ userId,
51
+ sessionId,
52
+ publishedTracks: [],
53
+ ...streams,
54
+ }),
55
+ );
56
+ };
57
+
58
+ it('should warn about dangling audio streams when active', () => {
59
+ // @ts-expect-error private property
60
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
61
+
62
+ addRemoteParticipant('session-1', 'user-1', {
63
+ audioStream: new MediaStream(),
64
+ });
65
+
66
+ call.state.setCallingState(CallingState.JOINED);
67
+ vi.advanceTimersByTime(3000);
68
+
69
+ expect(warnSpy).toHaveBeenCalledWith(
70
+ expect.stringContaining('Dangling audio bindings detected'),
71
+ );
72
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('user-1'));
73
+ });
74
+
75
+ it('should not warn when all audio elements are bound', () => {
76
+ // @ts-expect-error private property
77
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
78
+
79
+ addRemoteParticipant('session-1', 'user-1', {
80
+ audioStream: new MediaStream(),
81
+ });
82
+ watchdog.register(
83
+ document.createElement('audio'),
84
+ 'session-1',
85
+ 'audioTrack',
86
+ );
87
+
88
+ call.state.setCallingState(CallingState.JOINED);
89
+ vi.advanceTimersByTime(3000);
90
+
91
+ expect(warnSpy).not.toHaveBeenCalled();
92
+ });
93
+
94
+ it('should skip local participant', () => {
95
+ // @ts-expect-error private property
96
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
97
+
98
+ // @ts-expect-error incomplete data
99
+ call.state.updateOrAddParticipant('local-session', {
100
+ userId: 'local-user',
101
+ sessionId: 'local-session',
102
+ isLocalParticipant: true,
103
+ publishedTracks: [],
104
+ audioStream: new MediaStream(),
105
+ });
106
+
107
+ call.state.setCallingState(CallingState.JOINED);
108
+ vi.advanceTimersByTime(3000);
109
+
110
+ expect(warnSpy).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('should start on JOINED and stop on non-JOINED state', () => {
114
+ // @ts-expect-error private property
115
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
116
+
117
+ addRemoteParticipant('session-1', 'user-1', {
118
+ audioStream: new MediaStream(),
119
+ });
120
+
121
+ call.state.setCallingState(CallingState.JOINED);
122
+ vi.advanceTimersByTime(3000);
123
+ expect(warnSpy).toHaveBeenCalled();
124
+
125
+ warnSpy.mockClear();
126
+
127
+ call.state.setCallingState(CallingState.IDLE);
128
+ vi.advanceTimersByTime(6000);
129
+
130
+ expect(warnSpy).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it('should be disableable via setEnabled', () => {
134
+ // @ts-expect-error private property
135
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
136
+
137
+ addRemoteParticipant('session-1', 'user-1', {
138
+ audioStream: new MediaStream(),
139
+ });
140
+
141
+ watchdog.setEnabled(false);
142
+
143
+ call.state.setCallingState(CallingState.JOINED);
144
+ vi.advanceTimersByTime(6000);
145
+
146
+ expect(warnSpy).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it('should re-enable after disabling', () => {
150
+ // @ts-expect-error private property
151
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
152
+
153
+ addRemoteParticipant('session-1', 'user-1', {
154
+ audioStream: new MediaStream(),
155
+ });
156
+
157
+ watchdog.setEnabled(false);
158
+ watchdog.setEnabled(true);
159
+
160
+ vi.advanceTimersByTime(3000);
161
+
162
+ expect(warnSpy).toHaveBeenCalledWith(
163
+ expect.stringContaining('Dangling audio bindings detected'),
164
+ );
165
+ });
166
+
167
+ it('should warn when binding a different element to the same key', () => {
168
+ // @ts-expect-error private property
169
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
170
+
171
+ const audioElement1 = document.createElement('audio');
172
+ const audioElement2 = document.createElement('audio');
173
+
174
+ watchdog.register(audioElement1, 'session-1', 'audioTrack');
175
+ watchdog.register(audioElement2, 'session-1', 'audioTrack');
176
+
177
+ expect(warnSpy).toHaveBeenCalledWith(
178
+ expect.stringContaining('Audio element already bound'),
179
+ );
180
+ });
181
+
182
+ it('should not warn when re-binding the same element', () => {
183
+ // @ts-expect-error private property
184
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
185
+
186
+ const audioElement = document.createElement('audio');
187
+
188
+ watchdog.register(audioElement, 'session-1', 'audioTrack');
189
+ watchdog.register(audioElement, 'session-1', 'audioTrack');
190
+
191
+ expect(warnSpy).not.toHaveBeenCalled();
192
+ });
193
+
194
+ it('unregisterBinding should remove the binding', () => {
195
+ // @ts-expect-error private property
196
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
197
+
198
+ addRemoteParticipant('session-1', 'user-1', {
199
+ audioStream: new MediaStream(),
200
+ });
201
+ watchdog.register(
202
+ document.createElement('audio'),
203
+ 'session-1',
204
+ 'audioTrack',
205
+ );
206
+
207
+ call.state.setCallingState(CallingState.JOINED);
208
+ vi.advanceTimersByTime(3000);
209
+ expect(warnSpy).not.toHaveBeenCalled();
210
+
211
+ watchdog.unregister('session-1', 'audioTrack');
212
+ vi.advanceTimersByTime(3000);
213
+
214
+ expect(warnSpy).toHaveBeenCalledWith(
215
+ expect.stringContaining('Dangling audio bindings detected'),
216
+ );
217
+ });
218
+
219
+ it('should warn about dangling screenShareAudioStream', () => {
220
+ // @ts-expect-error private property
221
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
222
+
223
+ addRemoteParticipant('session-1', 'user-1', {
224
+ screenShareAudioStream: new MediaStream(),
225
+ });
226
+
227
+ call.state.setCallingState(CallingState.JOINED);
228
+ vi.advanceTimersByTime(3000);
229
+
230
+ expect(warnSpy).toHaveBeenCalledWith(
231
+ expect.stringContaining('Dangling audio bindings detected'),
232
+ );
233
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('user-1'));
234
+ });
235
+
236
+ it('should not warn when screenShareAudio element is bound', () => {
237
+ // @ts-expect-error private property
238
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
239
+
240
+ addRemoteParticipant('session-1', 'user-1', {
241
+ screenShareAudioStream: new MediaStream(),
242
+ });
243
+ watchdog.register(
244
+ document.createElement('audio'),
245
+ 'session-1',
246
+ 'screenShareAudioTrack',
247
+ );
248
+
249
+ call.state.setCallingState(CallingState.JOINED);
250
+ vi.advanceTimersByTime(3000);
251
+
252
+ expect(warnSpy).not.toHaveBeenCalled();
253
+ });
254
+
255
+ it('should warn only about the unbound track type', () => {
256
+ // @ts-expect-error private property
257
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
258
+
259
+ addRemoteParticipant('session-1', 'user-1', {
260
+ audioStream: new MediaStream(),
261
+ screenShareAudioStream: new MediaStream(),
262
+ });
263
+
264
+ // bind only the regular audio track
265
+ watchdog.register(
266
+ document.createElement('audio'),
267
+ 'session-1',
268
+ 'audioTrack',
269
+ );
270
+
271
+ call.state.setCallingState(CallingState.JOINED);
272
+ vi.advanceTimersByTime(3000);
273
+
274
+ // should still warn because screenShareAudio is unbound
275
+ expect(warnSpy).toHaveBeenCalledWith(
276
+ expect.stringContaining('Dangling audio bindings detected'),
277
+ );
278
+ });
279
+
280
+ it('should not warn when both audio and screenShareAudio are bound', () => {
281
+ // @ts-expect-error private property
282
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
283
+
284
+ addRemoteParticipant('session-1', 'user-1', {
285
+ audioStream: new MediaStream(),
286
+ screenShareAudioStream: new MediaStream(),
287
+ });
288
+
289
+ watchdog.register(
290
+ document.createElement('audio'),
291
+ 'session-1',
292
+ 'audioTrack',
293
+ );
294
+ watchdog.register(
295
+ document.createElement('audio'),
296
+ 'session-1',
297
+ 'screenShareAudioTrack',
298
+ );
299
+
300
+ call.state.setCallingState(CallingState.JOINED);
301
+ vi.advanceTimersByTime(3000);
302
+
303
+ expect(warnSpy).not.toHaveBeenCalled();
304
+ });
305
+
306
+ it('dispose should stop the watchdog', () => {
307
+ // @ts-expect-error private property
308
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
309
+
310
+ addRemoteParticipant('session-1', 'user-1', {
311
+ audioStream: new MediaStream(),
312
+ });
313
+
314
+ call.state.setCallingState(CallingState.JOINED);
315
+ vi.advanceTimersByTime(3000);
316
+ expect(warnSpy).toHaveBeenCalled();
317
+
318
+ warnSpy.mockClear();
319
+
320
+ watchdog.dispose();
321
+ vi.advanceTimersByTime(6000);
322
+
323
+ expect(warnSpy).not.toHaveBeenCalled();
324
+ });
325
+ });