@stream-io/video-client 1.44.4 → 1.44.6-beta.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.
@@ -162,7 +162,7 @@ export declare class Call {
162
162
  /**
163
163
  * A flag indicating whether the call was created by the current user.
164
164
  */
165
- get isCreatedByMe(): boolean;
165
+ get isCreatedByMe(): boolean | "" | undefined;
166
166
  /**
167
167
  * Update from the call response from the "call.ring" event
168
168
  * @internal
@@ -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<{
@@ -5,6 +5,7 @@ import type { RejectReason, StreamClientOptions, TokenProvider, User } from './c
5
5
  import type { Comparator } from './sorting';
6
6
  import type { StreamVideoWriteableStateStore } from './store';
7
7
  import { AxiosError } from 'axios';
8
+ import type { Call } from './Call';
8
9
  export type StreamReaction = Pick<ReactionResponse, 'type' | 'emoji_code' | 'custom'>;
9
10
  export declare enum VisibilityState {
10
11
  UNKNOWN = "UNKNOWN",
@@ -316,22 +317,53 @@ export type StartCallRecordingFnType = {
316
317
  (request: StartRecordingRequest): Promise<StartRecordingResponse>;
317
318
  (request: StartRecordingRequest, type: CallRecordingType): Promise<StartRecordingResponse>;
318
319
  };
320
+ type StreamRNVideoSDKCallManagerRingingParams = {
321
+ isRingingTypeCall: boolean;
322
+ };
323
+ type StreamRNVideoSDKCallManagerSetupParams = StreamRNVideoSDKCallManagerRingingParams & {
324
+ defaultDevice: AudioSettingsRequestDefaultDeviceEnum;
325
+ };
326
+ type StreamRNVideoSDKEndCallReason =
327
+ /** Call ended by the local user (e.g., hanging up). */
328
+ 'local'
329
+ /** Call ended by the remote party, or outgoing call was not answered. */
330
+ | 'remote'
331
+ /** Call was rejected/declined by the user. */
332
+ | 'rejected'
333
+ /** Remote party was busy. */
334
+ | 'busy'
335
+ /** Call was answered on another device. */
336
+ | 'answeredElsewhere'
337
+ /** No response to an incoming call. */
338
+ | 'missed'
339
+ /** Call failed due to an error (e.g., network issue). */
340
+ | 'error'
341
+ /** Call was canceled before the remote party could answer. */
342
+ | 'canceled'
343
+ /** Call restricted (e.g., airplane mode, dialing restrictions). */
344
+ | 'restricted'
345
+ /** Unknown or unspecified disconnect reason. */
346
+ | 'unknown';
347
+ type StreamRNVideoSDKCallingX = {
348
+ joinCall: (call: Call, activeCalls: Call[]) => Promise<void>;
349
+ endCall: (call: Call, reason?: StreamRNVideoSDKEndCallReason) => Promise<void>;
350
+ registerOutgoingCall: (call: Call) => Promise<void>;
351
+ };
319
352
  export type StreamRNVideoSDKGlobals = {
353
+ callingX: StreamRNVideoSDKCallingX;
320
354
  callManager: {
321
355
  /**
322
356
  * Sets up the in call manager.
323
357
  */
324
- setup({ defaultDevice, }: {
325
- defaultDevice: AudioSettingsRequestDefaultDeviceEnum;
326
- }): void;
358
+ setup({ defaultDevice, isRingingTypeCall, }: StreamRNVideoSDKCallManagerSetupParams): void;
327
359
  /**
328
360
  * Starts the in call manager.
329
361
  */
330
- start(): void;
362
+ start({ isRingingTypeCall, }: StreamRNVideoSDKCallManagerRingingParams): void;
331
363
  /**
332
364
  * Stops the in call manager.
333
365
  */
334
- stop(): void;
366
+ stop({ isRingingTypeCall }: StreamRNVideoSDKCallManagerRingingParams): void;
335
367
  };
336
368
  permissions: {
337
369
  /**
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.6-beta.0",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
package/src/Call.ts CHANGED
@@ -421,6 +421,7 @@ export class Call {
421
421
  const currentUserId = this.currentUserId;
422
422
  if (currentUserId && blockedUserIds.includes(currentUserId)) {
423
423
  this.logger.info('Leaving call because of being blocked');
424
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'restricted');
424
425
  await this.leave({ message: 'user blocked' }).catch((err) => {
425
426
  this.logger.error('Error leaving call after being blocked', err);
426
427
  });
@@ -465,6 +466,10 @@ export class Call {
465
466
  (isAcceptedElsewhere || isRejectedByMe) &&
466
467
  !hasPending(this.joinLeaveConcurrencyTag)
467
468
  ) {
469
+ globalThis.streamRNVideoSDK?.callingX?.endCall(
470
+ this,
471
+ isAcceptedElsewhere ? 'answeredElsewhere' : 'rejected',
472
+ );
468
473
  this.leave().catch(() => {
469
474
  this.logger.error(
470
475
  'Could not leave a call that was accepted or rejected elsewhere',
@@ -480,6 +485,10 @@ export class Call {
480
485
  const receiver_id = this.clientStore.connectedUser?.id;
481
486
  const ended_at = callSession?.ended_at;
482
487
  const created_by_id = this.state.createdBy?.id;
488
+
489
+ if (this.currentUserId && created_by_id === this.currentUserId) {
490
+ globalThis.streamRNVideoSDK?.callingX?.registerOutgoingCall(this);
491
+ }
483
492
  const rejected_by = callSession?.rejected_by;
484
493
  const accepted_by = callSession?.accepted_by;
485
494
  let leaveCallIdle = false;
@@ -636,16 +645,30 @@ export class Call {
636
645
 
637
646
  if (callingState === CallingState.RINGING && reject !== false) {
638
647
  if (reject) {
639
- await this.reject(reason ?? 'decline');
648
+ const reasonToEndCallReason = {
649
+ timeout: 'missed',
650
+ cancel: 'canceled',
651
+ busy: 'busy',
652
+ decline: 'rejected',
653
+ } as const;
654
+ const rejectReason = reason ?? 'decline';
655
+ const endCallReason =
656
+ reasonToEndCallReason[
657
+ rejectReason as keyof typeof reasonToEndCallReason
658
+ ] ?? 'rejected';
659
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, endCallReason);
660
+ await this.reject(rejectReason);
640
661
  } else {
641
662
  // if reject was undefined, we still have to cancel the call automatically
642
663
  // when I am the creator and everyone else left the call
643
664
  const hasOtherParticipants = this.state.remoteParticipants.length > 0;
644
665
  if (this.isCreatedByMe && !hasOtherParticipants) {
666
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'canceled');
645
667
  await this.reject('cancel');
646
668
  }
647
669
  }
648
670
  }
671
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this);
649
672
 
650
673
  this.statsReporter?.stop();
651
674
  this.statsReporter = undefined;
@@ -680,7 +703,9 @@ export class Call {
680
703
  this.cancelAutoDrop();
681
704
  this.clientStore.unregisterCall(this);
682
705
 
683
- globalThis.streamRNVideoSDK?.callManager.stop();
706
+ globalThis.streamRNVideoSDK?.callManager.stop({
707
+ isRingingTypeCall: this.ringing,
708
+ });
684
709
 
685
710
  this.camera.dispose();
686
711
  this.microphone.dispose();
@@ -720,7 +745,9 @@ export class Call {
720
745
  * A flag indicating whether the call was created by the current user.
721
746
  */
722
747
  get isCreatedByMe() {
723
- return this.state.createdBy?.id === this.currentUserId;
748
+ return (
749
+ this.currentUserId && this.state.createdBy?.id === this.currentUserId
750
+ );
724
751
  }
725
752
 
726
753
  /**
@@ -766,6 +793,7 @@ export class Call {
766
793
  video?: boolean;
767
794
  }): Promise<GetCallResponse> => {
768
795
  await this.setup();
796
+
769
797
  const response = await this.streamClient.get<GetCallResponse>(
770
798
  this.streamClientBasePath,
771
799
  params,
@@ -805,6 +833,7 @@ export class Call {
805
833
  */
806
834
  getOrCreate = async (data?: GetOrCreateCallRequest) => {
807
835
  await this.setup();
836
+
808
837
  const response = await this.streamClient.post<
809
838
  GetOrCreateCallResponse,
810
839
  GetOrCreateCallRequest
@@ -930,60 +959,73 @@ export class Call {
930
959
  joinResponseTimeout?: number;
931
960
  rpcRequestTimeout?: number;
932
961
  } = {}): Promise<void> => {
933
- await this.setup();
934
962
  const callingState = this.state.callingState;
935
963
 
936
964
  if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
937
965
  throw new Error(`Illegal State: call.join() shall be called only once`);
938
966
  }
939
967
 
968
+ if (data?.ring) {
969
+ this.ringingSubject.next(true);
970
+ }
971
+ const callingX = globalThis.streamRNVideoSDK?.callingX;
972
+ if (callingX) {
973
+ // for Android/iOS, we need to start the call in the callingx library as soon as possible
974
+ await callingX.joinCall(this, this.clientStore.calls);
975
+ }
976
+
977
+ await this.setup();
978
+
940
979
  this.joinResponseTimeout = joinResponseTimeout;
941
980
  this.rpcRequestTimeout = rpcRequestTimeout;
942
-
943
981
  // we will count the number of join failures per SFU.
944
982
  // once the number of failures reaches 2, we will piggyback on the `migrating_from`
945
983
  // field to force the coordinator to provide us another SFU
946
984
  const sfuJoinFailures = new Map<string, number>();
947
985
  const joinData: JoinCallData = data;
948
986
  maxJoinRetries = Math.max(maxJoinRetries, 1);
949
- for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
950
- try {
951
- this.logger.trace(`Joining call (${attempt})`, this.cid);
952
- await this.doJoin(data);
953
- delete joinData.migrating_from;
954
- delete joinData.migrating_from_list;
955
- break;
956
- } catch (err) {
957
- this.logger.warn(`Failed to join call (${attempt})`, this.cid);
958
- if (
959
- (err instanceof ErrorFromResponse && err.unrecoverable) ||
960
- (err instanceof SfuJoinError && err.unrecoverable)
961
- ) {
962
- // if the error is unrecoverable, we should not retry as that signals
963
- // that connectivity is good, but the coordinator doesn't allow the user
964
- // to join the call due to some reason (e.g., ended call, expired token...)
965
- throw err;
966
- }
987
+ try {
988
+ for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
989
+ try {
990
+ this.logger.trace(`Joining call (${attempt})`, this.cid);
991
+ await this.doJoin(data);
992
+ delete joinData.migrating_from;
993
+ delete joinData.migrating_from_list;
994
+ break;
995
+ } catch (err) {
996
+ this.logger.warn(`Failed to join call (${attempt})`, this.cid);
997
+ if (
998
+ (err instanceof ErrorFromResponse && err.unrecoverable) ||
999
+ (err instanceof SfuJoinError && err.unrecoverable)
1000
+ ) {
1001
+ // if the error is unrecoverable, we should not retry as that signals
1002
+ // that connectivity is good, but the coordinator doesn't allow the user
1003
+ // to join the call due to some reason (e.g., ended call, expired token...)
1004
+ throw err;
1005
+ }
967
1006
 
968
- // immediately switch to a different SFU in case of recoverable join error
969
- const switchSfu =
970
- err instanceof SfuJoinError &&
971
- SfuJoinError.isJoinErrorCode(err.errorEvent);
972
-
973
- const sfuId = this.credentials?.server.edge_name || '';
974
- const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
975
- sfuJoinFailures.set(sfuId, failures);
976
- if (switchSfu || failures >= 2) {
977
- joinData.migrating_from = sfuId;
978
- joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
979
- }
1007
+ // immediately switch to a different SFU in case of recoverable join error
1008
+ const switchSfu =
1009
+ err instanceof SfuJoinError &&
1010
+ SfuJoinError.isJoinErrorCode(err.errorEvent);
1011
+
1012
+ const sfuId = this.credentials?.server.edge_name || '';
1013
+ const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
1014
+ sfuJoinFailures.set(sfuId, failures);
1015
+ if (switchSfu || failures >= 2) {
1016
+ joinData.migrating_from = sfuId;
1017
+ joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
1018
+ }
980
1019
 
981
- if (attempt === maxJoinRetries - 1) {
982
- throw err;
1020
+ if (attempt === maxJoinRetries - 1) {
1021
+ throw err;
1022
+ }
983
1023
  }
1024
+ await sleep(retryInterval(attempt));
984
1025
  }
985
-
986
- await sleep(retryInterval(attempt));
1026
+ } catch (error) {
1027
+ callingX?.endCall(this, 'error');
1028
+ throw error;
987
1029
  }
988
1030
  };
989
1031
 
@@ -1166,7 +1208,9 @@ export class Call {
1166
1208
  // re-apply them on later reconnections or server-side data fetches
1167
1209
  if (!this.deviceSettingsAppliedOnce && this.state.settings) {
1168
1210
  await this.applyDeviceConfig(this.state.settings, true, false);
1169
- globalThis.streamRNVideoSDK?.callManager.start();
1211
+ globalThis.streamRNVideoSDK?.callManager.start({
1212
+ isRingingTypeCall: this.ringing,
1213
+ });
1170
1214
  this.deviceSettingsAppliedOnce = true;
1171
1215
  }
1172
1216
 
@@ -1711,6 +1755,7 @@ export class Call {
1711
1755
  if (SfuJoinError.isJoinErrorCode(e)) return;
1712
1756
  if (strategy === WebsocketReconnectStrategy.UNSPECIFIED) return;
1713
1757
  if (strategy === WebsocketReconnectStrategy.DISCONNECT) {
1758
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'error');
1714
1759
  this.leave({ message: 'SFU instructed to disconnect' }).catch((err) => {
1715
1760
  this.logger.warn(`Can't leave call after disconnect request`, err);
1716
1761
  });
@@ -85,6 +85,7 @@ export class SpeakerManager {
85
85
  this.defaultDevice = defaultDevice;
86
86
  globalThis.streamRNVideoSDK?.callManager.setup({
87
87
  defaultDevice,
88
+ isRingingTypeCall: this.call.ringing,
88
89
  });
89
90
  }
90
91
  }
@@ -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
  };
@@ -69,6 +69,7 @@ export const watchCallRejected = (call: Call) => {
69
69
  } else {
70
70
  if (rejectedBy[eventCall.created_by.id]) {
71
71
  call.logger.info('call creator rejected, leaving call');
72
+ globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
72
73
  await call.leave({ message: 'ring: creator rejected' });
73
74
  }
74
75
  }
@@ -80,6 +81,7 @@ export const watchCallRejected = (call: Call) => {
80
81
  */
81
82
  export const watchCallEnded = (call: Call) => {
82
83
  return function onCallEnded() {
84
+ globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
83
85
  const { callingState } = call.state;
84
86
  if (
85
87
  callingState !== CallingState.IDLE &&
@@ -113,6 +115,7 @@ export const watchSfuCallEnded = (call: Call) => {
113
115
  // update the call state to reflect the call has ended.
114
116
  call.state.setEndedAt(new Date());
115
117
  const reason = CallEndedReason[e.reason];
118
+ globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
116
119
  await call.leave({ message: `callEnded received: ${reason}` });
117
120
  } catch (err) {
118
121
  call.logger.error(
@@ -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();