@stream-io/video-client 1.45.0 → 1.46.1
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 +20 -0
- package/dist/index.browser.es.js +186 -49
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +186 -49
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +186 -49
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +5 -1
- package/dist/src/gen/video/sfu/event/events.d.ts +4 -0
- package/dist/src/helpers/DynascaleManager.d.ts +20 -0
- package/dist/src/helpers/RNSpeechDetector.d.ts +4 -2
- package/dist/src/types.d.ts +37 -5
- package/package.json +1 -1
- package/src/Call.ts +92 -40
- package/src/devices/MicrophoneManager.ts +7 -1
- package/src/devices/SpeakerManager.ts +1 -0
- package/src/events/__tests__/participant.test.ts +41 -0
- package/src/events/call.ts +3 -0
- package/src/events/participant.ts +1 -0
- package/src/gen/video/sfu/event/events.ts +5 -0
- package/src/helpers/DynascaleManager.ts +72 -1
- package/src/helpers/RNSpeechDetector.ts +52 -12
- package/src/helpers/__tests__/DynascaleManager.test.ts +120 -0
- package/src/helpers/__tests__/RNSpeechDetector.test.ts +52 -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
|
|
@@ -804,6 +804,10 @@ export declare class Call {
|
|
|
804
804
|
* @param trackType the kind of audio.
|
|
805
805
|
*/
|
|
806
806
|
bindAudioElement: (audioElement: HTMLAudioElement, sessionId: string, trackType?: AudioTrackType) => (() => void) | undefined;
|
|
807
|
+
/**
|
|
808
|
+
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
809
|
+
*/
|
|
810
|
+
resumeAudio: () => Promise<void>;
|
|
807
811
|
/**
|
|
808
812
|
* Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
|
|
809
813
|
*
|
|
@@ -574,6 +574,10 @@ export interface ParticipantJoined {
|
|
|
574
574
|
* @generated from protobuf field: stream.video.sfu.models.Participant participant = 2;
|
|
575
575
|
*/
|
|
576
576
|
participant?: Participant;
|
|
577
|
+
/**
|
|
578
|
+
* @generated from protobuf field: bool is_pinned = 3;
|
|
579
|
+
*/
|
|
580
|
+
isPinned: boolean;
|
|
577
581
|
}
|
|
578
582
|
/**
|
|
579
583
|
* ParticipantJoined is fired when a user leaves a call
|
|
@@ -42,6 +42,19 @@ export declare class DynascaleManager {
|
|
|
42
42
|
private sfuClient;
|
|
43
43
|
private pendingSubscriptionsUpdate;
|
|
44
44
|
readonly audioBindingsWatchdog: AudioBindingsWatchdog | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Audio elements that were blocked by the browser's autoplay policy.
|
|
47
|
+
* These can be retried by calling `resumeAudio()` from a user gesture.
|
|
48
|
+
*/
|
|
49
|
+
private blockedAudioElementsSubject;
|
|
50
|
+
/**
|
|
51
|
+
* Whether the browser's autoplay policy is blocking audio playback.
|
|
52
|
+
* Will be `true` when the browser blocks autoplay (e.g., no prior user interaction).
|
|
53
|
+
* Use `resumeAudio()` within a user gesture to unblock.
|
|
54
|
+
*/
|
|
55
|
+
autoplayBlocked$: import("rxjs").Observable<boolean>;
|
|
56
|
+
private addBlockedAudioElement;
|
|
57
|
+
private removeBlockedAudioElement;
|
|
45
58
|
private videoTrackSubscriptionOverridesSubject;
|
|
46
59
|
videoTrackSubscriptionOverrides$: import("rxjs").Observable<VideoTrackSubscriptionOverrides>;
|
|
47
60
|
incomingVideoSettings$: import("rxjs").Observable<{
|
|
@@ -121,6 +134,13 @@ export declare class DynascaleManager {
|
|
|
121
134
|
* @returns a cleanup function that will unbind the audio element.
|
|
122
135
|
*/
|
|
123
136
|
bindAudioElement: (audioElement: HTMLAudioElement, sessionId: string, trackType: AudioTrackType) => (() => void) | undefined;
|
|
137
|
+
/**
|
|
138
|
+
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
139
|
+
* Must be called from within a user gesture (e.g., click handler).
|
|
140
|
+
*
|
|
141
|
+
* @returns a promise that resolves when all blocked elements have been retried.
|
|
142
|
+
*/
|
|
143
|
+
resumeAudio: () => Promise<void>;
|
|
124
144
|
private getOrCreateAudioContext;
|
|
125
145
|
private resumeAudioContext;
|
|
126
146
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { SoundStateChangeHandler } from './sound-detector';
|
|
2
2
|
export declare class RNSpeechDetector {
|
|
3
|
-
private pc1;
|
|
4
|
-
private pc2;
|
|
3
|
+
private readonly pc1;
|
|
4
|
+
private readonly pc2;
|
|
5
5
|
private audioStream;
|
|
6
6
|
private externalAudioStream;
|
|
7
|
+
private isStopped;
|
|
7
8
|
constructor(externalAudioStream?: MediaStream);
|
|
8
9
|
/**
|
|
9
10
|
* Starts the speech detection.
|
|
@@ -18,4 +19,5 @@ export declare class RNSpeechDetector {
|
|
|
18
19
|
*/
|
|
19
20
|
private onSpeakingDetectedStateChange;
|
|
20
21
|
private cleanupAudioStream;
|
|
22
|
+
private forwardIceCandidate;
|
|
21
23
|
}
|
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
|
+
await this.reject(rejectReason);
|
|
660
|
+
globalThis.streamRNVideoSDK?.callingX?.endCall(this, endCallReason);
|
|
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) {
|
|
645
666
|
await this.reject('cancel');
|
|
667
|
+
globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'canceled');
|
|
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
|
});
|
|
@@ -2922,6 +2967,13 @@ export class Call {
|
|
|
2922
2967
|
};
|
|
2923
2968
|
};
|
|
2924
2969
|
|
|
2970
|
+
/**
|
|
2971
|
+
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
2972
|
+
*/
|
|
2973
|
+
resumeAudio = () => {
|
|
2974
|
+
return this.dynascaleManager.resumeAudio();
|
|
2975
|
+
};
|
|
2976
|
+
|
|
2925
2977
|
/**
|
|
2926
2978
|
* Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
|
|
2927
2979
|
*
|
|
@@ -158,6 +158,7 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
158
158
|
const devices = await firstValueFrom(this.listDevices());
|
|
159
159
|
const label = devices.find((d) => d.deviceId === deviceId)?.label;
|
|
160
160
|
|
|
161
|
+
let lastCapturesAudio: boolean | undefined;
|
|
161
162
|
this.noAudioDetectorCleanup = createNoAudioDetector(mediaStream, {
|
|
162
163
|
noAudioThresholdMs: this.silenceThresholdMs,
|
|
163
164
|
emitIntervalMs: this.silenceThresholdMs,
|
|
@@ -169,7 +170,12 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
169
170
|
deviceId,
|
|
170
171
|
label,
|
|
171
172
|
};
|
|
172
|
-
|
|
173
|
+
|
|
174
|
+
if (capturesAudio !== lastCapturesAudio) {
|
|
175
|
+
lastCapturesAudio = capturesAudio;
|
|
176
|
+
this.call.tracer.trace('mic.capture_report', event);
|
|
177
|
+
}
|
|
178
|
+
|
|
173
179
|
this.call.streamClient.dispatchEvent(event);
|
|
174
180
|
},
|
|
175
181
|
});
|
|
@@ -74,6 +74,47 @@ describe('Participant events', () => {
|
|
|
74
74
|
|
|
75
75
|
expect(state.participants).toEqual([]);
|
|
76
76
|
});
|
|
77
|
+
|
|
78
|
+
it('sets a server-side pin when isPinned is true', () => {
|
|
79
|
+
const state = new CallState();
|
|
80
|
+
state.setSortParticipantsBy(noopComparator());
|
|
81
|
+
|
|
82
|
+
const onParticipantJoined = watchParticipantJoined(state);
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
|
|
85
|
+
onParticipantJoined({
|
|
86
|
+
// @ts-expect-error incomplete data
|
|
87
|
+
participant: {
|
|
88
|
+
userId: 'user-id',
|
|
89
|
+
sessionId: 'session-id',
|
|
90
|
+
},
|
|
91
|
+
isPinned: true,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const participant = state.findParticipantBySessionId('session-id');
|
|
95
|
+
expect(participant?.pin).toBeDefined();
|
|
96
|
+
expect(participant?.pin?.isLocalPin).toBe(false);
|
|
97
|
+
expect(participant?.pin?.pinnedAt).toBeGreaterThanOrEqual(now);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('does not set a pin when isPinned is false', () => {
|
|
101
|
+
const state = new CallState();
|
|
102
|
+
state.setSortParticipantsBy(noopComparator());
|
|
103
|
+
|
|
104
|
+
const onParticipantJoined = watchParticipantJoined(state);
|
|
105
|
+
|
|
106
|
+
onParticipantJoined({
|
|
107
|
+
// @ts-expect-error incomplete data
|
|
108
|
+
participant: {
|
|
109
|
+
userId: 'user-id',
|
|
110
|
+
sessionId: 'session-id',
|
|
111
|
+
},
|
|
112
|
+
isPinned: false,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const participant = state.findParticipantBySessionId('session-id');
|
|
116
|
+
expect(participant?.pin).toBeUndefined();
|
|
117
|
+
});
|
|
77
118
|
});
|
|
78
119
|
|
|
79
120
|
describe('orphaned tracks reconciliation', () => {
|
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(
|
|
@@ -38,6 +38,7 @@ export const watchParticipantJoined = (state: CallState) => {
|
|
|
38
38
|
StreamVideoParticipantPatch | undefined,
|
|
39
39
|
Partial<StreamVideoParticipant>
|
|
40
40
|
>(participant, orphanedTracks, {
|
|
41
|
+
...(e.isPinned && { pin: { isLocalPin: false, pinnedAt: Date.now() } }),
|
|
41
42
|
viewportVisibilityState: {
|
|
42
43
|
videoTrack: VisibilityState.UNKNOWN,
|
|
43
44
|
screenShareTrack: VisibilityState.UNKNOWN,
|
|
@@ -625,6 +625,10 @@ export interface ParticipantJoined {
|
|
|
625
625
|
* @generated from protobuf field: stream.video.sfu.models.Participant participant = 2;
|
|
626
626
|
*/
|
|
627
627
|
participant?: Participant;
|
|
628
|
+
/**
|
|
629
|
+
* @generated from protobuf field: bool is_pinned = 3;
|
|
630
|
+
*/
|
|
631
|
+
isPinned: boolean;
|
|
628
632
|
}
|
|
629
633
|
/**
|
|
630
634
|
* ParticipantJoined is fired when a user leaves a call
|
|
@@ -1557,6 +1561,7 @@ class ParticipantJoined$Type extends MessageType<ParticipantJoined> {
|
|
|
1557
1561
|
super('stream.video.sfu.event.ParticipantJoined', [
|
|
1558
1562
|
{ no: 1, name: 'call_cid', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
|
|
1559
1563
|
{ no: 2, name: 'participant', kind: 'message', T: () => Participant },
|
|
1564
|
+
{ no: 3, name: 'is_pinned', kind: 'scalar', T: 8 /*ScalarType.BOOL*/ },
|
|
1560
1565
|
]);
|
|
1561
1566
|
}
|
|
1562
1567
|
}
|
|
@@ -79,6 +79,40 @@ export class DynascaleManager {
|
|
|
79
79
|
private pendingSubscriptionsUpdate: NodeJS.Timeout | null = null;
|
|
80
80
|
readonly audioBindingsWatchdog: AudioBindingsWatchdog | undefined;
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Audio elements that were blocked by the browser's autoplay policy.
|
|
84
|
+
* These can be retried by calling `resumeAudio()` from a user gesture.
|
|
85
|
+
*/
|
|
86
|
+
private blockedAudioElementsSubject = new BehaviorSubject<
|
|
87
|
+
Set<HTMLAudioElement>
|
|
88
|
+
>(new Set());
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether the browser's autoplay policy is blocking audio playback.
|
|
92
|
+
* Will be `true` when the browser blocks autoplay (e.g., no prior user interaction).
|
|
93
|
+
* Use `resumeAudio()` within a user gesture to unblock.
|
|
94
|
+
*/
|
|
95
|
+
autoplayBlocked$ = this.blockedAudioElementsSubject.pipe(
|
|
96
|
+
map((elements) => elements.size > 0),
|
|
97
|
+
distinctUntilChanged(),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
private addBlockedAudioElement = (audioElement: HTMLAudioElement) => {
|
|
101
|
+
setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
|
|
102
|
+
const next = new Set(elements);
|
|
103
|
+
next.add(audioElement);
|
|
104
|
+
return next;
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
private removeBlockedAudioElement = (audioElement: HTMLAudioElement) => {
|
|
109
|
+
setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
|
|
110
|
+
const nextElements = new Set(elements);
|
|
111
|
+
nextElements.delete(audioElement);
|
|
112
|
+
return nextElements;
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
82
116
|
private videoTrackSubscriptionOverridesSubject =
|
|
83
117
|
new BehaviorSubject<VideoTrackSubscriptionOverrides>({});
|
|
84
118
|
|
|
@@ -136,6 +170,7 @@ export class DynascaleManager {
|
|
|
136
170
|
clearTimeout(this.pendingSubscriptionsUpdate);
|
|
137
171
|
}
|
|
138
172
|
this.audioBindingsWatchdog?.dispose();
|
|
173
|
+
setCurrentValue(this.blockedAudioElementsSubject, new Set());
|
|
139
174
|
const context = this.audioContext;
|
|
140
175
|
if (context && context.state !== 'closed') {
|
|
141
176
|
document.removeEventListener('click', this.resumeAudioContext);
|
|
@@ -575,7 +610,10 @@ export class DynascaleManager {
|
|
|
575
610
|
|
|
576
611
|
setTimeout(() => {
|
|
577
612
|
audioElement.srcObject = source ?? null;
|
|
578
|
-
if (!source)
|
|
613
|
+
if (!source) {
|
|
614
|
+
this.removeBlockedAudioElement(audioElement);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
579
617
|
|
|
580
618
|
// Safari has a special quirk that prevents playing audio until the user
|
|
581
619
|
// interacts with the page or focuses on the tab where the call happens.
|
|
@@ -599,6 +637,10 @@ export class DynascaleManager {
|
|
|
599
637
|
audioElement.muted = false;
|
|
600
638
|
audioElement.play().catch((e) => {
|
|
601
639
|
this.tracer.trace('audioPlaybackError', e.message);
|
|
640
|
+
if (e.name === 'NotAllowedError') {
|
|
641
|
+
this.tracer.trace('audioPlaybackBlocked', null);
|
|
642
|
+
this.addBlockedAudioElement(audioElement);
|
|
643
|
+
}
|
|
602
644
|
this.logger.warn(`Failed to play audio stream`, e);
|
|
603
645
|
});
|
|
604
646
|
}
|
|
@@ -628,6 +670,7 @@ export class DynascaleManager {
|
|
|
628
670
|
|
|
629
671
|
return () => {
|
|
630
672
|
this.audioBindingsWatchdog?.unregister(sessionId, trackType);
|
|
673
|
+
this.removeBlockedAudioElement(audioElement);
|
|
631
674
|
sinkIdSubscription?.unsubscribe();
|
|
632
675
|
volumeSubscription.unsubscribe();
|
|
633
676
|
updateMediaStreamSubscription.unsubscribe();
|
|
@@ -637,6 +680,34 @@ export class DynascaleManager {
|
|
|
637
680
|
};
|
|
638
681
|
};
|
|
639
682
|
|
|
683
|
+
/**
|
|
684
|
+
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
685
|
+
* Must be called from within a user gesture (e.g., click handler).
|
|
686
|
+
*
|
|
687
|
+
* @returns a promise that resolves when all blocked elements have been retried.
|
|
688
|
+
*/
|
|
689
|
+
resumeAudio = async () => {
|
|
690
|
+
this.tracer.trace('resumeAudio', null);
|
|
691
|
+
const blocked = new Set<HTMLAudioElement>();
|
|
692
|
+
await Promise.all(
|
|
693
|
+
Array.from(
|
|
694
|
+
getCurrentValue(this.blockedAudioElementsSubject),
|
|
695
|
+
async (el) => {
|
|
696
|
+
try {
|
|
697
|
+
if (el.srcObject) {
|
|
698
|
+
await el.play();
|
|
699
|
+
}
|
|
700
|
+
} catch {
|
|
701
|
+
this.logger.warn(`Can't resume audio for element: `, el);
|
|
702
|
+
blocked.add(el);
|
|
703
|
+
}
|
|
704
|
+
},
|
|
705
|
+
),
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
setCurrentValue(this.blockedAudioElementsSubject, blocked);
|
|
709
|
+
};
|
|
710
|
+
|
|
640
711
|
private getOrCreateAudioContext = (): AudioContext | undefined => {
|
|
641
712
|
if (!this.useWebAudio) return;
|
|
642
713
|
if (this.audioContext) return this.audioContext;
|