@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.
- package/CHANGELOG.md +6 -0
- package/dist/index.browser.es.js +183 -51
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +183 -51
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +183 -51
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +1 -1
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +37 -0
- package/dist/src/helpers/DynascaleManager.d.ts +3 -1
- package/dist/src/types.d.ts +37 -5
- package/package.json +1 -1
- package/src/Call.ts +85 -40
- package/src/devices/SpeakerManager.ts +1 -0
- package/src/devices/devices.ts +1 -3
- package/src/events/call.ts +3 -0
- package/src/helpers/AudioBindingsWatchdog.ts +118 -0
- package/src/helpers/DynascaleManager.ts +22 -24
- package/src/helpers/__tests__/AudioBindingsWatchdog.test.ts +325 -0
- package/src/helpers/__tests__/DynascaleManager.test.ts +64 -0
- package/src/store/stateStore.ts +1 -1
- package/src/types.ts +48 -5
package/dist/src/Call.d.ts
CHANGED
|
@@ -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
|
|
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/dist/src/types.d.ts
CHANGED
|
@@ -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
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
|
-
|
|
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
|
|
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
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
(
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
|
|
982
|
-
|
|
1020
|
+
if (attempt === maxJoinRetries - 1) {
|
|
1021
|
+
throw err;
|
|
1022
|
+
}
|
|
983
1023
|
}
|
|
1024
|
+
await sleep(retryInterval(attempt));
|
|
984
1025
|
}
|
|
985
|
-
|
|
986
|
-
|
|
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
|
});
|
package/src/devices/devices.ts
CHANGED
|
@@ -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
|
|
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
|
};
|
package/src/events/call.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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();
|