@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.
@@ -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
  }
@@ -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.45.0",
3
+ "version": "1.46.1",
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
+ 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 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
  });
@@ -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
- this.call.tracer.trace('mic.capture_report', event);
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
  });
@@ -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
  }
@@ -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', () => {
@@ -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) return;
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;